Approval Step-Up

An approval is credential-equivalent — an approved instance action is a job that mints an admin kubeconfig. So no approval counts on a signed-in session alone: the approver re-proves presence for THAT action, and the server — never the page — checks the proof before it acts. Policy approval-step-up (register).

GitHub calls it sudo mode. Here it is a step-up receipt: a short-lived, single-use record minted only after a fresh, strong authentication, and bound to exactly what is being approved. Every approval path consumes one, and refuses — by parking the approval, never by silently proceeding — when there is none.

The ladder — always prefer a passkey

Policy approval-step-up fixes the order: always prefer a passkey, use a second factor only where no passkey is available.

The approver signed in with… Step-up method (method on the receipt) Why
a Microsoft (Entra ID) account entra — an OIDC round trip with prompt=login and the declared Conditional Access authentication context, whose policy requires the Phishing-resistant MFA authentication strength The tenant already governs these accounts and their passkeys (FIDO2 / Windows Hello / Authenticator passkeys); one prompt, the tenant's own policy, nothing duplicated
any other account (Google, LinkedIn, Apple, GitHub) passkey — the portal's own WebAuthn assertion These providers cannot step up (measured below), so the portal does it itself
any other account, on a device that can do no passkey and with no passkey enrolled totp — the portal's own RFC 6238 code The last rung, offered only when the passkey rung is impossible

An Entra account is never sent through the portal's own passkey as well — one prompt, not two. The receipt records the method, so a later policy can require entra/passkey only for the highest-risk actions without changing the receipt.

Why the portal runs its own passkey for non-Microsoft accounts (measured)

The discovery documents, read 2026-10-09:

So only Entra can attest phishing-resistant, just now. For everyone else the portal holds the authenticator itself.

The receipt

One shape for every method and every consumer.

Field Meaning
Id random, 128 bits, hex
UserId the mesh user id of the approver (AccessContext.ObjectId) — never the email
Method entra · passkey · totp — an open vocabulary (StepUpMethod)
Targets the actions it covers: { ActionPath, Binding } each — the node path being approved and the hash of what was shown (InstanceAction plan digest, OperationRequest script hash, Activity content hash). A bulk approval is ONE step-up covering N targets
IssuedAt · ExpiresAt ExpiresAt = IssuedAt + ReceiptLifetime (default 5 min)
AuthenticatedAt when the user actually authenticated (auth_time for Entra, the assertion time for a passkey, the code time for TOTP)
Evidence what was verified, for the audit line (acrs=c1 amr=fido tid=…, the credential id, …) — never a secret
Seal HMAC-SHA256 over every field above, keyed from the instance master key (HKDF, purpose MeshWeaver.StepUp.Receipt.v1)

Where it lives: Auth/_StepUp/{id}, written as System by the step-up endpoint only. The node type's access rule admits System alone for every operation; the seal makes even a write that bypassed it worthless. The seal's material is canonical — every field length-prefixed, instants as UTC ticks, the targets counted — so no two receipts share bytes.

Only the platform mints. The public IStepUpService is check-and-consume only; minting lives on the internal StepUpService, visible to the portal host that carries the step-up endpoints and to nothing compiled in the mesh — otherwise any code able to resolve it could stamp itself a valid receipt and skip the authentication it stands for.

Single use: consuming target T of receipt R CREATES Auth/_StepUpUse/{R}-{key(T)} as System. Creation is atomic at the owning hub, so a second consumer's create fails — a replay is refused by the store itself, not by a field someone could reset.

How a receipt reaches the approval it covers

The step-up endpoint stamps the receipt id onto each target node, as the approver, under the content property stepUpReceipts — a map { userId → receiptId }, so several signers of one activity each carry their own. The stamp is an ordinary GetMeshNodeStream(path).Update(...); it changes the node, so the owning watcher re-evaluates the approval with the receipt now present.

🚨 The stamp survives only on a content type that DECLARES the map as ImmutableDictionary<string,string>? StepUpReceipts: the endpoint round-trips a typed content through its own type, and a type without the property drops the stamp — the approval then has no receipt, which fails closed. Every approval content declares it: InstanceActionContent, OperationRequestContent, ActivityContent, InstanceRequestContent (MeshWeaver.Plugins) and the core Approval record behind the generic _Approval satellite. A type whose generated record equality would compare the map by REFERENCE must compare it by entries instead — Approval overrides Equals for exactly that, because its control plane writes only "while the node is still the revision it judged", and two reads of one stamped approval must be equal for that to hold.

A consumer never looks a receipt up by a computed id: reading an absent node is a framework defect (the routing not-found opens the storm-breaker on that path). The receipt is read only once a stamp names it, and by then it exists.

The verdict — one function, every consumer

IStepUpService.Consume(receiptId, approver, actionPath, binding) answers one StepUpVerdict, checked in this order:

Outcome When
NotRequired step-up is not enabled on this instance (policy off)
Missing no receipt is stamped for this approver, or the stamped id does not resolve
Unavailable the receipt read did not answer — fail closed, never read as "absent"
Invalid the seal does not verify (tampered, or another instance's key)
WrongUser the receipt belongs to someone else
WrongAction no target names this action path
WrongBinding the target's hash is not the hash being approved — the plan or script changed since the approver saw it
Expired past ExpiresAt
Replayed the consumption marker already exists
Accepted consumed now; carries the method

Anything but Accepted/NotRequired parks the approval with a localized reason and a Confirm with step-up button; it is never treated as a refusal of the request itself, and never as a pass. Consumption is the LAST check before the action proceeds, so a receipt is not burned by an approval some other gate parks.

Enablement — declared per deployment record, off until declared

Portal configuration key Deployment record field (ApprovalStepUp) Default Meaning
Authentication:StepUp:Enabled Enabled false Every approval requires a receipt. Off ⇒ every consumer answers NotRequired
Authentication:StepUp:Entra:AuthenticationContext EntraAuthenticationContext — The Conditional Access authentication context id (c1…c99) requested for Microsoft accounts. Enabled without it ⇒ Microsoft accounts are refused with "step-up is not configured", never waved through
Authentication:StepUp:Entra:TenantId EntraTenantId Authentication:Microsoft:TenantId The tenant whose tid the step-up token must carry. Required when the sign-in tenant is common/organizations
Authentication:StepUp:Entra:RequireAmr EntraRequireAmr true Require an amr claim naming a phishing-resistant method. false accepts acrs alone — only the Conditional Access policy behind the context then vouches for the strength
Authentication:StepUp:Entra:PhishingResistantAmr EntraPhishingResistantAmr fido,hwk The amr values that count as phishing-resistant (comma-separated). Not ngcmfa (Entra also emits it for an Authenticator push) and not x509 (single-factor certificate)
Authentication:StepUp:MaxAuthAgeSeconds MaxAuthAgeSeconds 120 How old auth_time may be when the token arrives
Authentication:StepUp:ReceiptLifetimeSeconds ReceiptLifetimeSeconds 300 How long a receipt stays consumable
Authentication:StepUp:AllowTotpFallback AllowTotpFallback true Whether the TOTP rung exists at all

Step-up also needs the instance master key (IMasterKeyProvider) — it keys the seal. An instance without one refuses to mint, naming the missing key.

Switching it on for an Entra tenant — the admin steps

Done once per tenant by a Conditional Access Administrator (Entra ID P1 or higher). Nothing in the portal changes behaviour until the last step.

  1. Create the authentication context. Entra admin center → Protection → Conditional Access → Authentication contexts → New authentication context. Name MeshWeaver approval, ID c1 (any free c1–c99), tick Publish to apps. Note the ID.
  2. Make sure a phishing-resistant method is enabled. Protection → Authentication methods → Policies → enable Passkey (FIDO2) (and/or Windows Hello for Business, certificate-based authentication) for the approvers. Each approver registers one at https://mysignins.microsoft.com/security-info.
  3. Create the Conditional Access policy. Conditional Access → Policies → New policy:
    • Users: include the approvers (e.g. the platform-admin group); exclude your break-glass accounts.
    • Target resources: choose Authentication context → select c1.
    • Grant: Require authentication strength → Phishing-resistant MFA.
    • Session: Sign-in frequency → Every time.
    • Enable policy: Report-only first, check the sign-in logs, then On.
  4. Register the callback and emit the claims. App registrations → the portal's sign-in app (the Authentication:Microsoft:ClientId):
    • Authentication → Web → Redirect URIs → add https://<portal host>/auth/step-up/callback (next to the existing /signin-microsoft).
    • Token configuration → Add optional claim → ID → tick acrs, auth_time and amr. All three are v2.0 optional ID-token claims: without auth_time every step-up fails auth_time, and without amr it fails amr (the default RequireAmr = true).
  5. Declare it on the deployment record (control instance, Deployments/<name>): ApprovalStepUp.EntraAuthenticationContext = "c1", then ApprovalStepUp.Enabled = true (fluent: WithApprovalStepUp(true, "c1")), and roll.

🚨 Without step 3, Entra issues the acrs claim for an unprotected context to anyone who signs in — Microsoft's own table: "ACRS requested, no policy assigned → ACRS added to claims". The acrs check is only as strong as the policy behind it, which is why the portal ALSO requires an amr naming a phishing-resistant method (fido for a FIDO2 key or a passkey, hwk for Windows Hello for Business) — Microsoft's AMR table maps an Authenticator push to rsa, ngcmfa, mfa, a password to pwd, so neither passes.

The Entra rung — exactly what the server checks

GET /auth/step-up?target={path}&binding={hash}[&target=…&binding=…]&returnUrl={local}

  1. Signed in, step-up enabled, the account signed in through the Microsoft scheme — the session's mw_idp claim, set from the sign-in TICKET the challenged scheme produced, never from the callback route. A session from before that claim existed is asked to sign in again — the provider is never guessed.
  2. The pending step-up — state, nonce, the targets, the return URL, the user, ten minutes — is stored SERVER-side at Auth/_StepUpPending/{handle} (System-only); the browser carries only the handle and the state, sealed with Data Protection (a bulk approval's targets would overflow a cookie). The callback reads it once and deletes it.
  3. Redirect to https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize with prompt=login, login_hint = the signed-in account, a fresh nonce, and claims={"id_token":{"acrs":{"essential":true,"value":"c1"}}}.

GET /auth/step-up/callback?code&state exchanges the code at the token endpoint and validates the id_token:

Check Refused when
signature not signed by a key in the tenant's published JWKS
iss / tid not https://login.microsoftonline.com/{tid}/v2.0 for the configured step-up tenant
aud not the portal's client id
exp / nbf outside the token's lifetime
nonce not the nonce of THIS pending step-up
subject oid differs from the session's oid (or, for an older session, the token's preferred_username/email differs from the signed-in account)
auth_time missing, or older than MaxAuthAgeSeconds
acrs does not contain the declared context
amr absent (unless the record waives it with RequireAmr = false), or without a phishing-resistant value (fido, hwk by default)

Then the receipt is minted, each target stamped, and the browser returned to returnUrl with stepUp=done (or stepUp=failed&reason=…).

The passkey rung (non-Microsoft accounts)

Where a factor lives: ONE System-only node per account, Auth/_StepUpFactors/{user}/factors (StepUpFactors) — the passkeys (credential id, COSE public key, user handle, signature counter, AAGUID, created/last-used) and, if any, the TOTP enrolment. Never a private key, never a plaintext secret. Whether it exists is learned from a scope:children listing of Auth/_StepUpFactors/{user} — never a point read of a path that may be absent. The FIRST factor is a CREATE that refuses a node already there (and reads the stored node back to see whether its own write landed); every further factor is a GetMeshNodeStream(path).Update(fold) onto the existing node. The two never fall back into each other, because WHICH one is allowed is an authorization decision: a stale "no factors" listing, or two enrolments at once, must not turn a first-factor create into adding a second factor without the step-up that adding one requires. The passkeys are a JSON OBJECT keyed by credential id, never an array: a cross-hub update ships an RFC 7396 merge patch, which replaces an array WHOLE, so two replicas folding stale copies of a list could drop a newly enrolled credential or move another one's counter back. Keyed, each credential (and each of its fields) is patched on its own.

What every completing endpoint re-checks

The page decides nothing; each endpoint that can yield a proof — the Entra callback, the passkey verification, the TOTP verification — checks, in this order:

  1. The pending step-up is TAKEN, not read. It is claimed with the same store primitive the receipt consumption uses: a marker node Auth/_StepUpUse/pending-{handle} carrying a fresh nonce, read back, and the claimant goes on only when the stored nonce is its own. Two requests carrying the same cookie at once yield at most one proof. The pending node is deleted afterwards as tidying; the claim, not the delete, is the guarantee.
  2. The rung the server recorded matches. StepUpPending.Rung holds the rung decided at the start, and StepUpLadder.MayComplete requires it to be this endpoint's method AND the ladder, decided again now on the session's provider and the account's current factors, to land on it too. A ceremony started for Entra can therefore never be finished with a portal passkey or TOTP code, whatever factors the account holds; a record written before the field existed completes nothing.
  3. A TOTP step or recovery code is CLAIMED before the receipt is minted — markers Auth/_StepUpUse/totp-{user}-{step} and …/rc-{user}-{hash} — because validating the code against a snapshot and folding the counter afterwards lets two concurrent confirmations both pass. A non-zero passkey counter is claimed the same way (above).
  4. A TOTP or recovery-code attempt spends the user's attempt budget first — before the code is even looked at. Taking the pending step-up allows one guess per ceremony, but ceremonies are free to start, so without a budget a stolen session could keep guessing six digits. Each attempt claims one of five slots of the current 15-minute window (Auth/_StepUpUse/totp-try-{user}-{window}-{slot}, StepUpSingleUse.ClaimTotpAttempt): atomic per slot, durable across restarts and replicas, and counted across ceremonies. With no slot left the attempt is refused (locked) until the next window.

The TOTP rung (only where no passkey is possible)

The ladder offers TOTP only to an account with no passkey and an authenticator app enrolled, and the enrolment page offers to SET UP an authenticator app only when the browser reports no platform authenticator (PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()) or no WebAuthn at all. The server enforces the half it can see, at every endpoint: an account with a passkey is never offered TOTP, cannot enrol it, and a code is refused for it (downgrade) — so the fallback cannot be used to step around a passkey. Authentication:StepUp:AllowTotpFallback = false removes the rung entirely.

Consumers

Approval path Where the receipt is consumed Binding
Hosting/InstanceAction Approve ActionsExecutor.ApprovalGate, consumed in the control plane just before the run ApprovedPlan (plan digest)
Essentials/OperationRequest Approve OperationRequestControlPlane.ApproveFlow, before the claim ScriptHash
Governance/Activity signatures ActivityGates.SignatureRefusal, consumed with the signature the activity ContentHash
Hosting/ApprovalInbox bulk approve inherits — one step-up covering every selected target each row's own hash
Approvals/Approval (the generic _Approval satellite) Approve / Reject ApprovalControlPlane, consumed just before the decision is ratified as System Approval.StepUpBinding() — the approval's terms AND the decision
Hosting/InstanceRequest Approve / Refuse InstanceRequestControlPlane, consumed after every other gate, before the decision runs InstanceRequestContent.StepUpBinding() — the request as filed AND the decision
Governance/Activity manual-gate answers the activity control plane, consumed when the answer is admitted the gate, the answer and the activity ContentHash

A decision whose receipt does not count yet is held, never refused: nothing is written back (a platform write would move the node past the decider's own revision), so the step-up stamp — the decider's next write — re-triggers the judgement with the receipt present. The page that wrote the decision leads straight on to /auth/step-up; for a decision written by hand (MCP, the edit form) the control plane's log line names the step-up URL to open.

SoleMaintainerApproval stays what it is — may this person approve their own request — and the receipt is required on top of it, never instead of it.

Status

Piece State
receipt, seal, consumption, verdict, node types core — first change
Entra rung, mw_idp/mw_oid/mw_tid/mw_auth_time on the session cookie, record keys core — first change
passkey rung, TOTP rung, Settings → Security core — second change
consumers MeshWeaver.Plugins — after the core contract is in a sealed set
Approval.StepUpReceipts + Approval.StepUpBinding() (the generic satellite's half) core — third change
public links (link.publish) consuming the receipt Refs #4306, after the consumers