Ga naar hoofdinhoud

Acting on Behalf of a User

OpenRegister owns the one implementation of "run this work as that user" for the whole fleet. This page is the contract other apps bind to.

Canonical decision: ADR-099 (hydra, openspec/architecture/).

There is no Nextcloud runAs()

Worth stating plainly, because it is the first thing people look for. Nextcloud core has no such function — verified against 33.0.0-dev and a code search across the nextcloud org, where the only hits are runAsBackgroundJob in the files-metadata event and runAsUser in Helm charts.

What core provides is a mutable session subject and two ways to set it:

methodeffectuse
IUserSession::setUser()sets the active user and writes user_id into the PHP sessionlogging a user in
IUserSession::setVolatileActiveUser()sets the active user, persists nothing (since 29.0)this

ObjectService::runAs(IUser $user, callable $operation)

Runs $operation with $user as the acting identity, then restores whatever identity preceded it — including when the operation throws.

$rows = $this->objects->runAs($owner, fn (): array => $this->objects->findAll($query));

Four properties you can rely on:

  • It narrows, never widens. A row $user cannot read stays unreadable. This is not a privilege grant; it is a restriction to somebody else's rights.
  • Nothing is persisted. It uses setVolatileActiveUser(), so a request that dies before the finally — a fatal, an exit() — cannot leave the caller's session authenticated as the acted-as user.
  • It restores the previous identity, not null. Nesting composes: an inner scope returning into an outer one leaves the outer identity in force.
  • It exists because the query layer has no acting-user parameter. MagicRbacHandler and MagicOrganizationHandler read IUserSession::getUser() directly at roughly a dozen points, and the UID-keyed caches follow the session subject. Swapping the subject moves every reader in lockstep. Threading a ?IUser through is the better end state and is not reachable in one change.

ObjectService::runAsSystem(callable $operation)

Runs $operation as a trusted principal with no user at all. For work that genuinely has nobody to act for: installation, migration, repair, and seeding the app's own shipped data. A schema migration runs on nobody's behalf.

🔴 Code-initiated only. It must not be reachable from a flow node, a tool invoked by an agent, or the handling of an inbound request. Those three carry user-authored definitions or user-supplied input, and this method sits right next to every place a missing identity makes something fail. An escape hatch that turns a refusal into a success gets taken. Where an identity cannot be resolved the correct outcome is a refusal naming what is missing.

SystemOperationContextBoundaryTest pins the call-site set so adding one is a deliberate act with a visible diff.

Attribution is not authorization

Two different questions, two different fields, different lifetimes:

fieldanswerslifetime
triggeredBywho caused thisimmutable provenance
runAswhose rights it executes withre-evaluated every time work runs

A scheduled run makes them differ: the cause is a schedule, the acting identity is a person. Never read triggeredBy to decide access.

Where a flow run's identity comes from

A flow has no acting identity of its own. flow.owner and flow.organisation are ownership of the definition — who may edit it, which tenant it belongs to — and are required on write. They are not a mandate to execute as that person.

Identity enters through the trigger, because that is where a run begins and a flow may carry several:

triggeracting identity
manualthe acting session user
object eventthe user whose action raised the event
schedulethe runAs declared on that trigger node — required
sub-flowthe calling run's runAs

A schedule trigger without a resolvable runAs fails to save (FlowTriggerValidator), and a dispatch that cannot resolve one is refused at the queue with FlowUnattributed rather than recorded and rejected node by node.

Rules that keep this from becoming escalation

Identity narrows along an invocation chain. A callee may never widen the identity it was invoked with by declaring one. A flow running as A that invokes an agent declaring actingUser: B runs as A, or refuses.

A caller-supplied context cannot choose the identity. Run context is supplied at queue time, so context['runAs'] is ignored — the run's own value wins. Honouring it would let anyone who can start a flow pick whose rights its steps use.

Rights are re-resolved, never snapshotted. At every fire, at every resume. A run parked for three weeks answers to the rights its subject holds now. A disabled account is refused, not just a deleted one — disabling is how a departure is normally processed, and IUserManager::get() returns a disabled user happily.

A dead identity under a schedule disables it, loudly. The flow records the refusal in status_message and the schedule is switched off. A schedule that quietly stops firing is an instrument that lies.

For app authors retiring a local copy

Five apps grew their own version of this — Integriq (FlowOwner::runAs), Buildiq (JobOwnerImpersonator::runAsOwner), Humaniq (HoursMigrationRunner::runAsActingUser), Dossiq (SearchesObjects::runAsSystemIfAvailable). All used setUser(), so all carried the session-persistence hazard.

Retiring each is that app's own change. Bind to ObjectService::runAs() and delete the local copy; the semantics above are the contract. Do not reimplement the scope — six copies of a security-critical grammar is how they drifted in the first place.