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 matchadministratorexactly (ApplicationPermission.cs:32-37,ResourceSecurity.json:13-17). A stored legacy lower-caseadministratorrole 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
administratororSyrfAdminin 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:
- 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 incluster-gitopsoverrides anyauth0key. The only otherauth0:block is the web default, with the same tenant, domain and audience (cluster-gitops syrf/services/web/values.yaml:21-26). - 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 secretcamarades-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). - 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. - The docs already say so:
docs/platform/mongodb-testing-strategy/technical-plan.md:546-547,docs/features/materialized-project-statistics/STATUS.md:608-609anddocs/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
IdentityProviderresolves to Auth0, the API registersAuth0IdentityServiceandAuthManagementApiClientProvider(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 holdread:usersandupdate:users, which also coversapp_metadata. This has not been checked live; an operator should confirm the client grant before production enforcement (§4, PR 2). Nothing writessyrf_groupstoday. - Identity. The API calls Identity's admin API with client credentials and the
admin:usersscope (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 bySyrfUserId(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 readspmInvestigatorlinearizably (Services/PendingDeactivationFence.cs:50-57). Nothing writesSyrfGroupsafter 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 toMongoApplicationRoleAdministration. That runs one guarded transaction which writes the role set and an audit record inpmApplicationRoleChange(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) andApplicationRoles(:75). Auth0 users also carryapp_metadata.syrf_id, the InvestigatorId (transform-token.js:141-147). Identity accounts carrySyrfUserId. - 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 containadministratororSyrfAdmin. 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_groupsuntil 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, andIdentityService__*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
applicationSuspensionas not run because it needs enforcement (e2e/harness/statistics-soak/config.mjs:170-173). - No preview sets it. The
cluster-gitopssearch at36b57035coverssyrf/environments/preview, including thepr-*directories, and finds no key. A PR author could set it:#preview-configpasses anyfeatureFlagskey 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=truetoenv.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-configwould stop at startup with the named error. That is intended under D1, and PR 1 documents it in the preview how-to.#preview-configcannot bypass the rule, because it selects onlyfeatureFlagsandIgnoreAuth0Groupsis not a feature flag.
3. Design¶
3.1 The rule to keep¶
While a person's role can change in SyRF, every change to
Administratorin 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:
- Auth0 is written only where
SYRF__RuntimeEnvironmentisproduction(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. - A new setting,
ApplicationRoleClaims:IgnoreAuth0Groups(defaultfalse, so behaviour is unchanged). When it istrue, the API stripssyrf_groupsandrolefrom Auth0-authenticated principals and from Auth0-provider BFF sessions before any authorization runs. - 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
pmApplicationRoleChangeaudit record gains aClaimSyncsub-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 Auth0user_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
administratorandSyrfAdmin(in any case), or addsadministrator, 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'sapp_metadata.syrf_iddoes 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. RevokingAdministratoralso removes a stored lower-caseadministratorvalue 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
Administratorhas 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:
- Population: PM administrators, together with every sign-in account that holds an administrator token, in each store the environment writes.
- Pending first. A person with a pending obligation is not compared; their obligation is completed first.
- Evidence that a claim was written. For a given person, store and account, there is evidence if either of these holds:
- the latest applied
ClaimSyncresult for that store names the same account with the token present; - 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:1026AccountApiController.cs:322Pages/Account/Register.cshtml.cs:290Pages/Account/ExternalLogin.cshtml.cs:388Services/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
ClaimSyncobligation in the role-change transaction (API and bootstrap); the legacy lower-case value removed withAdministrator. - Identity: the sync endpoint (§3.3).
- API: the worker; an
IIdentityServicesync method (OpenIddict calls Identity; Auth0 and NoOp report "no writer"); theIgnoreAuth0Groupssetting; the enforcement startup rule (§3.2). - Fixture: a
--round-triprun that switches modes against one database.
Acceptance criteria:
- A revocation commits its
ClaimSyncobligation in the same transaction; an aborted transaction leaves neither the role change nor the obligation. - Identity's sync sets
SyrfGroupsfrom the PM record it reads itself: removal dropsadministratorandSyrfAdminin any case; a grant addsadministrator; other tokens are untouched. A second call changes nothing. A removal revokes tokens and authorizations and rotates the stamp. - An obligation left pending while enforced completes after the API restarts in off mode.
- A failed or timed-out sync stays pending and is retried. It is never marked applied without a confirmed
answer (
204, or404 native-account-not-found). - Revoking
Administratorfrom a record that also holds a lower-caseadministratorremoves both. In off mode,ListUsersis then refused for that person, while an untouched administrator still gets200(positive control). - 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, withApplicationRoleClaims__IgnoreAuth0Groups=trueadded toe2e/authority/env.sh, still starts enforced (§2.7). The preview how-to says that enforcement set through#preview-configstops the API. - 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".
- The issue's reproduction,
bash e2e/authority/run.sh --round-trip: - Enforced: administrator A revokes B.
- 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 gets403onListUsers; A gets200. - Enforced again: B gets
403and A gets200. - Run the new spec against today's
mainfirst and record the off-phase failure (B gets200). - A grant made while enforced adds
administratorto Identity, and in the off phase that person gets200. This shows data made while on is handled correctly while off. - 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:16is updated to match.
Verification:
dotnet testfor 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:
- 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. - Against a stub Management API, the writer:
- patches only
app_metadata.syrf_groups, as an array, with the other tokens preserved; - makes no call when the store is already in step;
- leaves the obligation pending when
app_metadata.syrf_iddoes 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:usersandupdate:users; - confirm that the tenant's access-token lifetime for the
https://syrf-api.syrf.org.ukAPI 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, raiseMaxCredentialLifetimeto 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:
- An enforced replica answers application capabilities with
503 authority-reconcilinguntil its first pass completes. Role administration answers normally throughout. - The reconcile classification is tested case by case:
- in step: nothing happens;
- 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; - an account without the token and with no prior applied grant or observation: reported, not revoked;
- evidence recorded for a different account (the account was deleted and recreated): reported, not revoked;
- a grant whose sync found no account, followed by the account's creation: reported, not revoked;
- added while off: reported (a count metric, plus the InvestigatorIds in the state record), not granted, and the pass is not blocked;
- a pending obligation is completed first;
- no account in the store: in step.
- 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.
- In the
--round-tripfixture run: - 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;
- the fixture's
pm-adminaccount (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; - after switching on: Z is revoked in PM;
pm-adminis reported and keeps its PM role (200onListUsers); Y is reported and gets403; A is unaffected; - a second run, in which two people's tokens were removed while off, revokes neither, and
ListUsersanswers503 authority-reconcilinguntil an administrator resolves both in the role editor. - 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 theAccountApiController.cs:653bypass. - 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. - The docs:
- the P5 rollback prohibition is replaced with the new off and on procedure in
administer-application-roles.md:395-407andapplication-authority-mode.md:468-482; - the P5 rows in
design.md:280andimplementation-plan.mdare amended, and the P3 failover acceptance is restated without P5; - the ledger rows 19-21 are updated;
- the fixture how-to describes
--round-trip. ./docs/scripts/validate-docs.shpasses.
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. |
6. Decisions (Chris, 2026-10-19: all four accepted as recommended)¶
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.
- 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 inapp_metadataplus a change to the production Action, which is a production tenant change for a non-production benefit. - 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).
- 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).
- 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
auth0client secret from staging and preview namespaces once nothing there needs the Management API (cluster-gitopschange). 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
IgnoreAuth0Groupsgo when the flag and the claim retire (M7a, M7b).