Skip to content

Grant and revoke application roles (M1b, M1c)

Milestone M1b of the application authority transition lets a current SyRF administrator grant or revoke the application Administrator role, with the change owned by Project Management (PM). The role lives in Investigator.ApplicationRoles in the pmInvestigator collection. Identity's syrf_groups copy is never written by this path.

Everything here exists only when PM application authority is enforced. See application-authority mode. Both flags are off everywhere by default, and no staging or production value has changed. With the flags off, or in shadow mode, the three endpoints below answer 404 and nothing is read or written.

What can be changed

Role This path
Administrator Grant and revoke. It grants every administrator capability: listUsers and manageApplicationRoles, and since M2a also listEmailTemplates, modifyEmailTemplates, batchAdminProjects, manageRuntimeFeatureFlags and sendEmail (ledger). It does not grant impersonateUsers.
SupportImpersonator Refused here (400 role-not-grantable). Since the M2b reader gate every reader tolerates it (and any unrecognised name), and it grants only impersonateUsers. Holders are approved separately through M0b; nobody holds it.
Any other stored value, such as a legacy lower-case administrator Kept exactly as stored. It cannot be added or removed here (400 role-not-manageable). It grants nothing: the evaluator matches Administrator exactly.

Who may change roles

The caller must hold Administrator in PM now. Each request reads the caller's PM record uncached, from the primary, with linearizable read concern, and the writer checks again inside its transaction. An administrator whose role is revoked is refused on their next request, on every replica. An old Identity claim, BFF session or token confers nothing.

  • The real user acts. Role administration and the capabilities read are refused while impersonating (403 impersonation-not-allowed). No actor, group or claim is ever taken from the request body.
  • Self and target are distinguished. An administrator may remove their own role only while another active administrator remains. The audit record marks self-changes (SelfChange: true), and the response to GET says isSelf.

In the Admin Console

Admin Console → Application Roles (/admin/application-roles) is the minimal editor. The entry appears only where the capabilities read succeeds, which means PM authority is enforced. Where the flags are off, the Admin Console is exactly as before.

  1. The page reads your capabilities (GET /api/account/capabilities). It then explains one of these states, without a claim-based guard:
  2. not enabled here (404);
  3. not authorised (manageApplicationRoles denied);
  4. impersonating;
  5. temporarily unavailable.
  6. Choose a person from the list (GET /api/investigators). The editor shows their authoritative current roles and revision, read with GET /api/investigators/{id}/application-roles, and marks your own account as You.
  7. Enter a reason (required, up to 500 characters) and choose Grant Administrator or Revoke Administrator. Removing your own role also needs an explicit confirmation.
  8. The page changes only after the API answers 200, using the returned roles and revision. While the request is in flight, nothing on the page claims success.
  9. Conflict: the current roles are re-read, and you decide again.
  10. Last active administrator or deactivated account: the message says why.
  11. Denied: your capabilities are re-read, and the editor disappears if you lost them.
  12. Authority unavailable: nothing changed; Try again reuses the same operation ID.
  13. Unknown outcome, including a network failure or any other server error: the page never reports success or failure. It re-reads the current roles and says whether they already match your request. It then offers Retry the same change, with the same operation ID, or Discard.

The browser state only explains; the API decides every request from current PM authority.

The endpoints

Your own capabilities

GET /api/account/capabilities returns decisions about the caller only, all from one authoritative read:

{
  "policyVersion": "application-authority.m2b.1",
  "capabilities": [
    { "capability": "listUsers", "allowed": true, "reason": "allowed" },
    { "capability": "manageApplicationRoles", "allowed": true, "reason": "allowed" },
    { "capability": "listEmailTemplates", "allowed": true, "reason": "allowed" },
    { "capability": "modifyEmailTemplates", "allowed": true, "reason": "allowed" },
    { "capability": "batchAdminProjects", "allowed": true, "reason": "allowed" },
    { "capability": "manageRuntimeFeatureFlags", "allowed": true, "reason": "allowed" },
    { "capability": "sendEmail", "allowed": true, "reason": "allowed" },
    { "capability": "impersonateUsers", "allowed": false, "reason": "role-not-held" }
  ]
}

The UI uses this to explain what someone can do, never to enforce it; the API decides every request. Since M2a the web app reads it at sign-in, on every entry to an admin page and after any 403, and presents menus and admin pages from it (see the web presentation). The reasons are safe to show the caller: allowed, role-not-held, subject-deactivated, subject-unknown, not-authenticated. If authority is unavailable, the endpoint answers 503 rather than claiming a denial.

Read one person's roles

GET /api/investigators/{investigatorId}/application-roles requires manageApplicationRoles:

{
  "investigatorId": "…",
  "roles": ["Administrator"],
  "revision": 3,
  "deactivated": false,
  "isSelf": false,
  "grantableRoles": ["Administrator"],
  "policyVersion": "application-authority.m2b.1"
}

revision is the role revision. It starts at 0 for a record this path has never changed, and each committed change adds one. The read is uncached and linearizable, so it reflects every change that was acknowledged before it started.

Change roles

PUT /api/investigators/{investigatorId}/application-roles takes the complete desired role set:

{
  "roles": [],
  "expectedRevision": 3,
  "reason": "Left the SyRF team (ticket 1234)",
  "operationId": "5b0e0c1e-2f59-4d27-9a53-0e8f2f7f4a61"
}
  • expectedRevision is the revision you read. If the record has changed since, the request is a conflict, never an overwrite.
  • reason is required, 1–500 characters. It is stored in the audit record and nowhere else: it is not logged.
  • operationId is a GUID the client generates once for this one intended change. Retrying with the same ID is idempotent. Generate a new ID for a new decision.

Responses

Status code Meaning What the client does
200 – (result: applied) Committed with majority acknowledgement, together with its audit record. Show the returned roles and revision.
200 – (result: already-applied) This operationId had already committed. Nothing new was written; the recorded result is returned. As above.
200 – (result: unchanged) The desired set equals the current one. Nothing was written. As above.
400 reason-required, reason-too-long, operation-id-required, revision-invalid, roles-required, role-name-invalid, role-not-grantable, role-not-manageable The request is invalid. Nothing was written. Fix the request.
403 role-not-held, subject-deactivated, subject-deactivation-pending, subject-unknown, not-authenticated The caller does not hold role administration now. subject-deactivation-pending: the caller's own account deletion is reserved (M1c). Refresh capabilities; hide the editor.
403 impersonation-not-allowed The caller is impersonating. End impersonation.
404 investigator-not-found There is no such account. –
404 – (empty) PM authority is not enforced in this deployment: the feature does not exist here. Hide the editor.
409 revision-conflict Someone changed this record since you read it. The body carries currentRevision. Re-read, show the current roles, let the person decide again.
409 last-active-administrator The change would leave no active administrator. Grant someone else first.
409 target-deactivated, target-deactivation-pending A role cannot be granted to a deactivated account, or to one whose deletion is reserved (M1c). –
422 operation-id-reused This operationId was already used for a different change. Use a new ID.
503 authority-unavailable The caller's authority, or the target, could not be read. Nothing was written. Retry-After: 5. Retry shortly.
503 role-change-outcome-unknown The write was attempted and its result is not known. Retry-After: 5. See below.

Every response is Cache-Control: no-store. Problem bodies carry only a stable code and, for a revision conflict, currentRevision. They never include another person's details, the reason text or a storage error.

When the outcome is unknown

Once the writer has started its transaction, any exception means the outcome is unknown, and the API says so rather than guessing. Examples:

  • a majority write-concern timeout. The commit waits up to 10 seconds, and after a partition the P3 benchmark saw a revoke take 10–22 s to be acknowledged;
  • a primary failover during the commit;
  • the 15-second operation deadline expiring;
  • the ArgumentNullException that MongoDB.Driver 3.10 throws while it builds a write-concern error (P3).

The only exception that is retried inside the writer is a transient write conflict before the commit is attempted, which is a concurrent role change (see below). Nothing of that attempt committed, so the writer tries again from a fresh snapshot. It backs off first (20 ms doubling to 500 ms, with jitter), because the other change may still be committing. It keeps trying until the 15-second operation deadline. Only then is the outcome reported as unknown.

On 503 role-change-outcome-unknown the client must not report success or failure. It should:

  1. Re-read GET …/application-roles. If the revision moved and the roles are what you asked for, the change committed.
  2. Or retry the same request with the same operationId. If the change had committed, the answer is 200 already-applied; if not, it is applied now. It can never apply twice.

This works for a self-revoke too. There, the re-read in step 1 is refused (403), because a committed revoke has already removed your role administration. The retry in step 2 still answers 200 already-applied. A caller who is denied role administration gets exactly one thing from a PUT: the recorded result of their own earlier request with that operationId (same target, same expectedRevision, same set). That comes from a read-only, linearizable read of the audit record, and nothing is written. Otherwise, including for a malformed body, the answer is the 403. So a 403 role-not-held on that retry means the revoke did not commit, and someone else has removed your role since.

The Admin Console role editor (above) follows the same rule and never shows optimistic success.

Consistency, serialization and audit

Each change is one MongoDB transaction: snapshot reads, primary, w: majority with journal, a 10-second commit bound. In order, it:

  1. increments the singleton pmApplicationRoleGuard document, so two role changes that overlap in time cannot both commit from the same snapshot. The later one conflicts and re-decides from fresh state. The document is created, outside any transaction, before the first change, so even the very first concurrent changes conflict on an update, not on an insert;
  2. returns the recorded result if operationId already has an audit record;
  3. evaluates the actor's manageApplicationRoles from their record in the transaction, with the same pure evaluator as every other decision;
  4. compares the target's ApplicationRolesRevision with expectedRevision;
  5. if the change removes Administrator from an active administrator, requires another active administrator to exist: not deactivated, and with no pending deactivation (M1c, below);
  6. writes the new ApplicationRoles, ApplicationRolesRevision + 1, Audit.Version + 1 and Audit.LastModified/LastModifiedBy, and inserts the audit record, then commits all of it atomically.

The Audit.Version increment is a fence. The ordinary Investigator aggregate saves with an optimistic Audit.Version filter, so a copy cached before the change, for up to two seconds, cannot write the old role set back: that save fails instead. A fresh aggregate carries ApplicationRolesRevision through its extra elements.

The audit record goes in pmApplicationRoleChange, keyed by operationId. It holds:

  • the target and actor InvestigatorIds, and SelfChange;
  • RolesBefore, RolesAfter, Added and Removed;
  • RevisionBefore and RevisionAfter;
  • Reason, OccurredAt, PolicyVersion and Source: "api".

The collection is restricted operational data; nothing reads it back into the UI in M1b. Application logs record only aggregate facts: the mode, capability, outcome, reason code, policy version and whether it was a self-change.

The last-administrator guard on every lifecycle path (M1c)

While application authority is enforced (both flags on, in the API and in Identity), no supported path can leave SyRF without an active administrator. An active administrator holds Administrator in pmInvestigator.ApplicationRoles, is not Deactivated, and has no pending deactivation. Every path that can remove one takes the same pmApplicationRoleGuard serialization as the role writer, so concurrent removals by different paths are decided one after another from fresh state.

Path What guards it
Role revoke (PUT …/application-roles, above) The role writer's last-administrator check, counting pending deactivations as unavailable.
Account deletion (DELETE /api/account/profile) A pending-deactivation reservation in PM before Identity destroys anything (below).
Identity's admin delete (DELETE /api/admin/users/{id}) Refused with 409 deactivation-not-reserved, before any revocation, unless it carries the operation ID of that account's PM reservation. Its only supported caller is the API's account deletion.
Identity's self-service delete (DELETE /api/account/profile on Identity) Refused with 409 deletion-requires-application before anything is looked up: it would bypass the guard.
Operator bootstrap import Grant-only, in the same guarded transaction; it refuses an account whose deletion is pending.
Application suspension (M4a, applicationSuspension) The same guarded transaction admits it (the actor must still be an active Administrator; a suspended Administrator no longer counts), then commits the denial with a pending-deactivation reservation of source application-suspension. See suspend and restore.
Administrative deactivation No such path exists. It must use the same guard.
Direct database edits, provider consoles Unsupported. Recovering an already-zero-administrator state is an approved operator procedure.

With the flags off or in shadow mode, which every deployed environment runs until M6, none of this applies and account deletion behaves exactly as before. The claim is therefore about enforced mode; it holds for a deployment only when that deployment enforces.

Account deletion, step by step

  1. The API resolves the caller's Investigator, as before.
  2. Reserve (one guarded transaction): refuse with 409 last-active-administrator if the account is the last active administrator. Otherwise write a pending-deactivation marker on the Investigator (PendingDeactivation: operation ID, time, source), an Audit.Version increment, and a reservation record in pmInvestigatorDeactivation keyed by a server-generated operation ID, holding the Identity subject to delete and a 3-minute lease. From this commit on, the authority reader denies the account (subject-deactivation-pending), including for tokens it still holds, and the account no longer counts as an administrator for anyone else's removal.
  3. Call Identity outside any transaction with the operation ID (X-SyRF-Deactivation-Operation). Identity checks the marker with a linearizable read of pmInvestigator, then does exactly what it always did: revoke tokens and authorizations, rotate the security stamp, delete. Its two 503 meanings are unchanged. The API's admin-authenticated Identity client gives the whole call, token acquisition included, at most 60 s (OpenIddictIdentityService.RequestTimeout), well inside the 3-minute lease, so a resume after the lease lapses never races a call still in flight. A test keeps it below half the lease.
  4. Settle: on a confirmed deletion, finalize (set Deactivated, remove the marker, mark the record finalized). Release the reservation (remove the marker, restoring eligibility) only when Identity provably did nothing for this call: the request was never sent, or Identity refused it before doing anything (409 deactivation-not-reserved). On every other outcome the reservation and its denial are kept.
Status code Meaning
204 – Deleted and finalized.
409 last-active-administrator Nothing was changed. Grant someone else Administrator first.
409 deletion-in-progress Another attempt holds this account's reservation. Nothing was changed. Try again in a few minutes.
409 target-deactivation-pending Another change to this account's access (an M4a suspension's session withdrawal) is still being completed. Nothing was changed; an administrator can reconcile it.
503 deletion-refused The account was not deleted; retrying is safe. The reservation may be kept (below).
503 deletion-interrupted Sessions were revoked and the deletion's outcome is unknown. Kept.
503 – (untagged) Unknown outcome, or Identity deleted the account and PM finalization is unconfirmed. Kept.

A profile edit (name, email, picture, email verification) that read the account just before a role change, a deletion reservation or a suspension committed saves a stale version and loses its optimistic-concurrency check. It answers 409 with "code": "concurrent-change", Retry-After: 1 and no-store: nothing was saved, and a retry reads the current record. It used to be a 500.

A kept reservation is never reversed automatically. It converges in one of two idempotent ways, both resuming the same operation ID and subject once the lease has lapsed:

  • the account owner retries the deletion; or
  • an administrator reconciles it: POST /api/investigators/{id}/pending-deactivation/reconcile (enforced mode only, requires manageApplicationRoles). Answers: 200 {"result":"deleted"}, 200 {"result":"nothing-pending"}, 409 deletion-in-progress, 503 deletion-refused (Identity did not delete; still pending) or 503 deactivation-outcome-unknown. It never creates a reservation, so it can only complete a deletion its owner asked for.

The web client treats the new 409 answers like any other unrecognised deletion failure (it never retries or signs out on its own); a specific message is a follow-up.

Where the first administrator comes from: the operator bootstrap

There is no anonymous or claim-driven first-administrator path. The design requires an operator-run, separately approved, idempotent import of the approved initial administrators. The holder list is approved through M0b and gate G-I, and is never inferred from email, claims or history. That import is SyRF.ProjectManagement.ApplicationRoleBootstrap, a local operator tool. It is not a deployed service. It has not been run against any real environment, and running it against staging or production needs its own approval.

The manifest is restricted operational input. Never commit it. Parsing is strict: an unknown property, another role, a duplicate or more than 50 entries stops the run before anything is read.

{
  "schema": "syrf.application-role-bootstrap.v1",
  "role": "Administrator",
  "approval": "M0b approval reference",
  "reason": "Initial administrators approved in …",
  "entries": [ { "investigatorId": "<InvestigatorId>", "expectedRevision": 0 } ]
}

expectedRevision is the role revision at approval time. It is 0 for someone never changed through the API; GET …/application-roles shows the current value.

Running it. The connection comes only from the environment, never from arguments, so it stays out of the process list and shell history:

export SYRF_ROLE_BOOTSTRAP_MONGO_URL=…   # replica set
export SYRF_ROLE_BOOTSTRAP_DATABASE=…    # the PM database
dotnet run --project src/services/project-management/SyRF.ProjectManagement.ApplicationRoleBootstrap -- \
  --manifest approved.json --operator "<your name>"                                    # dry run
dotnet run --project … -- --manifest approved.json --operator "<your name>" \
  --apply --confirm-database "$SYRF_ROLE_BOOTSTRAP_DATABASE"                           # write

To run it where there is no source checkout, publish it once and run the DLL with the same arguments and environment. This is what the fixture does with its Release build:

dotnet publish src/services/project-management/SyRF.ProjectManagement.ApplicationRoleBootstrap -c Release -o ./role-bootstrap
dotnet ./role-bootstrap/SyRF.ProjectManagement.ApplicationRoleBootstrap.dll --manifest approved.json --operator "<your name>"
  • A dry run is the default and writes nothing. For each entry it prints what an apply would answer, checked in the writer's own order:
  • already-applied if this manifest's entry has committed;
  • revision-conflict (current revision N);
  • already-holds;
  • target-deactivated;
  • grant.

It can also print not-found or unavailable. It reads the authoritative roles, revision and audit record, uncached and linearizable. If the audit record cannot be read, it prints unavailable, never a guess. - --apply needs --confirm-database equal to the configured database name. Each entry is granted through the same guarded transaction as the API: the serialization guard, a compare-and-set on ApplicationRolesRevision, the Audit.Version fence and an audit record. That record has Source: "operator-bootstrap", no actor Investigator, the operator label and the manifest's SHA-256. - It only ever adds Administrator. It never removes or rewrites any stored role, and never grants to a deactivated account. - It is idempotent. Each entry's operation ID is derived from the manifest's SHA-256 and the entry. Re-running the same manifest therefore answers already-applied rather than writing again, including after an unknown outcome. Any later change to an entry's revision is a revision-conflict, never an overwrite. Refresh the approval and use a new manifest. - The output shows positions only (#1, #2, …): no identifiers, names or emails. A connection setting the driver rejects is reported by exception type only, because the driver's message can echo the connection string. - The manifest must be strictly valid JSON, UTF-8 without a byte-order mark. Duplicate property names are refused. - Exit codes: - 0: every entry holds the role (or would, in a dry run); - 2: a usage or manifest error; - 3: an entry was refused; - 4: an outcome is unknown, or authority was unavailable. Re-run the same manifest.

The isolated fixture runs the tool for its bootstrap administrator: a dry run, an apply without --confirm-database (refused), an apply, and an idempotent re-run.

Rollback

Before anyone relies on enforcement to remove access, turning the flags off is harmless: the endpoints disappear, and PM roles stay as they are. After a revocation through this path, turning enforcement off would let an old Identity claim grant ListUsers again. Decision P5 prohibits that. Roll forward, or freeze the operation, instead. M6 adds the fleet-wide authority-version guard.

Turning enforcement off also turns the M1c guard off: account deletion takes its previous path and Identity stops checking reservations. Markers and reservation records already written stay in place and are ignored outside enforced mode (shadow mode still evaluates them as a denial, for comparison only); re-enabling enforcement picks them up again, and reconciliation completes them. Change the flag in the API and Identity together; one Helm value drives both.

Try it locally

The application-authority fixture runs enforced by default. authority-m1b-role-administration.spec.ts proves the following on two API replicas:

  • grant, then revoke, through PUT with old BFF sessions, a direct token and a refreshed session;
  • no positive cache;
  • revision conflicts and idempotent retries;
  • concurrent removal of the last two administrators;
  • the committed audit record;
  • A2: an administrator who is not a project member gets no protected project content.

authority-m1c-lifecycle-guard.spec.ts proves, with the real Identity:

  • the last active administrator cannot be removed by account deletion (A and B), by Identity's admin delete without a reservation or with a forged operation ID, or by a self-revoke, and nothing is revoked or reserved;
  • concurrent deletions of the last two administrators: one 204 (reserved, then finalized, with the Identity account gone), the other 409 last-active-administrator;
  • a deletion racing a role removal of the other administrator: exactly one takes effect;
  • off and shadow: deletion unchanged, no reservation written.
bash e2e/authority/run.sh                                # enforced
bash e2e/authority/run.sh --application-roles-mode off   # endpoints 404, nothing written

Code map

Piece Location
Rules: grantable roles, validation, revision guard, last-admin rule src/libs/project-management/SyRF.ProjectManagement.Core/Authorization/ApplicationRoleAdministration.cs
Writer contract src/libs/project-management/SyRF.ProjectManagement.Core/Interfaces/IApplicationRoleAdministration.cs
Transactional writer, audit, guard src/libs/project-management/SyRF.ProjectManagement.Mongo.Data/Authorization/MongoApplicationRoleAdministration.cs
Shared guarded transaction (serialization, retries, active-administrator count) src/libs/project-management/SyRF.ProjectManagement.Mongo.Data/Authorization/GuardedTransaction.cs
M1c lifecycle guard: rules and contract …/Core/Authorization/AdministratorLifecycle.cs, …/Core/Interfaces/IAdministratorLifecycleGuard.cs
M1c reservation, finalization, release src/libs/project-management/SyRF.ProjectManagement.Mongo.Data/Authorization/MongoAdministratorLifecycleGuard.cs
M1c account deletion and reconciliation src/services/api/SyRF.API.Endpoint/Authorization/ApplicationAuthority/GuardedAccountDeletion.cs; AccountController.DeleteProfile; InvestigatorController.ReconcilePendingDeactivation
M1c Identity refusals and the reservation check AdminApiController.DeleteUser, AccountApiController.DeleteProfile, Services/PendingDeactivationFence.cs in src/services/identity/SyRF.Identity.Endpoint/
Operator bootstrap: manifest, options, dry run/apply src/libs/project-management/SyRF.ProjectManagement.Mongo.Data/Authorization/Bootstrap/ (host: src/services/project-management/SyRF.ProjectManagement.ApplicationRoleBootstrap/)
Endpoints InvestigatorController (…/application-roles), AccountController (capabilities) in src/services/api/SyRF.API.Endpoint/Controllers/
Problem responses src/services/api/SyRF.API.Endpoint/Authorization/ApplicationAuthority/ApplicationAuthorityResponses.cs