Skip to content

Commit 6215705

Browse files
committed
fix(service-automation, objectql): runAs refusal and run-setup warning texts hold on both kernels
The UnscopedRunDataAccessError message, the run-setup [runAs] warning and the HookUnscopedDataAccessError message said a user-less runAs:'user' data operation "would execute UNSCOPED (elevated, RLS-bypassing)". Since ADR-0096 D5 a kernel with plugin-security refuses an operation that carries no principal, so that counterfactual held only where no security plugin is composed. Each text now says: refused by the security plugin where one is composed, unscoped where none is. The remedy, codes, class and order are unchanged; only string text moves. The three pins that matched the old word move to the new clause. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 16096e8 commit 6215705

7 files changed

Lines changed: 30 additions & 10 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
'@objectstack/objectql': patch
4+
---
5+
6+
fix(service-automation, objectql): the `runAs: 'user'` refusals and the run-setup warning say what a principal-less operation meets on both kernels
7+
8+
Clause-②: no
9+
10+
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.
11+
12+
- `@objectstack/service-automation`: the `UnscopedRunDataAccessError` message, and the `[runAs]` warning logged at run setup for a flow whose trigger resolved no user.
13+
- `@objectstack/objectql`: the `HookUnscopedDataAccessError` message.
14+
15+
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.

‎packages/objectql/src/hook-run-as.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ describe('#14010 Hook.runAs — the identity a hook\'s ctx.api presents', () =>
207207
expect(thrown.hook).toBe('stamp_grade');
208208
// The remedy the author can act on, and the reason.
209209
expect(thrown.message).toContain("runAs: 'system'");
210-
expect(thrown.message).toContain('UNSCOPED');
210+
expect(thrown.message).toContain('refused by the security plugin where one is composed, unscoped where none is');
211211
// The decisive half: refusing means the operation did NOT run unscoped.
212212
expect(engine.seen).toEqual([]);
213213
});

‎packages/objectql/src/hook-run-as.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,10 @@ export class HookUnscopedDataAccessError extends Error {
117117
.join(', ');
118118
super(
119119
`[runAs] refusing a data operation (${where}): this hook's runAs is 'user' but no trigger user ` +
120-
`could be resolved, so the operation would execute UNSCOPED (elevated, RLS-bypassing) rather ` +
121-
`than restricted to a user. Declare \`runAs: 'system'\` on the hook to make the elevation ` +
120+
`could be resolved, so the operation cannot be restricted to a user. ` +
121+
`Without one it would carry no principal: ` +
122+
`refused by the security plugin where one is composed, unscoped where none is. ` +
123+
`Declare \`runAs: 'system'\` on the hook to make the elevation ` +
122124
`explicit and intended, or arrange for the trigger to supply a user (a write made with a system ` +
123125
`context carries none). Branch on \`code === '${HOOK_UNSCOPED_DATA_ACCESS_CODE}'\` (ADR-0112) ` +
124126
`to detect this. (ADR-0049)`,

‎packages/services/service-automation/src/builtin/crud-runas.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ describe('resolveRunDataContext (#1888 unit)', () => {
235235
expect(err).toBeInstanceOf(UnscopedRunDataAccessError);
236236
expect((err as UnscopedRunDataAccessError & { code: string }).code).toBe('AUTOMATION_UNSCOPED_RUN_DATA_ACCESS');
237237
expect(err!.message).toMatch(/runAs: 'system'/);
238-
expect(err!.message).toMatch(/UNSCOPED/);
238+
expect(err!.message).toMatch(/refused by the security plugin where one is composed, unscoped where none is/);
239239
// Names WHERE, so a refusal in a busy log is traceable to a flow + record.
240240
expect(err!.message).toContain("object 'invoice'");
241241
expect(err!.message).toContain("run 'run_1'");

‎packages/services/service-automation/src/engine.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6132,9 +6132,10 @@ export class AutomationEngine implements IAutomationService {
61326132
if (runIsUnscopedUserMode(runContext) && flowTouchesData(flow)) {
61336133
this.logger.warn(
61346134
`[runAs] flow '${flow.name}' executes with runAs:'user' but its trigger resolved no user ` +
6135-
`— its data operations will be REFUSED. Running them would execute UNSCOPED ` +
6136-
`(elevated, RLS-bypassing) rather than restricted, which is the fail-open ADR-0049 ` +
6137-
`forbids. Declare runAs:'system' to make the elevation explicit and intended, or arrange ` +
6135+
`— its data operations will be REFUSED. Without a user they would carry no principal: ` +
6136+
`refused by the security plugin where one is composed, unscoped where none is ` +
6137+
`(the fail-open ADR-0049 forbids). ` +
6138+
`Declare runAs:'system' to make the elevation explicit and intended, or arrange ` +
61386139
`for the trigger to supply a user. Note a user-less trigger is NOT only a schedule: a ` +
61396140
`record-change flow fired by a system write carries no user either (ADR-0049).`,
61406141
);

‎packages/services/service-automation/src/runtime-identity.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,10 @@ export class UnscopedRunDataAccessError extends Error {
8686
.join(', ');
8787
super(
8888
`[runAs] refusing a data operation${where ? ` (${where})` : ''}: this run's effective runAs is ` +
89-
`'user' but no trigger user could be resolved, so the operation would execute UNSCOPED ` +
90-
`(elevated, RLS-bypassing) rather than restricted to a user. Declare \`runAs: 'system'\` on the ` +
89+
`'user' but no trigger user could be resolved, so the operation cannot be restricted to a user. ` +
90+
`Without one it would carry no principal: ` +
91+
`refused by the security plugin where one is composed, unscoped where none is. ` +
92+
`Declare \`runAs: 'system'\` on the ` +
9193
`flow to make the elevation explicit and intended, or arrange for the trigger to supply a user ` +
9294
`(a write made with a system context carries none). (ADR-0049)`,
9395
);

‎packages/triggers/trigger-schedule/src/schedule-runas-e2e.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ describe('schedule trigger -> engine: user-less runAs fail-open via the REAL cro
119119
const w = runAsWarns(warns);
120120
expect(w).toHaveLength(1);
121121
expect(w[0]).toContain("flow 'nightly_sweep'");
122-
expect(w[0]).toMatch(/UNSCOPED/);
122+
expect(w[0]).toMatch(/refused by the security plugin where one is composed, unscoped where none is/);
123123
expect(w[0]).toMatch(/runAs:'system'/);
124124
});
125125

0 commit comments

Comments
 (0)