diff --git a/.changeset/22362-unscoped-run-strings.md b/.changeset/22362-unscoped-run-strings.md new file mode 100644 index 00000000000..63c76e179f4 --- /dev/null +++ b/.changeset/22362-unscoped-run-strings.md @@ -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. diff --git a/packages/objectql/src/hook-run-as.test.ts b/packages/objectql/src/hook-run-as.test.ts index 18094af7cca..3fc784ae8e4 100644 --- a/packages/objectql/src/hook-run-as.test.ts +++ b/packages/objectql/src/hook-run-as.test.ts @@ -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([]); }); diff --git a/packages/objectql/src/hook-run-as.ts b/packages/objectql/src/hook-run-as.ts index 315739a3507..26d796cde2d 100644 --- a/packages/objectql/src/hook-run-as.ts +++ b/packages/objectql/src/hook-run-as.ts @@ -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)`, diff --git a/packages/services/service-automation/src/builtin/crud-runas.test.ts b/packages/services/service-automation/src/builtin/crud-runas.test.ts index 073b2d4fc2d..f5f3e321cb0 100644 --- a/packages/services/service-automation/src/builtin/crud-runas.test.ts +++ b/packages/services/service-automation/src/builtin/crud-runas.test.ts @@ -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'"); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 864cbaf51b0..902f16ba587 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -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).`, ); diff --git a/packages/services/service-automation/src/runtime-identity.ts b/packages/services/service-automation/src/runtime-identity.ts index 19e1dde08e8..613932cfc20 100644 --- a/packages/services/service-automation/src/runtime-identity.ts +++ b/packages/services/service-automation/src/runtime-identity.ts @@ -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)`, ); diff --git a/packages/triggers/trigger-schedule/src/schedule-runas-e2e.test.ts b/packages/triggers/trigger-schedule/src/schedule-runas-e2e.test.ts index a8e3513169b..311d9d20fde 100644 --- a/packages/triggers/trigger-schedule/src/schedule-runas-e2e.test.ts +++ b/packages/triggers/trigger-schedule/src/schedule-runas-e2e.test.ts @@ -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'/); });