Skip to content

Keep application roles and sign-in claims in step (#4297)

This is the implementation plan for #4297: with syrfOwnedApplicationRolesEnforced switched off after a SyRF role revocation, the person's old sign-in claim grants administrator access again. This document changes nothing live. It proposes three pull requests (PRs). All of them must land before anyone switches enforcement on in any shared environment. Today the flag is not set anywhere: a search of the cluster-gitops checkout at 36b57035 (2026-10-17) finds no syrfOwnedApplicationRoles key, and the generated default is "false" (src/charts/syrf-common/env-mapping.yaml:818-827). No existing test lane needs a preview to enforce (§2.7).

1. The decision

Chris, 2026-10-17, option A (issue comment):

"Keep both records in step. Removing a role in SyRF also removes the matching sign-in claim. Switching enforcement back on first reconciles SyRF's records with anything changed while it was off, and refuses to switch on until that is done. 'Off' then simply means the sign-in claims decide, with no stale access. P5 is no longer needed."

He also asked for a check that the API and Identity agree on the flag, because each service reads its own configuration today. The work is application code only, needs no new infrastructure, and should take two or three PRs. The claim must be removed in Auth0 (today's provider) and in SyRF's Identity service (after the auth migration).

The issue title speaks of "old Identity claims". That is accurate for staging and the local fixture. In production, the stale claim lives in Auth0 app_metadata.syrf_groups, and Identity is not deployed there (§2.1). Because one Auth0 tenant serves every environment (§2.2), only production may remove a claim in Auth0 (§3.2).

2. Findings

2.1 Where administrator claims come from, per environment

The administrator claim is the https://claims.syrf.org.uk/syrf_groups claim (src/libs/kernel/SyRF.SharedKernel/Constants.cs:46). It is a space-separated list, and the tokens that mean "administrator" are administrator and SyrfAdmin. No code consumes Auth0 RBAC roles or permissions, and the repository has no Auth0 Rules: the claim comes from one post-login Action.

Environment How people sign in Where the administrator claim comes from
Production The web app talks to Auth0 directly. The BFF is off (cluster-gitops syrf/environments/production/api/values.yaml:73-80) and Identity is not deployed (…/production/identity/config.yaml:1-6). The API validates Auth0 JWTs (Program.cs:595-599). Auth0 app_metadata.syrf_groups. The Action copies it into the ID and access tokens (docs/auth0-actions/transform-token.js:153-171).
Staging BFF on staging's own Identity since S27 (…/staging/api/values.yaml:276-293, identityProvider: openiddict at :142). Direct OpenIddict tokens are introspected (:157-161, Program.cs:614-640). Direct Auth0 JWTs are still accepted, because every API registers the Auth0 scheme unconditionally (Program.cs:595-599). Staging Identity's ApplicationUser.SyrfGroups (Data/ApplicationUser.cs:62), emitted by UserClaimsService.cs:45-57. The BFF copies it into the session at sign-in (BffAuthController.cs:545, SessionAuthenticationHandler.cs:290-297). An Auth0 token presented directly carries the shared tenant's app_metadata.syrf_groups instead.
Previews The web app talks to Auth0 directly (no bffAuth override in argocd/applicationsets/syrf-previews.yaml; the chart default is off, src/services/api/.chart/values.yaml:198-199). Swagger signs in to the per-PR Identity (syrf-previews.yaml:327-343). Auth0 app_metadata.syrf_groups from the shared tenant, so production's administrator list. Per-PR Identity SyrfGroups applies only to Swagger tokens.
Local and the authority fixture Mock OIDC plus the real Identity. Identity SyrfGroups seeded by the fixture (e2e/authority/run.sh:160-196, e2e/authority/tools/AuthoritySeeder/Program.cs:64). The E2E login accepts groups in the request (E2EAuthController.cs:98,203).

How the API turns the claim into access in off and shadow mode:

  • Claim and stored roles are combined. Application policies evaluate the claim's groups together with the Investigator's stored role names (EffectiveApplicationGroups.cs:30-36, AuthorizationHandler.cs:107,114). They match administrator exactly (ApplicationPermission.cs:32-37, ResourceSecurity.json:13-17). A stored legacy lower-case administrator role therefore grants access in off mode. The M1b role editor cannot remove it (400 role-not-manageable, administer-application-roles.md:29).
  • Runtime flag administration accepts administrator or SyrfAdmin in any case (RuntimeFeatureFlagsController.cs:35,45-46).
  • The impersonation gate needs the exact token administrator (UserImpersonationMiddleware.cs:87).
  • The web app's fallback shows admin menus for either token (auth.selectors.ts:271-278). This is presentation only; the API decides every request.

Identity's migration fills SyrfGroups from the PM roles (administrator for Administrator, SyRF.Identity.Migration/InvestigatorReconciler.cs:82-86,306). So after the production cutover, the two records start in step for that role.

2.2 One Auth0 tenant serves production, staging and previews

Finding: every environment uses the same Auth0 tenant, the same API application and the same audience. There is one app_metadata.syrf_groups per person, with no environment dimension. If staging or a preview wrote a role removal to Auth0, it would remove that person's production administrator access.

Evidence:

  1. Same tenant, domain and audience. The API chart default is tenant: syrf, customDomain: signin.syrf.org.uk, apiAudience: https://syrf-api.syrf.org.uk (src/services/api/.chart/values.yaml:188-194). PM and Quartz charts share the same block. No environment in cluster-gitops overrides any auth0 key. The only other auth0: block is the web default, with the same tenant, domain and audience (cluster-gitops syrf/services/web/values.yaml:21-26).
  2. Same client secret. Staging and production both mount auth0: [clientSecret] (plugins/local/extra-secrets-staging/values.yaml:25-26, plugins/local/extra-secrets-production/values.yaml:25-26). Each resolves to the single GCP secret camarades-auth0 (charts/extra-secrets/templates/external-secrets.yaml:41). Previews get the same secret through a ClusterExternalSecret (argocd/local/argocd-secrets/values.yaml:93-97, charts/extra-secrets/templates/cluster-external-secrets.yaml:115).
  3. One Action for every environment. The Action reads user-level app_metadata.syrf_groups (transform-token.js:153-154). It picks which API to call from the redirect origin (transform-token.js:48-58), so it serves every environment. The repository copy is documentation (:1-5); the live Action was not read, because this plan makes no Auth0 calls.
  4. The docs already say so: docs/platform/mongodb-testing-strategy/technical-plan.md:546-547, docs/features/materialized-project-statistics/STATUS.md:608-609 and docs/how-to/enable-bff-auth.md:250-251.

What follows from this:

  • Only production may write Auth0. Credentials cannot enforce that: staging and preview namespaces hold the same client secret, so they could call the Management API against production's users today. The rule has to be in code (§3.2), and removing the secret from non-production namespaces is a follow-up (§7).
  • Staging and previews honour production's administrator list whenever someone presents an Auth0 token. Nothing SyRF does in those environments can keep that list in step.

2.3 How the Management API and Identity's admin API are used today

  • Auth0. When IdentityProvider resolves to Auth0, the API registers Auth0IdentityService and AuthManagementApiClientProvider (Program.cs:389-399). The provider gets a client-credentials token for the tenant's Management API with the API's own Auth0 client (AuthManagementApiClientProvider.cs:37-48). Existing calls already search users (Auth0Service.cs:44-47) and update them (Auth0Service.cs:225-227), so that client should already hold read:users and update:users, which also covers app_metadata. This has not been checked live; an operator should confirm the client grant before production enforcement (§4, PR 2). Nothing writes syrf_groups today.
  • Identity. The API calls Identity's admin API with client credentials and the admin:users scope (AdminApiController.cs:22-24,35). The closest existing endpoint is the M4a session withdrawal, POST /api/admin/investigators/{id}/session-withdrawal (AdminApiController.cs:713-760). It finds the account by SyrfUserId (ApplicationUser.cs:24), revokes tokens and authorizations, and rotates the security stamp. Identity has a read-only connection to the PM database (identity/.chart/values.yaml:38-49) and already reads pmInvestigator linearizably (Services/PendingDeactivationFence.cs:50-57). Nothing writes SyrfGroups after the migration import. The how-to states this explicitly (administer-application-roles.md:16), and the M1b spec asserts it (authority-m1b-role-administration.spec.ts:10,199).

2.4 Where roles are revoked, and what a reconcile must compare

  • Revocation path. PUT /api/investigators/{id}/application-roles (InvestigatorController.cs:78) goes to MongoApplicationRoleAdministration. That runs one guarded transaction which writes the role set and an audit record in pmApplicationRoleChange (MongoApplicationRoleAdministration.cs:37; administer-application-roles.md:204-237). The operator bootstrap tool grants through the same transaction. These endpoints exist only in enforced mode, so PM roles change only while enforced.
  • Mapping people across stores. The Investigator holds Auth0Id (Investigator.cs:48) and ApplicationRoles (:75). Auth0 users also carry app_metadata.syrf_id, the InvestigatorId (transform-token.js:141-147). Identity accounts carry SyrfUserId.
  • What a reconcile compares. On one side, PM records holding Administrator (or the legacy lower-case value). On the other, sign-in accounts whose groups contain administrator or SyrfAdmin. Both sets are small.

2.5 Removing a claim does not end access that has already been issued

  • Identity BFF sessions introspect their access token on each request (SessionAuthenticationHandler.cs:160-200). Revoking the account's tokens ends the session, as M4a's withdrawal does.
  • Auth0 BFF sessions are never re-validated (BffSessionTokenValidator.cs:43). They last up to 24 hours (enable-bff-auth.md:687).
  • Auth0 access tokens are self-contained JWTs that stay valid until they expire. The lifetime is a tenant setting and is not in the repository (Auth0's default for an API is 24 hours). A token issued before the claim was removed keeps the old syrf_groups until it expires.

2.6 The mixed-rollout hazard

Identity reads only its own copy of the flag pair (Configuration/IdentityFeatureFlags.cs:38-49). Both services take their values from the same generated keys (env-mapping.yaml:818-827), but each service has its own Helm values in cluster-gitops, and they roll out at different times. If the API enforces and Identity does not, Identity's self-service DELETE /api/account/profile is not refused (AccountApiController.cs:653). That deletion bypasses the PM last-administrator guard. Nothing detects the disagreement today.

2.7 Which lanes run enforcement today

The question here is whether any existing lane enforces in a preview that signs in through Auth0, because PR 1's startup rule (§3.2, rule 3) would stop such a preview. None does:

  • Only the authority fixture sets the flag. Apart from the chart template, it is the only place in the repository that sets either flag. It sets both, from --application-roles-mode (e2e/authority/env.sh:71-77,188-189,247-248). The fixture runs locally and no CI workflow runs it (docs/how-to/run-application-authority-fixture.md, "test-only and opt-in"). It signs in through its own Identity: IdentityProvider=openiddict, BffAuth__Provider=openiddict, and IdentityService__* pointing at the fixture's Identity (env.sh:226-245). Its Auth0 JWT scheme points at a port where nothing listens (env.sh:197-199,217), so no Auth0 token can validate there.
  • The main E2E lane and the statistics soak do not enable it. The soak lists applicationSuspension as not run because it needs enforcement (e2e/harness/statistics-soak/config.mjs:170-173).
  • No preview sets it. The cluster-gitops search at 36b57035 covers syrf/environments/preview, including the pr-* directories, and finds no key. A PR author could set it: #preview-config passes any featureFlags key through for a service (.github/scripts/select-preview-flags.sh:15-17, .github/workflows/pr-preview.yml:2210-2244).

How PR 1 avoids breaking any of this:

  • The fixture adds ApplicationRoleClaims__IgnoreAuth0Groups=true to env.sh. That changes nothing in practice, since no Auth0 token can validate there. PR 1 criterion 6 checks that the fixture still starts in enforced mode.
  • A preview with enforcement set through #preview-config would stop at startup with the named error. That is intended under D1, and PR 1 documents it in the preview how-to. #preview-config cannot bypass the rule, because it selects only featureFlags and IgnoreAuth0Groups is not a feature flag.

3. Design

3.1 The rule to keep

While a person's role can change in SyRF, every change to Administrator in PM is mirrored in each sign-in store that the environment honours and owns. A removal made in a sign-in store while enforcement was off is mirrored back into PM before enforcement takes effect, but only where SyRF knows that store once held the claim for that account (§3.5).

Removals travel in both directions. A grant made in SyRF adds the claim (decision D2). A claim added in a sign-in store while enforcement was off is reported, not imported (decision D3). A missing claim that was never known to be written is also reported and never revoked. Nobody gains PM authority from a claim, which is consistent with "never inferred from email, claims or history" (administer-application-roles.md:316-321). Nobody loses it because a claim was never written.

3.2 Which store each environment writes

Environment Stores it honours Stores it writes Can it enforce?
Production, on Auth0 Auth0 Auth0 (PR 2) Yes, after PR 2
Production, after the Identity cutover Identity (and Auth0, during any dual-acceptance window) Every store it honours Yes
Staging Identity, plus Auth0 through direct bearer tokens Identity only Only once it ignores administrator groups carried by Auth0 tokens (D1)
Previews Auth0 (sign-in), Identity (Swagger) Identity only No, while sign-in is through Auth0 (D1)
Local fixture Identity Identity Yes (the fixture sets the "ignore Auth0 groups" option)

Three rules, all enforced in code:

  1. Auth0 is written only where SYRF__RuntimeEnvironment is production (env-mapping.yaml:60-68). In any other environment the Auth0 writer is never constructed, and if configuration asks for it, startup stops with a named error.
  2. A new setting, ApplicationRoleClaims:IgnoreAuth0Groups (default false, so behaviour is unchanged). When it is true, the API strips syrf_groups and role from Auth0-authenticated principals and from Auth0-provider BFF sessions before any authorization runs.
  3. Enforcement refuses to start unless every store the API honours has a writer. The API honours Auth0 unless the new setting is true, and honours Identity when Identity is configured. This is the fail-closed shape the flag rule allows. It also stops production from enforcing between PR 1 and PR 2.

3.3 Write-through on every role change

  • The obligation is part of the transaction. The pmApplicationRoleChange audit record gains a ClaimSync sub-document (Pending, attempts, last attempt, AppliedAt). It is written in the same transaction as the role change, so a committed revocation always leaves a durable obligation behind. The bootstrap tool writes one too. When a sync is applied, the record keeps one result per store: the account it touched (Identity user ID or Auth0 user_id, or "no account") and whether the administrator token is present afterwards. The reconcile uses these results as evidence that a claim was written (§3.5).
  • A worker drives obligations to completion, and it is not flag-gated. It is an API hosted service that leases pending records and retries with back-off. It keeps converging after enforcement goes off, as the flag rule requires ("work already accepted keeps converging"). It marks a record applied only after a confirmed answer, or "no account" in that store.
  • Syncs converge. A sync does not replay the change. It sets the store's administrator tokens to match the PM record as it is now. Two quick changes in a row therefore converge, and older obligations become no-ops.
  • Identity side (PR 1). A new endpoint, POST /api/admin/investigators/{id}/application-groups/sync (admin:users, available in every mode), modelled on the session withdrawal:
  • Identity reads the PM record itself, through its existing read-only, linearizable path. It never trusts a desired state sent by the caller.
  • It removes administrator and SyrfAdmin (in any case), or adds administrator, and leaves every other token alone.
  • On a removal, it revokes tokens and authorizations and rotates the security stamp. The person is signed out everywhere and gets a token without the claim when they sign in again.
  • Auth0 side (PR 2, production only).
  • The writer reads the PM record and loads the Auth0 user by Investigator.Auth0Id. It refuses, and leaves the obligation pending, if the user's app_metadata.syrf_id does not match the Investigator.
  • It patches only app_metadata.syrf_groups (an array, the shape the Action expects), keeps the other tokens, and makes no call when the store already matches.
  • The legacy lower-case role goes with Administrator. Revoking Administrator also removes a stored lower-case administrator value in the same transaction, because that value grants the same off-mode access (§2.1). This changes M1b's "kept exactly as stored" rule for that one value.

3.4 Off mode with no stale access

After write-through, the stores no longer grant the role. Two gaps remain: credentials already issued that cannot be revoked (Auth0 tokens and Auth0 BFF sessions, §2.5), and an obligation still being retried. To close both, in off and shadow mode, when the claim decision would allow an application capability, the API also checks pmApplicationRoleChange for this person. It denies (403) if either is true:

  • a removal of Administrator has a pending sync;
  • a removal was applied within MaxCredentialLifetime (default 25 hours) and no later record grants the role again. The default must exceed both the Auth0 access-token lifetime and the BFF session limit. An operator confirms both before production enforces (PR 2).

If the check cannot be read, the API answers 503 (fail closed). With no records, which is every environment today, the decision is identical to the current one. The check covers every claim consumer in §2.1: the application policies, the runtime-flag check, the impersonation gate and the send-email policy. It is decision D4.

3.5 Switching enforcement on: reconcile first

Each enforced API replica reconciles at startup, and again every 15 minutes:

  1. Population: PM administrators, together with every sign-in account that holds an administrator token, in each store the environment writes.
  2. Pending first. A person with a pending obligation is not compared; their obligation is completed first.
  3. Evidence that a claim was written. For a given person, store and account, there is evidence if either of these holds:
  4. the latest applied ClaimSync result for that store names the same account with the token present;
  5. an earlier reconcile pass saw the token on that same account.

Each pass records what it saw in pmApplicationRoleClaimObservation, one document per person and store: the account ID and when the token was last seen. Evidence is tied to the account, so a deleted and recreated account starts with none. 4. Classify each person: - in step. - removed from the store: the store has no token, PM still holds the role, and there is evidence the token was written for this account. The person becomes a revocation candidate (step 5). - never written: the store has no token and PM holds the role, but there is no evidence. Examples: - an Identity account created after the grant; - a grant whose sync found no account, after which the account was created; - an Auth0 user whose syrf_id did not match, so the sync stayed pending or was refused.

 These people are reported and **never revoked**. An administrator resolves it with "match sign-in to
 SyRF" in the role editor, which records a sync that writes the token.
  • added while off, or never approved: the store has the token and PM does not. The person is reported and granted nothing (D3). An administrator resolves it in the role editor, by granting the role or by choosing "match sign-in to SyRF", which removes the token.
  • no account in that store: in step.
  • Safety brake: at most one automatic revocation per pass.
  • Exactly one candidate: the pass revokes that person's role in PM through the guarded writer, with audit Source: "reconcile". The last-administrator guard still applies.
  • Two or more candidates, or a store listing that comes back empty: the pass revokes nobody, logs a Warning and lists the candidates in the state record. An administrator then decides for each person in the role editor. Revoking is the normal audited path and also syncs. "Match sign-in to SyRF" puts the claim back.

The role editor is the operator confirmation, so no separate approval mechanism is needed. Even with two administrators, no single automatic step can remove more than one of them. A proportional threshold would not give that guarantee: one of two is exactly half. 6. Gate. Until this replica's first pass completes, enforced application capabilities answer 503 authority-reconciling with Retry-After: 5. A pass does not complete while it is held by the brake, by the last-administrator guard, or by a store it cannot read. Role administration stays available throughout, so an administrator can resolve what holds the pass. After the first success, later passes still apply the same evidence rule and brake, but only repair and report: they never re-block. This keeps steady-state admin features independent of Auth0 availability.

No record of past modes is needed for this. Switching on restarts every replica, so every replica reconciles before it enforces.

New Identity accounts are not given the token at creation in this plan. Identity binds accounts to Investigators in six places:

  • AdminApiController.cs:1026
  • AccountApiController.cs:322
  • Pages/Account/Register.cshtml.cs:290
  • Pages/Account/ExternalLogin.cshtml.cs:388
  • Services/RegistrationCompensation.cs:186
  • the link path, Services/InvestigatorMappingService.cs:330,753

Adding a PM read to every one of them, plus a backfill, is not small. It would also put an authority read on the sign-in path, which the design keeps off it (application-authority-mode.md:403-411). The evidence rule is needed either way, because Auth0 has the same "never written" cases. This stays a follow-up (§7). Until then, such an account has no administrator claim in off mode (it fails closed), and the reconcile reports it.

3.6 Mode agreement between the API and Identity

  • Heartbeat. Enforced API replicas upsert a single document, pmApplicationAuthorityState (_id: "api", Mode, HeartbeatAt), every 60 seconds. Off and shadow replicas write nothing.
  • Identity closes the bypass. Identity refuses its self-service delete when any of these holds:
  • its own flag is enforced (today's behaviour);
  • the heartbeat shows an enforced API within the last 5 minutes;
  • the record cannot be read.

This closes the AccountApiController.cs:653 bypass whichever value Identity was given. Identity also logs any disagreement between its own flag and the heartbeat. - The API checks Identity's mode. Identity gains GET /api/admin/application-authority/mode (admin:users). Enforced API replicas read it at startup and every 60 seconds. A disagreement logs a Warning and sets the metric syrf.authority.mode_agreement. Where Identity is a claim store, the API stays authority-reconciling until Identity reports enforced too. In production today there is no Identity: the check reports identity-absent and passes. - Every rollout combination is safe: - API enforced, Identity off: self-delete is refused, and the API waits for Identity. - API off, Identity enforced: Identity's existing refusals fail closed. - Both agree: normal behaviour.

3.7 Flag decision

No new flag. Every new effect starts from data that exists only after an enforced-mode role change, or from an enforced replica:

  • obligations exist only after a role change, and role changes happen only in enforced mode;
  • the off-mode check has no records to find until then;
  • the heartbeat comes only from enforced replicas;
  • the reconcile and the agreement check run only in enforced mode.

The worker and the off-mode check are deliberately not gated, so that work accepted while enforced still converges after the flag goes off. IgnoreAuth0Groups is a per-environment deployment setting with a default that changes nothing. All of this is infrastructure that exists only for the flag. It is removed when the flag retires, together with the claim itself (M7a in the implementation plan).

3.8 What this retires

P5 does two jobs today:

  • It prohibits rollback after a revocation. That is retired here, as Chris decided: switching off becomes an ordinary operation.
  • It is also the basis on which the P3 failover denial window was accepted (implementation-plan.md:98-99, application-authority-mode.md:413-419). That acceptance does not depend on the rollback rule. PR 3 restates it under P3 on its own.

The M6 "fleet-wide authority-version guard" (administer-application-roles.md:400-401) is no longer needed for this purpose.

4. Delivery: three PRs

The smallest slice comes first. PR 1 alone makes the issue's reproduction pass where the claims come from Identity (staging, the fixture, and production after the cutover). It also blocks enforcement wherever the claims cannot yet be kept in step.

PR 1: Identity in step, the reproduction, and the enforcement guard

Scope:

  • PM: the ClaimSync obligation in the role-change transaction (API and bootstrap); the legacy lower-case value removed with Administrator.
  • Identity: the sync endpoint (§3.3).
  • API: the worker; an IIdentityService sync method (OpenIddict calls Identity; Auth0 and NoOp report "no writer"); the IgnoreAuth0Groups setting; the enforcement startup rule (§3.2).
  • Fixture: a --round-trip run that switches modes against one database.

Acceptance criteria:

  1. A revocation commits its ClaimSync obligation in the same transaction; an aborted transaction leaves neither the role change nor the obligation.
  2. Identity's sync sets SyrfGroups from the PM record it reads itself: removal drops administrator and SyrfAdmin in any case; a grant adds administrator; other tokens are untouched. A second call changes nothing. A removal revokes tokens and authorizations and rotates the stamp.
  3. An obligation left pending while enforced completes after the API restarts in off mode.
  4. A failed or timed-out sync stays pending and is retried. It is never marked applied without a confirmed answer (204, or 404 native-account-not-found).
  5. Revoking Administrator from a record that also holds a lower-case administrator removes both. In off mode, ListUsers is then refused for that person, while an untouched administrator still gets 200 (positive control).
  6. With enforcement configured, startup stops with a named error if any honoured store has no writer: Auth0 without IgnoreAuth0Groups, or a preview-style configuration. The fixture configuration, with ApplicationRoleClaims__IgnoreAuth0Groups=true added to e2e/authority/env.sh, still starts enforced (§2.7). The preview how-to says that enforcement set through #preview-config stops the API.
  7. Each applied sync records, per store, the account it touched and whether the token is present afterwards (§3.3). Tests cover "token added", "token removed" and "no account".
  8. The issue's reproduction, bash e2e/authority/run.sh --round-trip:
  9. Enforced: administrator A revokes B.
  10. Off, after restarting both API replicas and Identity against the same database: on replicas A and B, B's old session and bearer token get 401; B's fresh sign-in gets 403 on ListUsers; A gets 200.
  11. Enforced again: B gets 403 and A gets 200.
  12. Run the new spec against today's main first and record the off-phase failure (B gets 200).
  13. A grant made while enforced adds administrator to Identity, and in the off phase that person gets 200. This shows data made while on is handled correctly while off.
  14. The M1b spec's "Identity copy is never written" assertion (authority-m1b-role-administration.spec.ts:199) is replaced by "the revoke removes the Identity token". administer-application-roles.md:16 is updated to match.

Verification:

  • dotnet test for the PM, Identity and API test projects, filtered to the new tests. Run them on Bramble with ~/.claude/scripts/bramble-dotnet-test.sh.
  • The authority fixture, on Bramble. No CI lane runs it (docs/how-to/run-application-authority-fixture.md).

PR 2: production on Auth0, and the off-mode check

Scope: the Auth0 writer (production only); the off and shadow recent-removal check (§3.4); the IgnoreAuth0Groups stripping wired into the Auth0 JWT and Auth0 BFF-session paths.

Acceptance criteria:

  1. The Auth0 writer is constructed only when SYRF__RuntimeEnvironment=production. A test matrix covers production, staging, preview and local. Outside production, configuration that asks for Auth0 writes stops startup.
  2. Against a stub Management API, the writer:
  3. patches only app_metadata.syrf_groups, as an array, with the other tokens preserved;
  4. makes no call when the store is already in step;
  5. leaves the obligation pending when app_metadata.syrf_id does not match the Investigator.

The test asserts that the stub is the only Management host contacted. 3. With IgnoreAuth0Groups=true, an Auth0 token or Auth0 BFF session carrying administrator gets 403 on ListUsers, while an Identity session carrying it gets 200. With the default false, every decision is unchanged. 4. Off and shadow: - with no role-change records, decisions are identical to today's, with a positive control; - for someone whose Administrator was removed less than 25 hours ago, still holding the claim: 403 on ListUsers, email templates, batch administration, runtime-flag administration and the send-email actions; the impersonation gate is refused; - after the window, or after a later grant, the claim decides again; - a failed read of the check answers 503. 5. The enforcement startup rule passes for production on Auth0 once the writer exists, and still refuses for a preview configuration.

Before production ever enforces (operator steps; agents make no live Auth0 calls):

  • confirm the API client's grant includes read:users and update:users;
  • confirm that the tenant's access-token lifetime for the https://syrf-api.syrf.org.uk API and the BFF session limit (BffAuth:SessionAbsoluteLifetime, enable-bff-auth.md:687) are both 24 hours or less. MaxCredentialLifetime (25 hours, §3.4) must exceed both. If either is longer, raise MaxCredentialLifetime to match before enforcing;
  • confirm the live post-login Action still reads app_metadata.syrf_groups;
  • read-only census of production PM records holding a lower-case administrator;
  • read-only census of Auth0 accounts holding an administrator token, so that the D3 report is expected.

Verification: dotnet test (API) with the stub; a code review of the environment guard.

PR 3: reconcile on switch-on, mode agreement, and retiring P5

Scope: the reconcile (§3.5); Identity's listing of administrator-token accounts and its mode endpoint; the heartbeat and Identity's self-delete rule (§3.6); the docs.

Acceptance criteria:

  1. An enforced replica answers application capabilities with 503 authority-reconciling until its first pass completes. Role administration answers normally throughout.
  2. The reconcile classification is tested case by case:
  3. in step: nothing happens;
  4. removed from the store, with evidence (an applied grant sync, or an earlier observation, on the same account), and the only candidate: revoked in PM, with a Source: "reconcile" audit record;
  5. an account without the token and with no prior applied grant or observation: reported, not revoked;
  6. evidence recorded for a different account (the account was deleted and recreated): reported, not revoked;
  7. a grant whose sync found no account, followed by the account's creation: reported, not revoked;
  8. added while off: reported (a count metric, plus the InvestigatorIds in the state record), not granted, and the pass is not blocked;
  9. a pending obligation is completed first;
  10. no account in the store: in step.
  11. With two or more revocation candidates, or an empty store listing, the pass revokes nobody, lists the candidates and keeps a first pass blocked. Revoking one of the candidates in the role editor, or using "match sign-in to SyRF", clears that candidate on the next pass. The last-administrator guard also holds the pass.
  12. In the --round-trip fixture run:
  13. while off, Z's Identity token is removed directly (Z's token was written by an applied grant sync while enforced) and Y's is added;
  14. the fixture's pm-admin account (e2e/authority/run.sh:167-168, no Identity group) is present throughout. Its PM role is seeded directly in the database, so no sync has ever run for it;
  15. after switching on: Z is revoked in PM; pm-admin is reported and keeps its PM role (200 on ListUsers); Y is reported and gets 403; A is unaffected;
  16. a second run, in which two people's tokens were removed while off, revokes neither, and ListUsers answers 503 authority-reconciling until an administrator resolves both in the role editor.
  17. With the API enforced and Identity off, Identity's self-service delete answers 409 deletion-requires-application. It succeeds again once 5 minutes pass with no heartbeat (a clock test). This closes the AccountApiController.cs:653 bypass.
  18. Where Identity is a claim store, a disagreement logs a Warning, sets the metric, and keeps the enforced API reconciling until Identity reports enforced. With no Identity configured, the check reports identity-absent.
  19. The docs:
  20. the P5 rollback prohibition is replaced with the new off and on procedure in administer-application-roles.md:395-407 and application-authority-mode.md:468-482;
  21. the P5 rows in design.md:280 and implementation-plan.md are amended, and the P3 failover acceptance is restated without P5;
  22. the ledger rows 19-21 are updated;
  23. the fixture how-to describes --round-trip.
  24. ./docs/scripts/validate-docs.sh passes.

Verification: dotnet test; the authority fixture (--round-trip, plus a mixed-mode run with the API enforced and Identity off); docs validation.

5. Risks

Risk Mitigation
A bug writes production Auth0 for the wrong person, or strips other groups. The writer reads PM fresh, checks syrf_id, touches only administrator tokens and runs only in production. It has hermetic tests with exact request bodies.
The reconcile revokes a real administrator whose claim was never written, or whose store was misread. A revocation needs evidence that the claim was written for that same account (§3.5). A pass makes at most one automatic revocation. An empty listing revokes nothing. The last-administrator guard applies, and the reconcile source is audited.
MaxCredentialLifetime is shorter than a real credential lifetime. An operator step before production enforces (PR 2) confirms that both inputs are 24 hours or less.
Auth0 or Identity is down when a role changes. The obligation stays pending and is retried; the off-mode check denies meanwhile.
A revoked administrator is signed out of Identity everywhere. This is intended: it is the only way to end an issued session. It is documented in the how-to.
Admin features answer 503 for a few seconds after an enforced deploy, or longer if the store is unreachable. Role administration stays available; the cause is logged; later passes never re-block.
Identity refuses self-service deletion during a PM read outage, or for 5 minutes after enforcement goes off. Accepted: it fails closed, and the person is told to delete from SyRF or retry.
Production enforcement is blocked by claims nobody approved in PM. Under D3, such claims are reported, not blocking. The census in PR 2 makes the report expected.

Chris chose the recommended option on each of D1-D4 on the decision page on 2026-10-19, with no notes. The text below records what was decided.

  1. D1: only production writes Auth0. Staging may enforce only with IgnoreAuth0Groups=true, which costs anyone using a direct Auth0 token there their admin access. Previews cannot enforce while they sign in through Auth0. Decided. The alternative is per-environment keys in app_metadata plus a change to the production Action, which is a production tenant change for a non-production benefit.
  2. D2: a SyRF grant also adds the sign-in claim, including in production Auth0. Decided, so that a PM-granted administrator stays an administrator after switching off. Without it, they silently lose access (fail closed, but out of step).
  3. D3: claims added while enforcement was off, or never approved, are reported, not imported, and do not block switch-on. The same applies to a PM administrator whose claim was never known to be written. Decided. They grant nothing while enforced. Blocking would let an Auth0 dashboard edit take production admin features offline. Importing them would make claims a source of PM authority, which the M0b and G-I approval rule forbids. Only a removal with evidence is applied to PM, and at most one per pass without an administrator's decision (§3.5).
  4. D4: the off-mode recent-removal check (one read on admin requests, for 25 hours after a removal). Decided. Auth0 tokens cannot be revoked, so without it "off" carries up to a day of stale access. It depends on the operator confirming that the access-token lifetime and the BFF session limit are both 24 hours or less (PR 2). The alternative is a runbook rule: never switch off within 25 hours of a revocation.

7. Out of scope (follow-ups)

  • Remove the auth0 client secret from staging and preview namespaces once nothing there needs the Management API (cluster-gitops change). Today those namespaces could change production's Auth0 users (§2.2).
  • Suspension with enforcement off: #4290 covers applicationSuspension's own round trip.
  • New Identity accounts (registration or account claiming) should take their administrator token from PM at creation, with a backfill for existing accounts. This was considered for PR 1 and kept out, for the reasons in §3.5. Until then, a PM administrator who first signs in to native Identity later has no off-mode claim, which fails closed. The reconcile reports them and never revokes them.
  • Retirement: the claim sync, the off-mode check, the heartbeat and IgnoreAuth0Groups go when the flag and the claim retire (M7a, M7b).