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
15 changes: 15 additions & 0 deletions .changeset/22362-unscoped-run-strings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/service-automation': patch
'@objectstack/objectql': patch
---

fix(service-automation, objectql): the `runAs: 'user'` refusals and the run-setup warning say what a principal-less operation meets on both kernels

Clause-②: no

Three author-facing texts told a flow or hook author that a `runAs: 'user'` data operation with no trigger user "would execute UNSCOPED (elevated, RLS-bypassing)" if it were not refused. That holds only on a kernel with no security plugin. Since ADR-0096 D5, a kernel with `@objectstack/plugin-security` refuses an operation that carries no principal (`403 PERMISSION_DENIED`). Each text now says both: without a user the operation would carry no principal, refused by the security plugin where one is composed, unscoped where none is.

- `@objectstack/service-automation`: the `UnscopedRunDataAccessError` message, and the `[runAs]` warning logged at run setup for a flow whose trigger resolved no user.
- `@objectstack/objectql`: the `HookUnscopedDataAccessError` message.

Only the wording changes. The refusals, their order, their codes (`AUTOMATION_UNSCOPED_RUN_DATA_ACCESS`, `HOOK_UNSCOPED_DATA_ACCESS` with its `403` status) and their remedy are unchanged: declare `runAs: 'system'` to make the elevation explicit and intended, or arrange for the trigger to supply a user.
2 changes: 1 addition & 1 deletion packages/objectql/src/hook-run-as.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ describe('#14010 Hook.runAs — the identity a hook\'s ctx.api presents', () =>
expect(thrown.hook).toBe('stamp_grade');
// The remedy the author can act on, and the reason.
expect(thrown.message).toContain("runAs: 'system'");
expect(thrown.message).toContain('UNSCOPED');
expect(thrown.message).toContain('refused by the security plugin where one is composed, unscoped where none is');
// The decisive half: refusing means the operation did NOT run unscoped.
expect(engine.seen).toEqual([]);
});
Expand Down
4 changes: 2 additions & 2 deletions packages/objectql/src/hook-run-as.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@ export class HookUnscopedDataAccessError extends Error {
.join(', ');
super(
`[runAs] refusing a data operation (${where}): this hook's runAs is 'user' but no trigger user ` +
`could be resolved, so the operation would execute UNSCOPED (elevated, RLS-bypassing) rather ` +
`than restricted to a user. Declare \`runAs: 'system'\` on the hook to make the elevation ` +
`could be resolved, so the operation cannot be restricted to a user. Without one it would carry no principal: ` +
`refused by the security plugin where one is composed, unscoped where none is. Declare \`runAs: 'system'\` on the hook to make the elevation ` +
`explicit and intended, or arrange for the trigger to supply a user (a write made with a system ` +
`context carries none). Branch on \`code === '${HOOK_UNSCOPED_DATA_ACCESS_CODE}'\` (ADR-0112) ` +
`to detect this. (ADR-0049)`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ describe('resolveRunDataContext (#1888 unit)', () => {
expect(err).toBeInstanceOf(UnscopedRunDataAccessError);
expect((err as UnscopedRunDataAccessError & { code: string }).code).toBe('AUTOMATION_UNSCOPED_RUN_DATA_ACCESS');
expect(err!.message).toMatch(/runAs: 'system'/);
expect(err!.message).toMatch(/UNSCOPED/);
expect(err!.message).toMatch(/refused by the security plugin where one is composed, unscoped where none is/);
// Names WHERE, so a refusal in a busy log is traceable to a flow + record.
expect(err!.message).toContain("object 'invoice'");
expect(err!.message).toContain("run 'run_1'");
Expand Down
6 changes: 3 additions & 3 deletions packages/services/service-automation/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6132,9 +6132,9 @@ export class AutomationEngine implements IAutomationService {
if (runIsUnscopedUserMode(runContext) && flowTouchesData(flow)) {
this.logger.warn(
`[runAs] flow '${flow.name}' executes with runAs:'user' but its trigger resolved no user ` +
`— its data operations will be REFUSED. Running them would execute UNSCOPED ` +
`(elevated, RLS-bypassing) rather than restricted, which is the fail-open ADR-0049 ` +
`forbids. Declare runAs:'system' to make the elevation explicit and intended, or arrange ` +
`— its data operations will be REFUSED. Without a user they would carry no principal: ` +
`refused by the security plugin where one is composed, unscoped where none is (the fail-open ADR-0049 forbids). ` +
`Declare runAs:'system' to make the elevation explicit and intended, or arrange ` +
`for the trigger to supply a user. Note a user-less trigger is NOT only a schedule: a ` +
`record-change flow fired by a system write carries no user either (ADR-0049).`,
);
Expand Down
4 changes: 2 additions & 2 deletions packages/services/service-automation/src/runtime-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,8 @@ export class UnscopedRunDataAccessError extends Error {
.join(', ');
super(
`[runAs] refusing a data operation${where ? ` (${where})` : ''}: this run's effective runAs is ` +
`'user' but no trigger user could be resolved, so the operation would execute UNSCOPED ` +
`(elevated, RLS-bypassing) rather than restricted to a user. Declare \`runAs: 'system'\` on the ` +
`'user' but no trigger user could be resolved, so the operation cannot be restricted to a user. Without one it would carry no principal: ` +
`refused by the security plugin where one is composed, unscoped where none is. Declare \`runAs: 'system'\` on the ` +
`flow to make the elevation explicit and intended, or arrange for the trigger to supply a user ` +
`(a write made with a system context carries none). (ADR-0049)`,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ describe('schedule trigger -> engine: user-less runAs fail-open via the REAL cro
const w = runAsWarns(warns);
expect(w).toHaveLength(1);
expect(w[0]).toContain("flow 'nightly_sweep'");
expect(w[0]).toMatch(/UNSCOPED/);
expect(w[0]).toMatch(/refused by the security plugin where one is composed, unscoped where none is/);
expect(w[0]).toMatch(/runAs:'system'/);
});

Expand Down
Loading