Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .changeset/hook-run-as-inherit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
"@objectstack/spec": minor
"@objectstack/objectql": minor
"@objectstack/lint": patch
---

feat(hooks): `runAs` on a hook — `'system' | 'user' | 'inherit'`, default `'inherit'`

A hook's `ctx.api` runs with the context of the write that fired it, so a column
an app wants **computed and never hand-written** could not be expressed: author
`editable: false` for the persona and the direct `PATCH` is refused — and so is
the hook that maintains the column, by the same field-level check. The guard and
the legitimate writer were the same door. The only elevation a hook had was the
in-process `ctx.api.sudo()`, which is not marshalled into the sandbox (a
`TypeError` once a build lowers the handler into a body) and which rides the L3
bundle path that is being retired.

`HookSchema` now accepts `runAs`:

| value | the hook's `ctx.api` data operations run as |
| --- | --- |
| `'inherit'` (default) | the context of the triggering write — exactly the behaviour every hook has today |
| `'system'` | elevated: a full-access, RLS-bypassing system principal |
| `'user'` | the triggering user; a hook whose trigger resolved no user has its data operations **refused** (`HOOK_UNSCOPED_DATA_ACCESS`) rather than run unscoped |

`'system'` and `'user'` mean here exactly what they mean on `flow.runAs` — same
word, same semantics. `'inherit'` is the hook-only third value, because only a
hook has a context to inherit; a flow establishes its identity from nothing,
which is why its default is `'user'` and this one's is `'inherit'`. Nothing on
`FlowSchema` changes.

**Purely additive: no migration, no behaviour change for any existing hook.**
The default reproduces today's behaviour by handing the engine-built `ctx.api`
through unchanged, and an absent key parses to it.

Scope, deliberately narrow: `ctx.api` data operations only. `condition`
evaluation, the `readonly` strip applied to the hook's own `ctx.input` payload,
`ctx.session` and `async` semantics all keep reading the triggering operation's
context, and declaring `runAs: 'system'` does not elevate the write that fired
the hook.

Elevation is authorization, not anonymity: a `runAs: 'system'` write still
carries the triggering user, so `created_by` / `updated_by` and the audit row
still name the operator.

Honoured on both execution surfaces — the in-process `handler` and the
sandboxed `body`.

Authoring notes:

- `sudo`, `elevate`, `elevated` and `isSystem` are refused with a prescription
naming `runAs`, and `run_as` is answered as a rename.
- `@objectstack/lint`'s gating `hook-api-update-readonly-field` rule now skips a
hook that declares `runAs: 'system'` — the static `readonly` strip skips a
system context, so the write it exists to catch does not happen — and its
hints name the knob. The `readonlyWhen` warning is unchanged: a system context
does not waive a conditional lock.
26 changes: 23 additions & 3 deletions content/docs/automation/hook-bodies.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -259,19 +259,39 @@ There is an asymmetry here that costs data if you learn it the hard way, so lear
| How the body writes it | What happens |
|:---|:---|
| `ctx.input.<field> = …` in `beforeInsert`/`beforeUpdate` | **Lands.** The stamp is a *server* value, not a caller-supplied one, so the strip leaves it alone. This is the recommended shape. |
| `ctx.api.object('x').update({ <field> })` | **Silently dropped.** `ctx.api` is scoped to the *triggering* operation's context, so on any non-system trigger the payload is an ordinary caller payload and the key is stripped. The call still returns success. |
| `ctx.api.sudo().object('x').update({ <field> })` | **`TypeError` — not available here.** `sudo()` is a member of the *in-process* `ScopedContext`; the VM's `ctx.api` carries `object()` and `transaction()` and nothing else, so a **body** cannot reach it. Worse than unavailable: the same source *works* when the handler runs in-process, so it passes a native `hook.handler(ctx)` test and throws only once the build lowers it into a body — aborting the triggering write under the default `onError: 'abort'`. `objectstack build` now refuses to lower such a handler and keeps it bundled instead. A hook has **no** declared elevation knob (no hook-side `runAs`); [#14010](https://github.com/objectstack-ai/objectstack/issues/14010) is where that gap is argued. |
| `ctx.api.object('x').update({ <field> })` | **Silently dropped** — unless the hook declares `runAs: 'system'`. `ctx.api` is scoped to the *triggering* operation's context, so on any non-system trigger the payload is an ordinary caller payload and the key is stripped, and the call still returns success. Declaring [`runAs: 'system'`](/docs/automation/hooks#elevation--runas) gives that `ctx.api` a system context, which the strip skips, so the write lands. |
| `ctx.api.sudo().object('x').update({ <field> })` | **`TypeError` — not available here.** `sudo()` is a member of the *in-process* `ScopedContext`; the VM's `ctx.api` carries `object()` and `transaction()` and nothing else, so a **body** cannot reach it. Worse than unavailable: the same source *works* when the handler runs in-process, so it passes a native `hook.handler(ctx)` test and throws only once the build lowers it into a body — aborting the triggering write under the default `onError: 'abort'`. `objectstack build` now refuses to lower such a handler and keeps it bundled instead. The knob to reach for is [`runAs: 'system'`](/docs/automation/hooks#elevation--runas) on the hook itself, which is declarative and works on **both** surfaces. |
| `ctx.api.object('x').insert({ <field> })` | **Lands.** INSERT is exempt — a create may legitimately seed read-only columns. |

The dropped case is the dangerous one: nothing fails, the step reports success, and the column is simply always null. Because both halves of that judgement are declared in your own stack, it is checked at author time and **gates the build**:

- `hook-api-update-readonly-field` — **error**. A body's literal `ctx.api.object('…').update()` / `.updateById()` writes a field the named object declares `readonly: true`.
- `hook-api-update-readonly-when-field` — **warning**. The same write against a `readonlyWhen` field, which strips per record *state*. The own-hook stamp **is** the workaround here, exactly as it is for static `readonly`: since [#9107](https://github.com/objectstack-ai/objectstack/issues/9107) the conditional strip judges the *caller's* entry payload, so a value a `beforeUpdate` hook **derives** is not caller-supplied and lands even on a locked record. (Deriving is the operative word — a hook that merely echoes the caller's own value back has written nothing the strip can tell from the caller's, and it still goes.) What does **not** help is elevation: `sudo()` a body cannot reach (see the row above), and — unlike the static strip — the conditional lock is **not** waived by a system context either, so there is no elevated caller for which a caller-supplied value survives. On this shape, confirm the write only targets records whose predicate is `false`, or derive the field in a `beforeUpdate` hook on the target object.
- `hook-api-update-readonly-when-field` — **warning**. The same write against a `readonlyWhen` field, which strips per record *state*. The own-hook stamp **is** the workaround here, exactly as it is for static `readonly`: since [#9107](https://github.com/objectstack-ai/objectstack/issues/9107) the conditional strip judges the *caller's* entry payload, so a value a `beforeUpdate` hook **derives** is not caller-supplied and lands even on a locked record. (Deriving is the operative word — a hook that merely echoes the caller's own value back has written nothing the strip can tell from the caller's, and it still goes.) What does **not** help is elevation: unlike the static strip, the conditional lock is **not** waived by a system context, so neither `runAs: 'system'` nor the `sudo()` a body cannot reach makes a caller-supplied value survive. On this shape, confirm the write only targets records whose predicate is `false`, or derive the field in a `beforeUpdate` hook on the target object.

Only literal object names and literal payload keys are seen; a `sudo()` chain, a dynamic object name, an object this stack does not declare, and `insert`/`create` are all skipped, so the rule has no opinion on them. The flow surface has carried the same gate as `flow-update-readonly-field` since [#3425](https://github.com/objectstack-ai/objectstack/issues/3425).

The table above is about a **hook** body. An **action** body is the one surface where the answer changes, so read this before you move a body from one to the other: an action body runs **elevated** — its `ctx.api` is built over the caller's envelope with `isSystem` set, which is the same trusted posture that lets an action bypass row and field permissions — and the static strip applies only to non-system callers. So `ctx.api.object('x').update({ someReadonlyField })` **lands** in an action, and there is no finding for it. Elevation does not waive the *conditional* lock, though, so that half does carry across: `action-api-update-readonly-when-field` — a **warning** — on an action body's literal `ctx.api` update to a `readonlyWhen` field ([#13770](https://github.com/objectstack-ai/objectstack/issues/13770)). Net effect when you move a body: a `readonly` write changes behaviour, a `readonlyWhen` write does not.

### Elevating a body — `runAs`

The knob that makes "guarded **and** maintained" expressible is a hook-level declaration, not a body API: `runAs: 'system'` gives the hook's `ctx.api` a system context, which is what lets a column nobody may hand-write still be maintained by the automation that owns it. It is honoured identically for an in-process `handler` and a sandboxed `body` — which is the whole reason it exists rather than `ctx.api.sudo()`, a member only the in-process surface has.

```ts
{
name: 'stamp_account_grade',
object: 'rating',
events: ['afterInsert'],
runAs: 'system', // this hook maintains a column nobody may hand-write
body: {
language: 'js',
source: `await ctx.api.object('account').update({ id: ctx.input.account_id, current_grade: ctx.input.grade });`,
capabilities: ['api.read', 'api.write'],
},
}
```

It scopes `ctx.api` and nothing else — the `condition` gate, `ctx.session`, the strip applied to the hook's own `ctx.input`, and `async` behaviour all still read the triggering operation's context. Full semantics, including the `'user'` value and what it refuses, are on [Hooks → Elevation](/docs/automation/hooks#elevation--runas).

### Errors from `ctx.api`

A rejected `ctx.api` call gives your body the host error's `name` and `message`, plus two structured properties when the host supplied them:
Expand Down
33 changes: 33 additions & 0 deletions content/docs/automation/hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,39 @@ Because `record` now means the record's *state*, `record.done == true` alone is
on **every** update of an already-done row. If you wrote a condition under the old
payload semantics expecting "the write that changed it", add the `previous` half.

## Elevation — `runAs`

A hook's `ctx.api` runs with the context of the write that **fired** it. That is usually what you want, and it is the one thing that makes a column both *guarded* and *maintained* impossible to express: author `editable: false` (or `readonly: true`) so nobody hand-writes the column, and the hook that computes it is refused by the very same check. The guard and the legitimate writer are the same door.

`runAs` is the declaration that separates them. It is the same key a [flow](/docs/automation/flows) declares, and `'system'` and `'user'` mean there exactly what they mean here, plus one value only a hook can have — a flow establishes its identity from nothing, so it has no context to inherit:

| `runAs` | The hook's `ctx.api` runs as |
|:---|:---|
| `'inherit'` *(default)* | the context of the triggering write — the behaviour every hook had before this key existed. |
| `'system'` | elevated: a full-access, RLS-bypassing system principal. Row and field permissions do not apply, and the static `readonly` strip is skipped. |
| `'user'` | the triggering **user**, so the hook can never exceed that user's grants. A hook fired by a write that carried no user has no identity to scope to, so its `ctx.api` data operations are **refused** (`HOOK_UNSCOPED_DATA_ACCESS`) rather than run unscoped. |

```ts
{
name: 'stamp_account_grade',
object: 'rating',
events: ['afterInsert'],
runAs: 'system', // this hook maintains a column nobody may hand-write
body: {
language: 'js',
source: `await ctx.api.object('account').update({ id: ctx.input.account_id, current_grade: ctx.input.grade });`,
capabilities: ['api.read', 'api.write'],
},
}
```

Four things worth knowing before you reach for it:

- **It scopes `ctx.api`, and nothing else.** The `condition` gate, `ctx.session`, the `readonly` strip applied to the hook's own `ctx.input` payload, and `async` behaviour all still read the triggering operation's context. Declaring `runAs: 'system'` does **not** elevate the write that fired the hook.
- **Elevation is authorization, not anonymity.** The triggering user rides along, so an elevated write still stamps `created_by` / `updated_by` with the operator and still appears under their name in the audit log. You do not trade the audit trail for the write.
- **Both surfaces honour it** — an in-process `handler` and a sandboxed `body` alike. This is the reason `runAs` exists rather than `ctx.api.sudo()`: `sudo()` is real only in-process, so the same source passed a unit test and threw in production.
- **`'user'` is a narrowing, and it fails closed.** It cannot resolve to a grant: a hook that declares it and finds no trigger user refuses its data operations instead of running them with no principal at all.

## Before Hook

Mutate the incoming record before it is saved. The engine exposes the pending
Expand Down
Loading
Loading