Authorize as Caller, Execute as System
A control-plane operation separates AUTHORIZATION from EXECUTION. Check the caller's access EXPLICITLY — when the request is written, and again immediately before it executes — and refuse fail-closed, naming the missing permission. Then execute under System. Never let the caller's row-level-security reach decide, mid-execution, whether an operation that was already authorized can finish. Policy
authorize-as-caller-execute-as-system(register).
What it covers
A control-plane / system operation is work the platform performs on behalf of a request that a person (or a service identity) filed, where the work itself touches records that belong to the platform rather than to that person:
- a
Hosting/InstanceAction— Provision, Roll, Reconcile, Restart, Suspend, … — and its approval re-plan; - a governance pass (a policy evaluation, a release-readiness gate, a broad-grant activity);
- a source sync or import that a person requested;
- an operator step executed against a deployment record;
- any watcher that reacts to a
RequestedXfield on a request node and acts on it.
The records these operations read and write — a GitSynced Deployments/<name>, a registry entry,
an operator status node — are system-owned. The person who filed or approved the request is
authorized to ask for the operation; that does not mean they can read every record the
operation must read to carry it out, and it should not have to.
Operations nobody requested
A webhook-triggered sync (a push imports at its pushed SHA), a periodic reconcile that repairs lost deliveries, a scheduled governance pass: these have no caller — no person wrote a request node, so there is no identity whose access could be checked twice. Their authority is the platform's own declaration (the sync source, the schedule) plus the authenticity of the trigger (the webhook's signature), checked where the trigger arrives; they execute as System from the start and record the trigger, not a requester. The two checkpoints below apply to every operation that a person or a service identity asked for — including a manual sync or import, and the approval of any of them.
The two checkpoints
1. When the request is WRITTEN
The create/update of the request node is validated, and an unauthorized request is refused by name at that write — it never gets parked as a pending request that nobody can execute.
- The check is an
INodeValidatoronCreateandUpdate, evaluated as the CALLER against the operation's TARGET (the deployment, the source, the partition), returning aNodeValidationResultthat names it. AnINodeTypeAccessRule/INodeTypePermissionRuleis not enough on its own here: both answer a barebool, and on a denialRlsNodeValidatorwrites a generic message about the REQUEST node's path, never the target's. - The refusal names the permission and the target: "User 'alice' lacks Update permission on
'Deployments/control' — cannot request Roll", not a generic "Access denied". A check that could
not decide (
PermissionCheckOutcome.IsUndetermined) is refused as "could not be checked", never as a missing permission. - An approval is a write too: an approver who lacks the right to approve is refused at the approval write, not at execution.
2. Immediately BEFORE executing
The caller's access is checked again, explicitly, right before the operation runs. Between the request and its execution the requester or the approver may have lost the right (a revoked grant, a removed membership, a role change). The check uses the Permission API against the identity recorded on the request, not the ambient context of whatever thread picked it up — and it checks every identity the operation's authority rests on: the requester's right to request it, and, for an operation that needed approval (the approval re-plan included), the recorded approver's right to approve it. A requester who still holds the right does not carry an approver who lost it.
hub.CheckPermissionOutcome(path, userId, permission)— the tri-state form, so could not decide (Undetermined) is reported as such and still fails closed. See Permission API.- On
DeniedorUndeterminedthe operation does not start; its status records the refusal with the permission, the path and the identity checked.
Then: execute as System
Once both checkpoints pass, everything the operation does — reading the records it acts on, rendering plans, writing results and status — runs as System. The operation's success must not depend on what the caller can read.
// 1) explicit authorization of EVERY identity the action's authority rests on — fail closed
var requester = hub.CheckPermissionOutcome(request.DeploymentPath, request.RequestedBy, Permission.Update)
.Select(outcome => (who: request.RequestedBy, what: "Update", outcome));
var approver = request.ApprovedBy is null
? Observable.Return((who: (string?)null, what: "", outcome: PermissionCheckOutcome.Granted))
: hub.CheckPermissionOutcome(request.DeploymentPath, request.ApprovedBy, ApprovePermission)
.Select(outcome => (who: request.ApprovedBy, what: "Approve", outcome));
requester.Zip(approver, (r, a) => r.outcome.IsGranted ? a : r) // the first check that did not grant
.SelectMany(check => access.RunAsSystemFor(
governedBy: request.Path, onBehalfOf: request.RequestedBy,
// 2) everything after the checks runs as System — the action, and the refusal's status
// write alike: the status node is system-owned too
() => check.outcome.IsGranted
? ExecuteAction(hub, request)
: RecordRefusal(hub, request, check.outcome.IsUndetermined
// could not decide is NOT a denial (PermissionCheckOutcome) — say which it was
? $"{check.what} permission of '{check.who}' on '{request.DeploymentPath}' could not be checked: {check.outcome.UndeterminedReason}"
: $"User '{check.who}' lacks {check.what} permission on '{request.DeploymentPath}'")))
.Subscribe(_ => { }, ex => logger.LogWarning(ex, "Action {Path} failed", request.Path));
ApprovePermission stands for whatever right the action's approval policy requires; the shape is
the point — one explicit check per identity, the refusal naming which identity and which right, and
nothing after the checks running as the caller.
AccessService.ImpersonateAsSystem()is the primitive; in a reactive pipeline compose it throughaccess.RunAsSystem(() => work)(ImpersonationScopeExtensions), which opens and closes the scope inside one synchronousSubscribe. NeverObservable.Using(() => access.ImpersonateAsSystem(), …)— its store and restore land on different threads and latch System onto the subscriber (Access Context Propagation).- Where the operation is the executor of a governed activity, or acts for one user, say so:
access.RunAsSystemFor(governedBy: <activity path>, onBehalfOf: <user>, () => work)— the same seal asRunAsSystem, carrying both fields so the broad-grant guard and the audit trail see whom the System write serves.AccessService.ImpersonateAsSystemFor(…)is its rawIDisposableprimitive; likeImpersonateAsSystem()it belongs in a synchronoususing, never inObservable.Using. - A single infrastructure post can carry System as a value instead:
o.WithAccessContext(WellKnownUsers.SystemContext). - Results that record WHO asked (
requestedBy,approvedBy) carry the caller's id as data on the result — the write itself is System's.
What is NOT allowed
- Impersonation as a substitute for the check. Running as System without the explicit check is privilege escalation: every caller who can write a request node gets System's reach. The check is the authority; System is only the executor.
- Checking only at execution. An unauthorized request must be refused at its write. Parking it and refusing later leaves a request that looks pending and can never run.
- Checking only at the write. Rights change between request and execution; the pre-execution check is not optional.
- Executing as the caller. A plan render, a re-plan after approval, or a status write that runs under the requester's or approver's identity makes the operation depend on that person's RLS reach over system-owned records. That is the incident below.
- Inferring permission from a failed read. A
Not foundorlacks Read permissionfrom a read inside the operation is not an authorization verdict — the verdict is the explicit check. - Application writes on a user's behalf. This rule does not widen Access Context Propagation: a user editing their own data, a view writing back a field, a thread posting a message — those still carry the user's identity end to end. This page covers only control-plane operations whose authority was checked explicitly.
Evidence
On the control instance (control.systemorph.com), the action
Ops/Actions/provision-control-registry-20261008 was approved and then refused at
2026-10-08 07:39:40Z:
the Hosting/Deployment record 'Deployments/control' is listed by the index but could not be read — User 'rbuergi' lacks Read permission on 'Deployments/control'
The approval re-plan ran as the approver. The approver was authorized to approve the action; the deployment record is GitSynced and system-owned, and the approver held no Read grant on it. The operation failed after authorization, on a read its executor should have performed as System. Under this rule the re-plan checks the approver's right to approve explicitly, then renders the plan as System.
Related
- Access Context Propagation — application writes carry the user's identity; the sanctioned System exceptions.
- Owner Injection — the standing identity when a node's own hub acts with no live caller.
- Permission API —
CheckPermission/CheckPermissionOutcome. - In-Mesh Impersonation — who may act as the platform at all.
- Policy Not Prose — the register entry for this rule.