Skip to content

Commit 92f1e6b

Browse files
os-zhuangclaude
andcommitted
docs(adr): ADR-0073 — automation execution identity (non-human principal for user-less runs)
Completes the identity model (ADR-0068 unified the human surface) by adding the non-human / automation identity that user-less runs need: - Automation is a first-class identity expressed as built-in roles (`automation` org-scoped, `platform_automation` unscoped) — the ADR-0068 idiom; no anonymous run. - `runAs` becomes an authorization POSTURE (user / automation-with-RLS / system), decoupling authorization from attribution; `automation` (RLS-enforced) is the new safe default for user-less triggers, `system` (god-mode) unchanged for back-compat. - Attribution always concrete (audit actor), decoupled from record ownership so automation never silently owns the rows it writes (owner-RLS would hide them). - User-less `runAs:'user'` is a configuration error. Staged per ADR-0049: contract + seeded roles + lint completeness now (non-breaking, mirrors ADR-0068 v1); runtime wiring (attribution then RLS-enforced authorization + scopable grants) deferred to M2. Grounds the decision in how Salesforce / ServiceNow / AWS IAM / K8s / iPaaS / GitHub Actions handle automation identity. Status: Proposed — for discussion. Refs #1888, #2308; builds on ADR-0049/0057/0066/0068. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7ae969e commit 92f1e6b

1 file changed

Lines changed: 164 additions & 0 deletions

File tree

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
# ADR-0073: Automation execution identity — a built-in non-human principal for user-less runs, `runAs` as authorization posture, attribution always concrete
2+
3+
**Status**: Proposed (2026-06-25)
4+
**Deciders**: ObjectStack Protocol Architects
5+
**Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-mark gate; stage by whether the feature exists), [ADR-0057](./0057-erp-authorization-core-business-units-and-scope-depth.md) (`sys_role` is platform-native; `ExecutionContext.roles`; scheduled/lifecycle jobs), [ADR-0066](./0066-unified-authorization-model.md) (capability / assignment / requirement separation — resources declare a capability, never "who"), [ADR-0068](./0068-unified-user-context-and-built-in-identity-roles.md) (`EvalUser` / `current_user`; **identities are roles, not booleans**)
6+
**Consumers**: `@objectstack/spec` (the identity contract), `@objectstack/plugin-security` (`resolve-execution-context`, RLS, seeded roles), `@objectstack/service-automation` (the engine's `runAs` resolution), `@objectstack/trigger-schedule` + `@objectstack/trigger-api` (user-less trigger surfaces), `@objectstack/plugin-reports` (the report scheduler — today hand-rolls `SYSTEM_CTX`), `@objectstack/runtime` (audit actor), and the ADR-0057 lifecycle/retention jobs.
7+
8+
**Premise**: pre-launch — specify the target end-state, then land only the non-speculative slice now (ADR-0049). This ADR **completes** the identity model: ADR-0068 unified the *human* identity surface (`current_user`, identities-as-roles); this ADR adds the **non-human / automation** identity that user-less runs need, in the same idiom.
9+
10+
> **Trigger**: #1888 enforced flow `runAs`, and the follow-up #2308 surfaced that a **schedule-triggered** flow with the default `runAs:'user'` has **no trigger user** → the data-layer security middleware *skips* (it skips on no-identity by design, delegating auth to the auth layer) → the run executes **UNSCOPED** (effectively elevated). #2308 made that fail-open *audible* (a build-time lint + a runtime warning) but deliberately did **not** change the runtime identity — because the right fix is a platform-identity decision, recorded here.
11+
12+
---
13+
14+
## TL;DR
15+
16+
1. **[new] Automation is a first-class non-human identity, expressed as built-in roles** (the ADR-0068 idiom): seed reserved `sys_role` rows `automation` (org-scoped — app-authored scheduled/user-less flows) and `platform_automation` (unscoped — platform-internal jobs only). A user-less run resolves to an `EvalUser` whose `id` is a stable automation principal and whose `roles` carry the automation role. There is **no anonymous run**.
17+
2. **[new] `runAs` declares *authorization posture*, not identity** — decoupling the two axes the platform conflates today:
18+
- `user` — run as the triggering human (only valid when one exists);
19+
- **`automation`** (the new default for user-less triggers) — run as the automation principal **with RLS enforced** against its grants (Salesforce "with sharing"; the safe middle);
20+
- `system` — full elevation (`isSystem`, RLS-bypassing) — explicit opt-in, **back-compat unchanged**, always audited.
21+
3. **[new] Attribution is always concrete** — every run carries an identity, so the **audit actor** is the human, the automation principal, or `platform_automation`. **No more `created_by = NULL` automation writes.** Attribution is recorded at the audit layer and is **decoupled from record ownership** (automation must not silently *own* the rows it writes, or owner-RLS would hide them from humans).
22+
4. **[ruled] User-less + `runAs:'user'` is a configuration error** (no user to scope to). Mainstream platforms do not offer "as the triggering user" for scheduled work; neither do we.
23+
5. **Staging (ADR-0049)**: land the **contract + seeded roles + lint completeness** now (foundational, non-breaking, mirrors ADR-0068 v1); defer the **runtime wiring** (attribution, then the `automation`-default authorization + scopable grants) to M2 as the roadmapped enforcement.
24+
25+
---
26+
27+
## Context
28+
29+
### The two axes the platform conflates
30+
31+
"What identity does a run execute under when there is no human?" is the classic **confused-deputy** problem. Mature platforms keep two axes separate:
32+
33+
- **Authorization** — what may the run read/write? (RLS / permission context)
34+
- **Attribution** — who is recorded as having done it? (audit identity)
35+
36+
ObjectStack collapses both into "is `context.userId` present?", which is why the user-less case both (a) loses authorization (skips → unscoped) and (b) loses attribution (`created_by = NULL`).
37+
38+
### What the surfaces do today (code-grounded)
39+
40+
| Run | Authorization | Attribution |
41+
|---|---|---|
42+
| `runAs:'user'` (human present) | trigger user + full RLS | `created_by = userId` |
43+
| `runAs:'system'` | `{isSystem:true}` → bypasses **everything** (object perms + RLS) | `created_by = NULL` (orphan) |
44+
| user-less + `user` (schedule/webhook) | **skips** → UNSCOPED (the #2308 fail-open) | `created_by = NULL` (orphan) |
45+
46+
Two findings sharpen the problem:
47+
48+
- **Automation creates orphan records.** `created_by` is stamped from `session.userId` (`@objectstack/objectql` insert hook); a `system`/user-less run has no `userId``created_by = NULL`. The existence of `claimSeedOwnership` (re-owning NULL rows to the first admin) is the tell. "NULL-then-claim" works for **one-time seeds** (a single claim event) but **fails for perpetual automation** — a nightly sweep has no claim event, so unattributed rows accumulate forever.
49+
- **The standing system user was deliberately removed.** `SystemUserId.SYSTEM` (`usr_system`) is vestigial ("NO LONGER AUTO-PROVISIONED", `system-names.ts`). That removal was about **seed ownership ergonomics**, not automation — so it does not bar a non-human *execution* identity, but it does warn us not to recreate a seed-ownership crutch.
50+
51+
### How mainstream platforms solve it
52+
53+
| Platform | Authorization for automation | Attribution |
54+
|---|---|---|
55+
| **Salesforce** | System Context with author choice: *without sharing* (god-mode) vs **with sharing** (elevated object/FLS, record sharing still enforced). Never "as the triggering user". | dedicated **"Automated Process"** system user. |
56+
| **ServiceNow** | scheduled jobs/flows have a **"Run as"** service account; ACLs evaluated against it. | the service account. |
57+
| **AWS IAM** | per-task **execution role** — a first-class non-human principal, **least-privilege**, assumed per-invocation. | the role, in CloudTrail. |
58+
| **Kubernetes** | every workload (incl. CronJobs) runs under a **ServiceAccount**; RBAC against it. | the ServiceAccount. |
59+
| **iPaaS (Workato/Zapier)** | steps run as the **connection owner** (the configured credential). | the connection. |
60+
| **GitHub Actions** | scoped `GITHUB_TOKEN`; default shifted broad → read-only (least-privilege trend). | the workflow token. |
61+
| **Postgres** | `SECURITY DEFINER` = "run as definer" — documented as a privilege-escalation **footgun**. | the role. |
62+
63+
**Four invariants they converge on**: (1) automation always runs as a **concrete, named, non-human principal** — never "no one", never ambient god-mode; (2) **authorization ≠ attribution**; (3) **safe, explicit defaults**, least-privilege as the trend; (4) **elevation is explicit and audited**. ObjectStack violates (1) and (2) for the user-less case.
64+
65+
This primitive is **not flow-specific**: `plugin-reports` already hand-rolls `SYSTEM_CTX = {isSystem:true}`, audit writes, the ADR-0057 retention/lifecycle jobs, future queue/webhook consumers, and autonomous AI agents all need the same non-human identity. Building it inside the flow engine would be a mistake — it is a **platform identity primitive**.
66+
67+
---
68+
69+
## Decision
70+
71+
### D1 — A non-human automation identity, expressed as built-in roles [new]
72+
73+
Extend ADR-0068's "identities are roles" to the non-human case. Seed reserved, managed `sys_role` rows (siblings of `platform_admin` / `org_*`), carrying `label` + `description`:
74+
75+
| name | scope | meaning |
76+
|---|---|---|
77+
| `automation` | org-scoped (`organization_id` = the run's tenant) | the identity for **app-authored** scheduled / user-less flows within a tenant. |
78+
| `platform_automation` | unscoped (`org_id = null`) | the identity for **platform-internal** jobs (retention, telemetry, migrations). **Not author-selectable** — reserved for the platform, sibling to `platform_admin`. |
79+
80+
A user-less run resolves to an `EvalUser` (ADR-0068) whose `id` is a stable automation principal id and whose `roles` include the appropriate automation role. It appears as `current_user` like any other identity — so RLS policies, formulas, and audit treat it uniformly, with **zero bespoke booleans** (ADR-0068 D2). The principal is **non-loginable** and excluded from human/admin enumerations (the existing `usr_system` exclusion guards already model this).
81+
82+
> This does **not** resurrect `usr_system` as a seed-ownership crutch (seeds keep NULL-then-claim, ADR's removal intact). It adds a non-human **execution + attribution** identity in the modern idiom.
83+
84+
### D2 — `runAs` declares authorization posture, not identity [new]
85+
86+
`runAs` stops meaning "system vs user" (which conflates the two axes) and becomes a declaration of authorization posture, resolved against the run's available identity:
87+
88+
| `runAs` | authorization | when |
89+
|---|---|---|
90+
| `user` | the triggering **human** + full RLS | only valid when a human triggered the run |
91+
| **`automation`** | the **automation principal**, **RLS enforced** against its grants (object perms + record-level RLS both apply) | the **default for user-less triggers** (schedule, unauthenticated webhook, internal `execute`) |
92+
| `system` | `{isSystem:true}` — full elevation, RLS-bypassing | explicit opt-in; **semantics unchanged from today** (back-compat) |
93+
94+
`automation` is the Salesforce-"with sharing" middle that ObjectStack's binary `system`/`user` cannot express today: elevated enough to act as the platform, but **still subject to row-level security and its own permission grants**, so a scheduled flow can never quietly exceed what the automation principal is allowed.
95+
96+
### D3 — Attribution is always concrete, and decoupled from ownership [new]
97+
98+
- Every run carries an identity, so the **audit actor** is always concrete: the human, `automation`, or `platform_automation`. The anonymous/`NULL` automation write is eliminated.
99+
- **Attribution ≠ ownership.** Automation must **not** be force-stamped as `created_by` / `owner_id` of the rows it writes — owner-RLS keys on `created_by == current_user.id` (ADR-0057/0068), so automation-owned rows would become **invisible to the humans they are for**. Salesforce models this exactly: `CreatedBy = Automated Process` (audit) while `OwnerId` is set by the flow logic. Therefore: record the automation actor at the **audit layer**; let flow logic set ownership explicitly (or leave it to the normal default), not the execution identity.
100+
101+
### D4 — Scope follows the isolation boundary (ADR-0068 D3) [ruled]
102+
103+
- The `automation` identity is **tenant-scoped**: a scheduled flow belongs to an app installed in a tenant, so its automation principal carries that tenant's `organizationId`; RLS evaluates within the tenant; its grants bound what it can touch.
104+
- `platform_automation` is the **only** cross-tenant automation identity, reserved for platform-internal jobs and never author-selectable. This mirrors `platform_admin` (operator) vs `org_admin` (tenant) and resolves "which tenant does a scheduled sweep run in?" — its own.
105+
106+
### D5 — User-less `runAs:'user'` is a configuration error [ruled]
107+
108+
A scheduled / unauthenticated-webhook trigger has no user; `runAs:'user'` there is incoherent. Validation rejects it (extending the #2308 lint into a hard author-time/compile rule for **all** user-less trigger types, not just schedule). The author picks `automation` (default) or `system`.
109+
110+
---
111+
112+
## Scope
113+
114+
**v1 (land now — foundational contract + the present, non-speculative gap):**
115+
- **Define** the automation identity contract (`EvalUser`-shaped, D1) in `@objectstack/spec` and **seed** the `automation` / `platform_automation` `sys_role` rows (non-breaking; nothing enforces against them yet — exactly how ADR-0068 v1 seeds `platform_admin`/`org_*`).
116+
- **Lint completeness**: extend the #2308 `flow-schedule-runas-unscoped` rule to every user-less trigger type (api/webhook/queue), and turn user-less `runAs:'user'` into a validation error (D5) at compile time.
117+
- Runtime behavior is **unchanged** from #2308 (the audible warning stays); no identity is wired into `runAs` resolution yet.
118+
119+
**M2 (roadmapped enforcement — ADR-0049 "build with the feature"):**
120+
- **Attribution wiring first (non-breaking)**: user-less runs carry the automation principal as the **audit actor** (D3) — concrete attribution without changing authorization.
121+
- **Authorization next (the behavior change)**: `runAs:'automation'` becomes the default for user-less triggers and **runs RLS-enforced** (D2); assign capabilities/permission-sets to the automation role (ADR-0066) so admins can least-privilege automation. Ship **default-broad → tighten** (the GitHub-Actions playbook) so AI-authored flows are not denied on day one, with each flow's effective automation grants **visible and restrictable**.
122+
- Keep `runAs:'system'` = god-mode throughout.
123+
124+
**Non-goals / deferred:**
125+
- A full IAM-style per-flow custom role surface (over-engineering pre-MVP; the two built-in roles + permission-set assignment suffice).
126+
- Migrating `plugin-reports` / audit / lifecycle jobs off their ad-hoc `SYSTEM_CTX` onto the principal — a fast-follow once the principal exists, tracked separately.
127+
- Capability-gating of the automation identity (follows ADR-0066 / cloud#474).
128+
129+
---
130+
131+
## Consequences
132+
133+
**Good**
134+
- Closes the user-less **fail-open** at its root: a scheduled run is RLS-enforced against a named, least-privilege-able principal — not skipped-into-god-mode.
135+
- Ends **anonymous automation writes**: every system-initiated change is attributable (audit/compliance: SOC2/ISO want every mutation tied to a principal).
136+
- One identity model: automation is just another `current_user` with `roles[]` (ADR-0068), so RLS/formula/audit/AI-authoring need **no new concept**.
137+
- Gives the platform a **reusable** non-human principal that `plugin-reports`, audit, ADR-0057 lifecycle jobs, webhooks, and AI agents can all adopt — replacing scattered `SYSTEM_CTX` hacks.
138+
- `runAs:'system'` stays exactly as-is → **no migration break** for existing elevated flows.
139+
140+
**Bad / costs**
141+
- A new built-in role + a stable principal id to seed and guard (reuses ADR-0068's seeding + the `usr_system` exclusion guards).
142+
- The M2 authorization flip (`automation` default, RLS-enforced) is a behavior change for user-less flows that today run unscoped — mitigated by default-broad-then-tighten and by the #2308 warning + v1 lint giving authors long advance notice.
143+
- A third `runAs` value enlarges the author surface (justified: it is the most-requested real mode, and the safe default).
144+
145+
## Alternatives considered
146+
147+
- **Keep NULL-then-claim for automation.** Rejected: there is no "claim event" for perpetual automation, so attribution never converges; and it does nothing for authorization.
148+
- **Pure runtime warning (the #2308 stopping point).** Necessary but insufficient: it makes the fail-open audible without eliminating it, and leaves writes unattributed.
149+
- **Fail-closed (deny user-less data ops).** Rejected in #2308: breaks legitimate scheduled CRUD (2/3 example flows relied on the default) and gives no attribution.
150+
- **Reuse `runAs:'system'` for scheduled (silent elevation).** Rejected: hides author intent and is exactly the ambient god-mode the four invariants warn against.
151+
152+
## Migration / conformance checklist
153+
154+
1. **`@objectstack/spec`** — document the automation identity as an `EvalUser` (D1); add `runAs:'automation'` to `FlowSchema.runAs` (describe), with the three-posture semantics (D2).
155+
2. **`plugin-security`** — seed `automation` / `platform_automation` `sys_role` rows (sibling to `bootstrap-declared-roles`), with labels/descriptions; extend the non-human exclusion guards to the new principal.
156+
3. **`@objectstack/cli`** — extend `flow-schedule-runas-unscoped` to all user-less trigger types; make user-less `runAs:'user'` a hard validation error (D5).
157+
4. **`service-automation`** *(M2)*`resolveRunContext` resolves user-less runs to the automation `EvalUser`; `runAs:'automation'` threads an RLS-enforcing context (not `isSystem`); audit actor stamped (D3).
158+
5. **`runtime` / audit** *(M2)* — record the automation actor; do **not** stamp `created_by`/`owner_id` to it (D3).
159+
6. **`plugin-reports`, ADR-0057 jobs** *(fast-follow)* — adopt the principal in place of ad-hoc `SYSTEM_CTX`.
160+
161+
## References
162+
163+
- #1888 (flow `runAs` enforcement), #2308 (schedule/user-less fail-open made audible — this ADR's trigger).
164+
- ADR-0049 (enforce-or-mark staging), ADR-0057 (sys_role / scope / lifecycle jobs), ADR-0066 (capabilities), ADR-0068 (EvalUser / identities-as-roles).

0 commit comments

Comments
 (0)