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 toGETsaysisSelf.
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.
- The page reads your capabilities (
GET /api/account/capabilities). It then explains one of these states, without a claim-based guard: - not enabled here (
404); - not authorised (
manageApplicationRolesdenied); - impersonating;
- temporarily unavailable.
- Choose a person from the list (
GET /api/investigators). The editor shows their authoritative current roles and revision, read withGET /api/investigators/{id}/application-roles, and marks your own account as You. - Enter a reason (required, up to 500 characters) and choose Grant Administrator or Revoke Administrator. Removing your own role also needs an explicit confirmation.
- 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. - Conflict: the current roles are re-read, and you decide again.
- Last active administrator or deactivated account: the message says why.
- Denied: your capabilities are re-read, and the editor disappears if you lost them.
- Authority unavailable: nothing changed; Try again reuses the same operation ID.
- 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"
}
expectedRevisionis therevisionyou read. If the record has changed since, the request is a conflict, never an overwrite.reasonis required, 1–500 characters. It is stored in the audit record and nowhere else: it is not logged.operationIdis 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
ArgumentNullExceptionthat 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:
- Re-read
GET …/application-roles. If the revision moved and the roles are what you asked for, the change committed. - Or retry the same request with the same
operationId. If the change had committed, the answer is200 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:
- increments the singleton
pmApplicationRoleGuarddocument, 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; - returns the recorded result if
operationIdalready has an audit record; - evaluates the actor's
manageApplicationRolesfrom their record in the transaction, with the same pure evaluator as every other decision; - compares the target's
ApplicationRolesRevisionwithexpectedRevision; - if the change removes
Administratorfrom an active administrator, requires another active administrator to exist: not deactivated, and with no pending deactivation (M1c, below); - writes the new
ApplicationRoles,ApplicationRolesRevision + 1,Audit.Version + 1andAudit.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,AddedandRemoved;RevisionBeforeandRevisionAfter;Reason,OccurredAt,PolicyVersionandSource: "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¶
- The API resolves the caller's Investigator, as before.
- Reserve (one guarded transaction): refuse with
409 last-active-administratorif the account is the last active administrator. Otherwise write a pending-deactivation marker on the Investigator (PendingDeactivation: operation ID, time, source), anAudit.Versionincrement, and a reservation record inpmInvestigatorDeactivationkeyed 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. - Call Identity outside any transaction with the operation ID
(
X-SyRF-Deactivation-Operation). Identity checks the marker with a linearizable read ofpmInvestigator, 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. - Settle: on a confirmed deletion, finalize (set
Deactivated, remove the marker, mark the recordfinalized). 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, requiresmanageApplicationRoles). Answers:200 {"result":"deleted"},200 {"result":"nothing-pending"},409 deletion-in-progress,503 deletion-refused(Identity did not delete; still pending) or503 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-appliedif 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
PUTwith 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 other409 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 |