Skip to content

Concurrent dry-run testers share one global "active virtual user" per policy, causing 422 "Block Unavailable" and identity swaps on refresh #6327

Description

@mariana-andruk-qa

Summary

When two or more real users open the same policy in dry-run mode at the same time, switching the active virtual user (VU) as one user silently reassigns the acting identity for every other user viewing that policy. Form submissions then fail with 422 Unprocessable Entity ("Block Unavailable"), and a hard refresh can land a user on a completely different virtual persona than the one they were using.

The dry-run "active virtual user" is a single pointer per policyId, shared globally across every real user/session/tab currently testing that policy. Whoever calls the persona switch last wins — for everyone.

Steps to Reproduce

  1. Two real users open the same policy in dry-run mode.
  2. User A switches their active dry-run persona to Virtual User 2 via the persona switcher.
  3. User B (who already had the policy/form open) submits a document (e.g. clicks the create/validate button on a form block).
  4. The request POST /api/v1/policies/{policyId}/blocks/{uuid} returns 422 Unprocessable Entity with message "Block Unavailable". The sync-events channel also shows failures.
  5. User B does a hard refresh. Instead of returning to their own working state, they land on User A's last-selected persona (Virtual User 2).

Expected Behavior

Each real user should be able to independently select and drive their own virtual persona within a shared dry-run policy, without affecting what other concurrent users see or are authenticated as.

Actual Behavior

There is exactly one "active virtual user" per policyId, shared globally across every real user/session/tab currently testing that policy's dry run. The last persona switch overwrites the acting identity for all concurrent testers.

Root Cause

The "who am I acting as" resolution for dry-run mode is keyed only by policyId, with no per-real-user scoping.

  • common/src/database-modules/database-server.ts
    • static setVirtualUser(policyId, did) (~L5111): iterates all VirtualUsers rows for the policy and sets active = (item.did === did) — a single shared pointer:
      public static async setVirtualUser(policyId: string, did: string): Promise<void> {
          const items = (await new DataBaseHelper(DryRun).find({
              dryRunId: policyId,
              dryRunClass: 'VirtualUsers'
          }));
          for (const item of items) {
              item.active = item.did === did;
              await new DataBaseHelper(DryRun).save(item);
          }
      }
    • static getVirtualUser(policyId) (~L4109): reads that single active: true pointer back, filtered only by dryRunId: policyId + dryRunClass: 'VirtualUsers'.
  • guardian-service/src/policy-engine/policy-components-utils.ts
    • GetUserRoleByPolicy (~L62) and GetUserRole (~L129) both override the caller's identity with the single shared pointer when the policy is in dry-run mode:
      if (PolicyHelper.isDryRunMode(policy)) {
          const activeUser = await DatabaseServer.getVirtualUser(policyId);
          if (activeUser && did !== activeUser.did) {
              did = activeUser.did;
              permissions = [];
          }
      }
      This is the path hit on page load / refresh (policy() info), which is why a hard refresh moves the user onto whichever persona was selected last.
  • policy-service/src/policy-engine/policy-components-utils.ts
    • GetPolicyUserByName (~L1344) and GetActiveVirtualUser (~L1440) resolve the acting identity via DatabaseServer.getVirtualUser(instance.policyId), again with no real user id in the lookup. The 422 "Block Unavailable" is the block engine correctly refusing to serve a block to a role that doesn't match the now-swapped active identity — a symptom, not the bug.
  • api-gateway/src/api/service/policy.ts
    • POST /:policyId/dry-run/login (~L6195) calls loginVirtualUser (~L6259), which calls the shared setVirtualUser above.

Design note: the dry-run feature appears to have been designed for a single tester driving multiple simulated actors sequentially, not for concurrent multi-user testing of the same policy. As collaborative dry-run testing becomes common, the single-active-user-per-policy model breaks down.

Proposed Fix

Scope the "active persona" pointer to (policyId, real user id) instead of policyId alone, rather than duplicating the entire dry-run sandbox per user. Document/group/block state is already keyed by persona did, so per-user scoping of the active pointer is sufficient to isolate concurrent testers. No schema migration is required: the DryRun entity (common/src/entity/dry-run.ts) already has an unused userId field.

Touch points:

  • common/src/database-modules/database-server.ts: getVirtualUser, setVirtualUser, getVirtualUserByAccount, getVirtualUsers, countVirtualUsers — add/filter by real userId.
  • policy-service/src/policy-engine/policy-components-utils.ts: GetPolicyUserByName, GetActiveVirtualUser — thread the real user id through (already available in the call chain).
  • guardian-service/src/policy-engine/policy-components-utils.ts: GetUserRoleByPolicy / GetUserRole (the role-resolution path hit on page load / refresh) need the same scoping.
  • api-gateway/src/api/service/policy.ts: /dry-run/login, /dry-run/user — pass user.id down. Verify cache invalidation (invalidedCacheTags for .../navigation, .../groups) is scoped per real user so cached responses do not leak between concurrent testers.
  • frontend/src/app/modules/policy-engine/policy-viewer/policy-viewer/policy-viewer.component.ts: loadPolicyById re-fetches policy() on every load/refresh and renders the server-resolved userRole; it will follow correct per-user scoping once the backend pointer is fixed.

Estimated effort: Medium. Self-contained backend change across common, policy-service, guardian-service, and api-gateway, with no DB migration or backfill. Main cost is careful testing of the dry-run flow across services (login, block execution, refresh, sync-events).

Out of Scope / Follow-up

  • Two testers selecting the same virtual persona simultaneously will still collide after this fix. Needs a product decision (block it, or accept as shared/intentional).
  • "Restart dry run" currently wipes all virtual users for the entire policyId, affecting every concurrent tester. This remains a shared destructive action regardless of the fix above and may warrant its own guard/warning.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Fields

    No fields configured for Bug.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions