From 579609a153c127418d35bfc9fe2cce465f7004ba Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 01:24:09 +0000 Subject: [PATCH 01/11] wip(plugin-sharing): guard declared rule names and reconcile the shadowed declaration Claude-Session: https://claude.ai/code/session_01MaH9HpfmNnJGzgWQPqNmhP Co-authored-by: Claude --- .../plugin-sharing/src/sharing-plugin.ts | 32 +++- .../src/sharing-rule-service.ts | 148 +++++++++++++++++- 2 files changed, 176 insertions(+), 4 deletions(-) diff --git a/packages/plugins/plugin-sharing/src/sharing-plugin.ts b/packages/plugins/plugin-sharing/src/sharing-plugin.ts index c5c5d7ac2e7..d2b53fdb2c7 100644 --- a/packages/plugins/plugin-sharing/src/sharing-plugin.ts +++ b/packages/plugins/plugin-sharing/src/sharing-plugin.ts @@ -56,7 +56,7 @@ import { type SharingSecurityProbe, type SharingTenancyProbe, } from './sharing-service.js'; -import { SharingRuleService } from './sharing-rule-service.js'; +import { SharingRuleService, bindDeclaredRuleNameGuard } from './sharing-rule-service.js'; import { ShareLinkService } from './share-link-service.js'; import { registerShareLinkRoutes } from './share-link-routes.js'; import { bindRuleHooks, unbindAllRuleHooks, bindRuleCriteriaGuard, RULE_REBIND_TRIGGER_PACKAGE } from './rule-hooks.js'; @@ -467,10 +467,23 @@ export class SharingServicePlugin implements Plugin { * logs and leaves the previous grants alone — it must never fail the * authoring write. */ - const scheduleReconcile = (ruleId: string, deleted: boolean): Promise => { + const scheduleReconcile = ( + ruleId: string, + deleted: boolean, + names: ReadonlyArray<{ name: string; organizationId: string | null }>, + ): Promise => { const run = this.ruleRebindChain.then(async () => { const ruleService = this.ruleService; if (!ruleService) return; + // [ADR-0131 D6 regime O — ruling B′] A write of a declared rule's name + // (the customised copy's creation, its `active` switch, its removal) + // reconciles the declaration's grants in that scope NOW, not at the next + // boot's orphan sweep. FIRST, so a failure here stops before the row's + // own pass below: the declaration's grants and the copy's are never both + // left in force by this write. + for (const { name, organizationId } of names) { + await ruleService.reconcileDeclaredRuleName(name, organizationId); + } if (deleted) { await ruleService.revokeRuleGrants(ruleId); return; @@ -500,8 +513,16 @@ export class SharingServicePlugin implements Plugin { const data = hookCtx?.result ?? hookCtx?.input?.data ?? {}; const ruleId = String(data?.id ?? hookCtx?.input?.id ?? ''); if (!ruleId) return; + // The names this write touched, each in its row's organization: the + // pre-image's (a rename away from it, a delete) and the written row's. + const names = new Map(); + for (const row of [hookCtx?.previous, deleted ? undefined : { ...(hookCtx?.previous ?? {}), ...data }]) { + if (!row || typeof row.name !== 'string' || !row.name) continue; + const organizationId = row.organization_id == null ? null : String(row.organization_id); + names.set(`${row.name}::${organizationId ?? ''}`, { name: row.name, organizationId }); + } try { - await scheduleReconcile(ruleId, deleted); + await scheduleReconcile(ruleId, deleted, [...names.values()]); } catch (err: any) { ctx.logger.warn('SharingServicePlugin: sharing-rule grant reconcile failed — grants left unchanged', { rule: ruleId, @@ -849,6 +870,11 @@ export class SharingServicePlugin implements Plugin { // match-all criteria gate has to sit on the table itself too. bindRuleCriteriaGuard(engine, ctx.logger as any); + // [ADR-0131 D6 regime O, guarded — ruling B′] …and so must the + // refusal of a NEW row, or a rename, that takes a declared rule's + // name: `defineRule` makes it for its own callers. + bindDeclaredRuleNameGuard(engine, this.ruleService); + // [#2909 T1] Stamp `customized` on admin edits of seeded rules so // a customized copy keeps answering for its name — the declared // rule is not served beside it (ADR-0131 D2), and the ownership diff --git a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts index af952aac8aa..449416bfec6 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts @@ -178,6 +178,100 @@ export function isDeclaredSharingRule(rule: Pick, write: 'create' | 'rename') { + super( + `sharing rule '${rule.name}' is declared by the application (on object '${rule.object}'), so ` + + (write === 'create' ? 'a new rule cannot take its name' : 'no rule can be renamed to it') + + ` — a row of that name would replace the declared rule. To narrow or switch off '${rule.name}', ` + + `edit the declared rule itself: save it with id '${rule.name}', the id the rule list ` + + '(GET /api/v1/sharing/rules) gives it, which stores the customised copy. Otherwise choose another name.', + ); + this.name = 'DeclaredSharingRuleNameConflictError'; + this.rule = rule.name; + } +} + +/** Package id of {@link bindDeclaredRuleNameGuard}'s hooks. */ +export const DECLARED_RULE_NAME_GUARD_PACKAGE = 'plugin-sharing:declared-rule-name-guard'; + +/** + * [ruling B′] Bind the guard above on `sys_sharing_rule` writes that do not + * come through {@link SharingRuleService.defineRule}: the `/data` door Setup's + * New and its edit form write through, scripts, imports. An insert of a + * declared name is refused, and so is an update that CHANGES `name` onto one; + * an update that leaves the name alone (a customised copy's `active` switch) + * passes. + * + * System writes pass: `defineRule` writes under the system context after making + * the same check itself, and the remaining system writers are the platform's + * own machinery (the ownership ceremony, migrations). + */ +export function bindDeclaredRuleNameGuard( + engine: { + registerHook(event: string, handler: (ctx: any) => any, options?: Record): void; + unregisterHooksByPackage?(packageId: string): number; + }, + rules: Pick, +): void { + engine.unregisterHooksByPackage?.(DECLARED_RULE_NAME_GUARD_PACKAGE); + const guard = (insert: boolean) => async (ctx: any) => { + if ((ctx?.session as any)?.isSystem) return; + const data = ctx?.input?.data; + if (!data || typeof data !== 'object' || Array.isArray(data)) return; + if (typeof data.name !== 'string') return; + // An update is a rename only against its pre-image (`ctx.previous`, the + // `HookContext` contract both update shapes bind before `beforeUpdate`): a + // full-form save re-sends the name it already has. + if (!insert) { + const previous = ctx?.previous as Record | undefined; + if (!previous || typeof previous !== 'object' || data.name === previous.name) return; + } + const declared = await rules.declaredRuleNamed(data.name); + if (declared) throw new DeclaredSharingRuleNameConflictError(declared, insert ? 'create' : 'rename'); + }; + const opts = { object: 'sys_sharing_rule', packageId: DECLARED_RULE_NAME_GUARD_PACKAGE, priority: 100 }; + engine.registerHook('beforeInsert', guard(true), opts); + engine.registerHook('beforeUpdate', guard(false), opts); +} + /** * The `sys_record_share` rows a rule's grants are: `source: 'rule'` plus the * rule's id. A declared rule served under a wall is one projection per @@ -652,7 +746,7 @@ export class SharingRuleService implements ISharingRuleService { ); } - async defineRule(input: DefineSharingRuleInput, context: ExecutionContext): Promise { + async defineRule(input: DefineSharingRuleWrite, context: ExecutionContext): Promise { this.assertCanManageRules(context); if (!input.name) throw new Error('VALIDATION_FAILED: name is required'); if (!input.label) throw new Error('VALIDATION_FAILED: label is required'); @@ -753,6 +847,18 @@ export class SharingRuleService implements ISharingRuleService { return rowFromRule({ ...row, ...patch }); } + // [ADR-0131 D6 regime O, guarded — ruling B′] No row holds this name in the + // caller's scope, so the write creates one. Under a declared rule's name that + // row replaces the declaration in the scope: allowed only as the EDIT of the + // projection the caller reads (it sends the projection's id, the rule's + // name), refused as a new rule. + const declared = await this.declaredRuleNamed(input.name); + if (declared) { + const editsProjection = + input.id === declared.name && (await this.declaredRules(context, { name: declared.name })).length > 0; + if (!editsProjection) throw new DeclaredSharingRuleNameConflictError(declared, 'create'); + } + const newRow: any = { id: uid('srule'), organization_id: orgId, @@ -912,6 +1018,46 @@ export class SharingRuleService implements ISharingRuleService { return scopes.includes(orgId) ? [orgId] : []; } + /** + * [ruling B′] The declared rule named `name`, or `null` — whatever scope it + * is served in: a declaration is the application's in every organization, so + * its name is taken in every one ({@link DeclaredSharingRuleNameConflictError}). + */ + async declaredRuleNamed(name: string): Promise { + if (!this.declaredSource || !name) return null; + return (await this.declaredSource.list()).find((r) => r.name === name) ?? null; + } + + /** + * [ADR-0131 D6 regime O — ruling B′] Reconcile a declared rule's name-keyed + * grants in the scope a `sys_sharing_rule` write of that name touched, in the + * write's own request — not at the next boot's {@link sweepOrphanedRuleGrants}. + * + * The scope is the `held` key's ({@link declaredRules}): under `single` the + * one organization-less projection, so a row of the name in any organization + * answers everywhere; under a wall `organizationId`'s projection alone, so an + * organization's customised copy is its own. A row created under the name (the + * customised copy) or switched on or off leaves the declaration shadowed there, + * and its grants are revoked; a write that leaves no row holding the name + * serves the declaration again, and its grants are re-derived. Either way it is + * the one-rule pass every reconcile runs ({@link evaluateResolvedRule} over the + * {@link ruleGrantScope} of the projection). + * + * Scopes not known (a failed organization enumeration) reconcile nothing: the + * boot sweep's own rule for them — hold every declared grant — applies. + */ + async reconcileDeclaredRuleName(name: string, organizationId: string | null): Promise { + const declared = await this.declaredRuleNamed(name); + const scopes = this.declaredScopes; + if (!declared || scopes === null) return; + const single = scopes.includes(null); + const scope = single ? null : organizationId; + if (!single && (scope == null || !scopes.includes(scope))) return; + const served = (await this.declaredRules(SYSTEM_CTX, { name })).find((r) => r.organization_id === scope); + if (served) await this.evaluateResolvedRule(served); + else await this.purgeGrants(ruleGrantScope(projectDeclaredRule(declared, scope))); + } + async getRule(idOrName: string, context: ExecutionContext): Promise { this.assertCanManageRules(context); // [ADR-0111 D6] if (!idOrName) return null; From 889e4c3cf72330245f9958b42e4b4b8b7b09c4c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 01:31:23 +0000 Subject: [PATCH 02/11] test(plugin-sharing,rest): pin the declared-name guard, the edit path and the immediate reconcile Claude-Session: https://claude.ai/code/session_01MaH9HpfmNnJGzgWQPqNmhP Co-authored-by: Claude --- .../plugin-sharing/src/sharing-rule.test.ts | 208 +++++++++++++++++- packages/rest/src/sharing-routes.ts | 6 + ...haring-rule-declared-name-conflict.test.ts | 109 +++++++++ 3 files changed, 321 insertions(+), 2 deletions(-) create mode 100644 packages/rest/src/sharing-rule-declared-name-conflict.test.ts diff --git a/packages/plugins/plugin-sharing/src/sharing-rule.test.ts b/packages/plugins/plugin-sharing/src/sharing-rule.test.ts index cb9f88b3248..ea82e797a14 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule.test.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule.test.ts @@ -6,7 +6,7 @@ import { assertEngineDeleteDispatch } from '@objectstack/objectql'; // assert an HTTP status they SOURCE rather than one they restate. import { HttpStatusErrorCodeMap } from '@objectstack/spec/api'; import { SharingService } from './sharing-service.js'; -import { SharingRuleService, isDeclaredSharingRule } from './sharing-rule-service.js'; +import { SharingRuleService, isDeclaredSharingRule, bindDeclaredRuleNameGuard } from './sharing-rule-service.js'; import { TeamGraphService, expandPrincipal } from './team-graph.js'; import { BusinessUnitGraphService } from './business-unit-graph.js'; // [commit 04d03c3a0] The ADDRESSING primitive, imported so the negative half of the @@ -16,7 +16,7 @@ import { PositionGraphService } from './position-graph.js'; import { celToFilter, celToFilterOutcome, createDeclaredSharingRuleSource } from './declared-sharing-rules.js'; import { isMatchAllCriteria } from './rule-criteria.js'; import { bindRuleCriteriaGuard } from './rule-hooks.js'; -import { resolveDeclaredRuleScopes } from './sharing-plugin.js'; +import { resolveDeclaredRuleScopes, SharingServicePlugin } from './sharing-plugin.js'; interface Row { [k: string]: any } @@ -2341,6 +2341,210 @@ describe('[ADR-0131 D2] declared sharing rules are read from the registry, never expect((await h.rules.listRules({}, BOOT)).map((r) => r.organization_id)).toEqual(['org_b']); }); }); + + // ─────────────────────────────────────────────────────────────────── + // [ADR-0131 D6 regime O, guarded — ruling B′] A row of a declared rule's + // name replaces the declaration in its scope. Only the EDIT of the + // projection (saved back under its id, the rule's name) may create that + // row; a new rule, or a rename, taking the name is refused 409. And the + // write that creates, switches or removes the row reconciles the + // declaration's grants in that scope at once — not at the next boot's sweep. + // ─────────────────────────────────────────────────────────────────── + describe('regime O, guarded: the declared name is taken; the edit is the overlay', () => { + const adminOf = (org: string) => + ({ userId: 'admin', tenantId: org, organizationId: org, systemPermissions: ['manage_sharing'] }) as any; + const RULE_INPUT = { + name: DECLARED.name, label: 'Red projects → exec (ours)', object: 'project', + criteria: { status: 'red' }, recipientType: 'user', recipientId: 'exec', accessLevel: 'read', + } as const; + + /** The refusal's declared envelope — `code` + `status`, never the sentence. */ + const refusalOf = async (call: Promise): Promise<{ code: unknown; status: unknown; rule: unknown }> => { + try { + await call; + } catch (err: any) { + return { code: err?.code, status: err?.status, rule: err?.rule }; + } + throw new Error('expected the write to be REFUSED, but it resolved successfully'); + }; + + /** + * The plugin's `sys_sharing_rule` write trigger, bound over this harness's + * engine with the real rule service — the seam every door's write reaches. + */ + function wireTrigger(h: ReturnType) { + const hooks: Array<{ event: string; handler: (ctx: any) => any; options: Record }> = []; + h.engine.registerHook = (event: string, handler: (ctx: any) => any, options: Record = {}) => { + hooks.push({ event, handler, options }); + }; + h.engine.unregisterHooksByPackage = (pkg: string) => { + let removed = 0; + for (let i = hooks.length - 1; i >= 0; i--) if (hooks[i].options.packageId === pkg) { hooks.splice(i, 1); removed++; } + return removed; + }; + const plugin = new SharingServicePlugin(); + (plugin as any).ruleService = h.rules; + (plugin as any).ruleGrantsBootReconciled = true; + const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + (plugin as any).bindRuleRebindTriggers(h.engine, { logger }); + bindDeclaredRuleNameGuard(h.engine, h.rules); + const fire = async (event: string, ctx: Record) => { + for (const x of hooks.filter((y) => y.event === event && y.options.object === 'sys_sharing_rule')) await x.handler(ctx); + }; + return { fire, logger }; + } + const storedRow = (h: ReturnType, id: string): Row => + (h.engine._tables.sys_sharing_rule ?? []).find((r: Row) => r.id === id); + + describe('`single`', () => { + let h: ReturnType; + beforeEach(async () => { + h = harness([DECLARED], [ + { id: 'p_red', status: 'red', organization_id: 'org_default' }, + { id: 'p_red_2', status: 'red', organization_id: 'org_other' }, + ]); + await h.rules.evaluateRule(DECLARED.name, BOOT); + }); + + it.each([ + ['an org-scoped sharing admin', adminOf('org_default')], + ['a system context', BOOT], + ])('%s: a NEW rule taking the declared name is refused 409 RESOURCE_CONFLICT, naming the vendor rule, and nothing lands', async (_who, ctx) => { + const before = h.grants().map((g) => g.id).sort(); + const refusal = await refusalOf(h.rules.defineRule({ ...RULE_INPUT } as any, ctx)); + expect(refusal).toEqual({ code: 'RESOURCE_CONFLICT', status: 409, rule: DECLARED.name }); + // The catalogue's own code for the status, not a restated pair. + expect(HttpStatusErrorCodeMap[409]).toBe(refusal.code); + expect(h.engine._tables.sys_sharing_rule ?? []).toEqual([]); + expect(h.grants().map((g) => g.id).sort()).toEqual(before); + expect((await h.rules.listRules({}, BOOT)).map((r) => r.id)).toEqual([DECLARED.name]); + }); + + it('an id that is not the projection’s is a new rule too — refused', async () => { + const refusal = await refusalOf(h.rules.defineRule({ ...RULE_INPUT, id: 'srule_elsewhere' } as any, adminOf('org_default'))); + expect(refusal.code).toBe('RESOURCE_CONFLICT'); + expect(h.engine._tables.sys_sharing_rule ?? []).toEqual([]); + }); + + it('CONTROL: a name no declaration holds is created as before, with no id', async () => { + const row = await h.rules.defineRule({ ...RULE_INPUT, name: 'red_to_exec_too' } as any, adminOf('org_default')); + expect(row.id).toMatch(/^srule_/); + }); + + it('the EDIT of the projection (saved back under its id) stores the customised copy, which answers for the name', async () => { + const copy = await h.rules.defineRule({ ...RULE_INPUT, id: DECLARED.name, active: false } as any, adminOf('org_default')); + expect(copy).toMatchObject({ name: DECLARED.name, organization_id: 'org_default', active: false }); + expect(copy.id).not.toBe(DECLARED.name); + expect((await h.rules.listRules({}, BOOT)).map((r) => r.id)).toEqual([copy.id]); + // The copy is a stored row now: saving it again is the upsert, no id needed. + const again = await h.rules.defineRule({ ...RULE_INPUT, active: true } as any, adminOf('org_default')); + expect(again.id).toBe(copy.id); + }); + + it('the copy’s creation revokes the declaration’s grants EVERYWHERE in the same write — before any boot sweep', async () => { + const { fire } = wireTrigger(h); + expect(h.grants().filter((g) => g.source_id === DECLARED.name)).toHaveLength(2); + const copy = await h.rules.defineRule({ ...RULE_INPUT, id: DECLARED.name, active: false } as any, adminOf('org_default')); + await fire('afterInsert', { result: storedRow(h, copy.id) }); + // Under `single` the one overlay answers everywhere: both organizations' grants go. + expect(h.grants()).toEqual([]); + // Nothing was left for the sweep. + expect(await h.rules.sweepOrphanedRuleGrants()).toBe(0); + }); + + it('an `active` change on the copy reconciles the declaration’s grants again — a stale one goes', async () => { + const { fire } = wireTrigger(h); + const copy = await h.rules.defineRule({ ...RULE_INPUT, id: DECLARED.name } as any, adminOf('org_default')); + await fire('afterInsert', { result: storedRow(h, copy.id) }); + // The copy grants under its own id (an organization's row sweeps its own + // organization's records); the declaration grants nothing. + expect(h.grants().map((g) => [g.record_id, g.source_id])).toEqual([['p_red', copy.id]]); + // A declaration grant left standing (a write that failed half-way, say). + h.engine._tables.sys_record_share.push({ + id: 'share_stale', organization_id: 'org_default', object_name: 'project', record_id: 'p_red', + recipient_type: 'user', recipient_id: 'exec', access_level: 'read', source: 'rule', source_id: DECLARED.name, + }); + // The admin switches the copy off through the `/data` door. + const previous = { ...storedRow(h, copy.id) }; + Object.assign(storedRow(h, copy.id), { active: false }); + await fire('afterUpdate', { previous, input: { id: copy.id, data: { active: false } }, result: storedRow(h, copy.id) }); + expect(h.grants()).toEqual([]); + }); + + it('a reconcile failure stops before the copy’s own pass: the two are never both in force', async () => { + const { fire, logger } = wireTrigger(h); + const copy = await h.rules.defineRule({ ...RULE_INPUT, id: DECLARED.name } as any, adminOf('org_default')); + const evaluate = vi.spyOn(h.rules, 'evaluateRule'); + vi.spyOn(h.rules, 'reconcileDeclaredRuleName').mockRejectedValueOnce(new Error('driver down')); + await fire('afterInsert', { result: storedRow(h, copy.id) }); + expect(evaluate).not.toHaveBeenCalled(); + expect(h.grants().every((g) => g.source_id === DECLARED.name)).toBe(true); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('grant reconcile failed'), expect.anything()); + }); + + it('removing the copy serves the declaration again, its grants re-derived in the same write', async () => { + const { fire } = wireTrigger(h); + const copy = await h.rules.defineRule({ ...RULE_INPUT, id: DECLARED.name, active: false } as any, adminOf('org_default')); + await fire('afterInsert', { result: storedRow(h, copy.id) }); + expect(h.grants()).toEqual([]); + const previous = { ...storedRow(h, copy.id) }; + h.engine._tables.sys_sharing_rule = []; + await fire('afterDelete', { previous, input: { id: copy.id } }); + expect(h.grants().map((g) => [g.record_id, g.source_id]).sort()).toEqual([ + ['p_red', DECLARED.name], ['p_red_2', DECLARED.name], + ]); + }); + + describe('the `/data` door (Setup’s New and edit form)', () => { + it('an INSERT of the declared name is refused 409 before it lands; a system write passes', async () => { + const { fire } = wireTrigger(h); + const session = { userId: 'admin', isSystem: false }; + const refusal = await refusalOf(fire('beforeInsert', { session, input: { data: { name: DECLARED.name } } })); + expect(refusal).toEqual({ code: 'RESOURCE_CONFLICT', status: 409, rule: DECLARED.name }); + await expect(fire('beforeInsert', { session: { isSystem: true }, input: { data: { name: DECLARED.name } } })).resolves.toBeUndefined(); + await expect(fire('beforeInsert', { session, input: { data: { name: 'another_rule' } } })).resolves.toBeUndefined(); + }); + + it('a RENAME onto the declared name is refused; a save that keeps the copy’s name, or renames away, passes', async () => { + const { fire } = wireTrigger(h); + const session = { userId: 'admin', isSystem: false }; + const other = { id: 'srule_other', name: 'another_rule' }; + const refusal = await refusalOf(fire('beforeUpdate', { session, previous: other, input: { id: other.id, data: { name: DECLARED.name } } })); + expect(refusal.code).toBe('RESOURCE_CONFLICT'); + const copy = { id: 'srule_copy', name: DECLARED.name }; + await expect(fire('beforeUpdate', { session, previous: copy, input: { id: copy.id, data: { name: DECLARED.name, active: false } } })).resolves.toBeUndefined(); + await expect(fire('beforeUpdate', { session, previous: copy, input: { id: copy.id, data: { name: 'renamed_away' } } })).resolves.toBeUndefined(); + }); + }); + }); + + describe('walled: an organization’s overlay is its own', () => { + let h: ReturnType; + beforeEach(async () => { + h = harness([DECLARED], [ + { id: 'a_red', status: 'red', organization_id: 'org_a' }, + { id: 'b_red', status: 'red', organization_id: 'org_b' }, + ]); + h.rules.setDeclaredRuleScopes(['org_a', 'org_b']); + await h.rules.evaluateRule(DECLARED.name, BOOT); + }); + + it('a NEW rule of the declared name is refused in an organization too', async () => { + expect((await refusalOf(h.rules.defineRule({ ...RULE_INPUT } as any, adminOf('org_a')))).code).toBe('RESOURCE_CONFLICT'); + }); + + it('org_a’s copy revokes org_a’s declared grants at once; org_b keeps the declaration and its grants', async () => { + const { fire } = wireTrigger(h); + const copy = await h.rules.defineRule({ ...RULE_INPUT, id: DECLARED.name, active: false } as any, adminOf('org_a')); + await fire('afterInsert', { result: storedRow(h, copy.id) }); + expect(h.grants().map((g) => [g.record_id, g.organization_id, g.source_id])).toEqual([['b_red', 'org_b', DECLARED.name]]); + expect((await h.rules.listRules({}, BOOT)).map((r) => [r.id, r.organization_id])).toEqual([ + [copy.id, 'org_a'], [DECLARED.name, 'org_b'], + ]); + expect(await h.rules.sweepOrphanedRuleGrants()).toBe(0); + }); + }); + }); }); // ───────────────────────────────────────────────────────────────────── diff --git a/packages/rest/src/sharing-routes.ts b/packages/rest/src/sharing-routes.ts index dfa1abd3df5..f474b4ad087 100644 --- a/packages/rest/src/sharing-routes.ts +++ b/packages/rest/src/sharing-routes.ts @@ -692,6 +692,12 @@ export function registerSharingRuleEndpoints(this: SharingRuleRouteHost, basePat // refuses a match-all criteria, so the typo surfaces as a // 400 naming the field instead of a silent 201. const input = { + // [ADR-0131 D6 regime O, guarded] The id of the rule this + // write EDITS: a declared rule's projection is listed (and + // saved back) under its name, which is what lets the + // service tell the edit that stores its customised copy + // from a new rule taking its name — refused, 409. + id: body.id, name: body.name, label: body.label, description: body.description, diff --git a/packages/rest/src/sharing-rule-declared-name-conflict.test.ts b/packages/rest/src/sharing-rule-declared-name-conflict.test.ts new file mode 100644 index 00000000000..240fed92334 --- /dev/null +++ b/packages/rest/src/sharing-rule-declared-name-conflict.test.ts @@ -0,0 +1,109 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D6 regime O, guarded] `POST {basePath}/sharing/rules` and a + * declared rule's name. + * + * A `sys_sharing_rule` row of a declared rule's name replaces the declaration + * in its scope. The sharing-rule service refuses a NEW rule taking that name + * with a declared envelope — `code: 'RESOURCE_CONFLICT'`, `status: 409` + * (`DeclaredSharingRuleNameConflictError`, plugin-sharing) — and accepts the + * EDIT of the declared rule, which the caller sends under the projection's id + * (the rule's name). This door therefore owes two things: + * + * - the refusal answers at its own status, in the nested envelope, through + * the classified-refusal arm of `handleError` — not as `500 RULE_DEFINE_FAILED`; + * - the body's `id` reaches the service, or no caller could make the edit. + * + * The thrown error is built here in the service's declared shape: this package + * does not depend on plugin-sharing, and the arm reads the shape, not the class. + */ + +import { describe, it, expect, vi } from 'vitest'; +// `.js` on purpose — NodeNext resolution requires the extension (#7248). +import { RestServer } from './rest-server.js'; +import { HttpStatusErrorCodeMap } from '@objectstack/spec/api'; + +const RULES = '/api/v1/sharing/rules'; + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +function mockRes() { + const res: any = { + statusCode: 200, + json: vi.fn(function (this: any, body: any) { this._body = body; return this; }), + send: vi.fn(function (this: any) { return this; }), + end: vi.fn(function (this: any) { return this; }), + setHeader: vi.fn(function (this: any) { return this; }), + status: vi.fn(function (this: any, code: number) { this.statusCode = code; return this; }), + header: vi.fn(function (this: any) { return this; }), + }; + return res; +} + +function boot(ruleService: any) { + const rest = new RestServer( + mockServer() as any, + { getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: {} }) } as any, + { api: { requireAuth: false } } as any, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, + undefined, undefined, + (async () => ruleService) as any, // sharingRulesServiceProvider + ); + (rest as any).resolveExecCtx = async () => ({ userId: 'u_admin' }); + rest.registerRoutes(); + const found = (rest as any).getRoutes().find((r: any) => r.method === 'POST' && r.path === RULES); + if (!found) throw new Error(`route not registered: POST ${RULES}`); + return async (body: Record): Promise<{ status: number; body: any }> => { + const res = mockRes(); + await found.handler({ method: 'POST', path: RULES, headers: {}, query: {}, body, params: {} } as any, res); + return { status: res.statusCode, body: res.json.mock.calls.at(-1)?.[0] }; + }; +} + +const RULE = { + name: 'share_red_projects', label: 'Red projects', object: 'project', + criteria: { status: 'red' }, recipientType: 'user', recipientId: 'exec', +}; + +/** The service's refusal, in its declared shape. */ +function conflict(): Error { + return Object.assign( + new Error("sharing rule 'share_red_projects' is declared by the application (on object 'project'), so a new rule cannot take its name"), + { code: 'RESOURCE_CONFLICT', status: 409, rule: 'share_red_projects' }, + ); +} + +describe('[ADR-0131 D6 regime O, guarded] POST /sharing/rules — a declared rule’s name', () => { + it('a new rule taking the name answers 409 RESOURCE_CONFLICT in the nested envelope, the service writing nothing else', async () => { + const defineRule = vi.fn().mockRejectedValue(conflict()); + const answer = await boot({ defineRule })(RULE); + expect(answer.status, JSON.stringify(answer.body)).toBe(409); + expect(answer.body?.error?.code).toBe('RESOURCE_CONFLICT'); + expect(HttpStatusErrorCodeMap[409]).toBe(answer.body?.error?.code); + expect(answer.body?.success).toBe(false); + expect(defineRule).toHaveBeenCalledTimes(1); + }); + + it('PRESERVATION: the body’s `id` reaches the service — the edit of the declared rule is addressed by it', async () => { + const row = { id: 'srule_copy', name: RULE.name }; + const defineRule = vi.fn().mockResolvedValue(row); + const answer = await boot({ defineRule })({ ...RULE, id: RULE.name, active: false }); + expect(answer.status).toBe(201); + expect(answer.body).toEqual(row); + expect(defineRule.mock.calls[0][0]).toMatchObject({ id: RULE.name, name: RULE.name, active: false, object: 'project' }); + }); + + it('ANTI-VACUITY: an unexpected fault is still the route’s own 500', async () => { + const answer = await boot({ defineRule: vi.fn().mockRejectedValue(new Error('kaboom')) })(RULE); + expect(answer.status).toBe(500); + expect(answer.body?.code ?? answer.body?.error?.code).toBe('RULE_DEFINE_FAILED'); + }); +}); From 6e5cccb2d1d45cf13eecfa67e763efc7fc785eb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 01:32:29 +0000 Subject: [PATCH 03/11] chore(changeset): declare the declared-name guard as a breaking narrowing Claude-Session: https://claude.ai/code/session_01MaH9HpfmNnJGzgWQPqNmhP Co-authored-by: Claude --- .../24421-sharing-rule-declared-name-guard.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .changeset/24421-sharing-rule-declared-name-guard.md diff --git a/.changeset/24421-sharing-rule-declared-name-guard.md b/.changeset/24421-sharing-rule-declared-name-guard.md new file mode 100644 index 00000000000..4e2bf890a21 --- /dev/null +++ b/.changeset/24421-sharing-rule-declared-name-guard.md @@ -0,0 +1,18 @@ +--- +'@objectstack/plugin-sharing': minor +'@objectstack/rest': patch +--- + +fix(plugin-sharing)!: a new sharing rule, or a rename, cannot take a declared rule's name — the edit of the declared rule stores its customised copy, and that write reconciles the declaration's grants at once + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +A `sys_sharing_rule` row holding a declared rule's name replaces the declared rule in its scope (ADR-0131 D6, regime O): that is how an organization narrows or switches off a vendor rule. Until now any write could take the name — Setup's New, a script, an AI tool — and so switch the vendor rule off without anyone asking to. + +- **Refused now:** `POST /api/v1/sharing/rules` (and `SharingRuleService.defineRule`) creating a rule under a declared rule's name, and a `/data` insert of `sys_sharing_rule` under one, or an update that renames a row onto one. FROM `201` / `200` with the new row replacing the vendor rule, TO `409 RESOURCE_CONFLICT`, naming the declared rule. Fix: to customise the vendor rule, save it back with its `id` (the declared rule's name, as `GET /api/v1/sharing/rules` lists it); otherwise choose another name. +- **Unchanged:** saving the declared rule with its `id` stores the organization's customised copy, exactly as a same-name `POST` did before; editing or switching off that copy, through either door, works as before; every rule whose name no declaration holds is created and renamed as before. +- **Reconciled at once:** the write that creates the customised copy, switches it on or off, or removes it reconciles the declaration's grants in that scope in the same request — revoked while the copy holds the name, re-derived when it no longer does. Before, the declaration's grants stood until the next boot's orphaned-grant sweep. Under a wall an organization's copy is its own (the other organizations keep the declared rule); under `single` the one copy answers everywhere. From bd061fac90425aa4e7c488f4e1cf84e1af20bc3a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 01:32:43 +0000 Subject: [PATCH 04/11] chore(changeset): state the ADR-0087 disposition's reason exactly Claude-Session: https://claude.ai/code/session_01MaH9HpfmNnJGzgWQPqNmhP Co-authored-by: Claude --- .changeset/24421-sharing-rule-declared-name-guard.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/24421-sharing-rule-declared-name-guard.md b/.changeset/24421-sharing-rule-declared-name-guard.md index 4e2bf890a21..b0fbbee6c12 100644 --- a/.changeset/24421-sharing-rule-declared-name-guard.md +++ b/.changeset/24421-sharing-rule-declared-name-guard.md @@ -7,7 +7,7 @@ fix(plugin-sharing)!: a new sharing rule, or a rename, cannot take a declared ru Clause-②: no (narrowing) - + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. From 81d447db8c359cfdc81b16c7d200ab2967e66371 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 01:40:00 +0000 Subject: [PATCH 05/11] docs(permissions): name the declared-name guard's system-write skip on the system-context page Claude-Session: https://claude.ai/code/session_01MaH9HpfmNnJGzgWQPqNmhP Co-authored-by: Claude --- content/docs/permissions/system-context.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index d5086daba4a..6ef61bbb458 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -158,7 +158,7 @@ One of the largest consumers of the flag; | 37 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `packages/plugins/plugin-sharing/src/sharing-plugin.ts#buildSharingMiddleware` | | 38 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `packages/plugins/plugin-sharing/src/share-link-service.ts#createLink`, `#revokeLink`, `#listLinks` | | 39 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `packages/plugins/plugin-sharing/src/sharing-rule-provenance.ts#bindRuleProvenanceStamp` | -| 40 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `packages/plugins/plugin-sharing/src/sharing-rule-service.ts#assertCanManageRules`, `#assertCanDeletePlatformGlobalRule` | +| 40 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, the platform-global-rule delete guard, and the declared-name guard on `sys_sharing_rule` writes (a new row or a rename taking a declared rule's name, 409) — `defineRule` makes that check itself before its own system write, and the other system writers are the platform's machinery (the ownership ceremony, migrations) | `packages/plugins/plugin-sharing/src/sharing-rule-service.ts#assertCanManageRules`, `#assertCanDeletePlatformGlobalRule`, `#bindDeclaredRuleNameGuard` | ### 4. Approvals, attachments, comments, knowledge From 38881babbd61222cdd28e22d2b5e617ec2bdf856 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 03:32:37 +0000 Subject: [PATCH 06/11] =?UTF-8?q?wip(plugin-sharing,runtime):=20regime=20C?= =?UTF-8?q?=20=E2=80=94=20every=20same-name=20row=20refused,=20the=20ledge?= =?UTF-8?q?r=20switch=20for=20declared=20sharing=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01MaH9HpfmNnJGzgWQPqNmhP Co-authored-by: Claude --- .../src/declared-sharing-rules.ts | 8 + .../plugin-sharing/src/sharing-plugin.ts | 36 +-- .../src/sharing-rule-service.ts | 225 ++++++++++++------ packages/rest/src/sharing-routes.ts | 6 - .../runtime/src/domains/activation-gate.ts | 13 + .../runtime/src/domains/catalog-activation.ts | 74 +++++- packages/runtime/src/domains/security.ts | 5 +- 7 files changed, 255 insertions(+), 112 deletions(-) diff --git a/packages/plugins/plugin-sharing/src/declared-sharing-rules.ts b/packages/plugins/plugin-sharing/src/declared-sharing-rules.ts index 1d8ae059fa2..c00981ded35 100644 --- a/packages/plugins/plugin-sharing/src/declared-sharing-rules.ts +++ b/packages/plugins/plugin-sharing/src/declared-sharing-rules.ts @@ -151,6 +151,13 @@ export interface DeclaredSharingRule { recipientId: string; accessLevel: ShareAccessLevel; active: boolean; + /** + * The package that ships the declaration (the registry item's `_packageId`), + * when it names one — the provenance an activation-ledger row records + * (`sys_metadata_activation.package_id`). Absent for a declaration no package + * names. + */ + packageId?: string; } /** Why a declaration is not served: the compiler's own refusal, or a shape the runtime has no slot for. */ @@ -217,6 +224,7 @@ export function translateDeclaredSharingRule( recipientId: String(r.sharedWith.value), accessLevel: (r.accessLevel ?? 'read') as ShareAccessLevel, active: r.active !== false, + ...(typeof r._packageId === 'string' && r._packageId ? { packageId: r._packageId } : {}), }, }; } diff --git a/packages/plugins/plugin-sharing/src/sharing-plugin.ts b/packages/plugins/plugin-sharing/src/sharing-plugin.ts index d2b53fdb2c7..80a2d758590 100644 --- a/packages/plugins/plugin-sharing/src/sharing-plugin.ts +++ b/packages/plugins/plugin-sharing/src/sharing-plugin.ts @@ -56,7 +56,7 @@ import { type SharingSecurityProbe, type SharingTenancyProbe, } from './sharing-service.js'; -import { SharingRuleService, bindDeclaredRuleNameGuard } from './sharing-rule-service.js'; +import { SharingRuleService, bindDeclaredRuleNameGuard, bindSharingRuleActivationReconcile } from './sharing-rule-service.js'; import { ShareLinkService } from './share-link-service.js'; import { registerShareLinkRoutes } from './share-link-routes.js'; import { bindRuleHooks, unbindAllRuleHooks, bindRuleCriteriaGuard, RULE_REBIND_TRIGGER_PACKAGE } from './rule-hooks.js'; @@ -467,23 +467,10 @@ export class SharingServicePlugin implements Plugin { * logs and leaves the previous grants alone — it must never fail the * authoring write. */ - const scheduleReconcile = ( - ruleId: string, - deleted: boolean, - names: ReadonlyArray<{ name: string; organizationId: string | null }>, - ): Promise => { + const scheduleReconcile = (ruleId: string, deleted: boolean): Promise => { const run = this.ruleRebindChain.then(async () => { const ruleService = this.ruleService; if (!ruleService) return; - // [ADR-0131 D6 regime O — ruling B′] A write of a declared rule's name - // (the customised copy's creation, its `active` switch, its removal) - // reconciles the declaration's grants in that scope NOW, not at the next - // boot's orphan sweep. FIRST, so a failure here stops before the row's - // own pass below: the declaration's grants and the copy's are never both - // left in force by this write. - for (const { name, organizationId } of names) { - await ruleService.reconcileDeclaredRuleName(name, organizationId); - } if (deleted) { await ruleService.revokeRuleGrants(ruleId); return; @@ -513,16 +500,8 @@ export class SharingServicePlugin implements Plugin { const data = hookCtx?.result ?? hookCtx?.input?.data ?? {}; const ruleId = String(data?.id ?? hookCtx?.input?.id ?? ''); if (!ruleId) return; - // The names this write touched, each in its row's organization: the - // pre-image's (a rename away from it, a delete) and the written row's. - const names = new Map(); - for (const row of [hookCtx?.previous, deleted ? undefined : { ...(hookCtx?.previous ?? {}), ...data }]) { - if (!row || typeof row.name !== 'string' || !row.name) continue; - const organizationId = row.organization_id == null ? null : String(row.organization_id); - names.set(`${row.name}::${organizationId ?? ''}`, { name: row.name, organizationId }); - } try { - await scheduleReconcile(ruleId, deleted, [...names.values()]); + await scheduleReconcile(ruleId, deleted); } catch (err: any) { ctx.logger.warn('SharingServicePlugin: sharing-rule grant reconcile failed — grants left unchanged', { rule: ruleId, @@ -870,10 +849,13 @@ export class SharingServicePlugin implements Plugin { // match-all criteria gate has to sit on the table itself too. bindRuleCriteriaGuard(engine, ctx.logger as any); - // [ADR-0131 D6 regime O, guarded — ruling B′] …and so must the - // refusal of a NEW row, or a rename, that takes a declared rule's - // name: `defineRule` makes it for its own callers. + // [ADR-0131 D6 regime C] …and so must the refusal of a row, new or + // renamed, that takes a declared rule's name: `defineRule` makes it + // for its own callers. A declared rule is customised by a clone + // under another name plus the declared rule switched off in the + // activation ledger, whose writes reconcile its grants at once. bindDeclaredRuleNameGuard(engine, this.ruleService); + bindSharingRuleActivationReconcile(engine, this.ruleService); // [#2909 T1] Stamp `customized` on admin edits of seeded rules so // a customized copy keeps answering for its name — the declared diff --git a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts index 449416bfec6..79e9ac1e612 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts @@ -57,6 +57,12 @@ import type { DeclaredSharingRule, DeclaredSharingRuleSource } from './declared- */ const SYSTEM_CTX: ExecutionContext = { isSystem: true, positions: [], permissions: [] }; +/** + * [ADR-0131 D6 regime C] The `sys_metadata_activation.metadata_type` a declared + * sharing rule's switch is recorded under — its registry type. + */ +export const SHARING_RULE_ACTIVATION_TYPE = 'sharing_rule'; + /** * [#15072] The `FieldSchema.name` machine-name grammar, as `packages/spec` * spells it inline (`data/field.zod.ts`; `shared/identifiers.zod.ts` records @@ -179,32 +185,16 @@ export function isDeclaredSharingRule(rule: Pick any, options?: Record): void; + unregisterHooksByPackage?(packageId: string): number; + }, + rules: Pick, +): void { + engine.unregisterHooksByPackage?.(SHARING_RULE_ACTIVATION_RECONCILE_PACKAGE); + const handler = async (ctx: any) => { + // The update patch carries `{ id, active, package_id }`; type and name come + // from the pre-image (`ctx.previous`), an insert's from the row itself. + const row = { ...(ctx?.previous ?? {}), ...(ctx?.result ?? ctx?.input?.data ?? {}) }; + if (row.metadata_type !== SHARING_RULE_ACTIVATION_TYPE || typeof row.name !== 'string' || !row.name) return; + await rules.reconcileDeclaredRuleActivation(row.name); + }; + for (const event of ['afterInsert', 'afterUpdate', 'afterDelete']) { + engine.registerHook(event, handler, { + object: METADATA_ACTIVATION_TABLE, + packageId: SHARING_RULE_ACTIVATION_RECONCILE_PACKAGE, + priority: 200, + }); + } +} + /** * The `sys_record_share` rows a rule's grants are: `source: 'rule'` plus the * rule's id. A declared rule served under a wall is one projection per @@ -440,6 +467,8 @@ export class SharingRuleService implements ISharingRuleService { private declaredScopes: ReadonlyArray | null = [null]; /** [ADR-0131 D2] The held-back sweep under unknown scopes says so once per process. */ private unknownScopesSweepWarned = false; + /** [ADR-0131 D6 regime C] The declaration-over-row conflicts already reported, `name::scope`. */ + private readonly declarationOverRowReported = new Set(); constructor(opts: SharingRuleServiceOptions) { this.engine = opts.engine; @@ -746,7 +775,7 @@ export class SharingRuleService implements ISharingRuleService { ); } - async defineRule(input: DefineSharingRuleWrite, context: ExecutionContext): Promise { + async defineRule(input: DefineSharingRuleInput, context: ExecutionContext): Promise { this.assertCanManageRules(context); if (!input.name) throw new Error('VALIDATION_FAILED: name is required'); if (!input.label) throw new Error('VALIDATION_FAILED: label is required'); @@ -847,17 +876,10 @@ export class SharingRuleService implements ISharingRuleService { return rowFromRule({ ...row, ...patch }); } - // [ADR-0131 D6 regime O, guarded — ruling B′] No row holds this name in the - // caller's scope, so the write creates one. Under a declared rule's name that - // row replaces the declaration in the scope: allowed only as the EDIT of the - // projection the caller reads (it sends the projection's id, the rule's - // name), refused as a new rule. + // [ADR-0131 D6 regime C] No row holds this name in the caller's scope, so + // the write creates one — and no row may take a declared rule's name. const declared = await this.declaredRuleNamed(input.name); - if (declared) { - const editsProjection = - input.id === declared.name && (await this.declaredRules(context, { name: declared.name })).length > 0; - if (!editsProjection) throw new DeclaredSharingRuleNameConflictError(declared, 'create'); - } + if (declared) throw new DeclaredSharingRuleNameConflictError(declared, 'create'); const newRow: any = { id: uid('srule'), @@ -980,6 +1002,11 @@ export class SharingRuleService implements ISharingRuleService { if (filter.name !== undefined) rules = rules.filter((r) => r.name === filter.name); const scopes = this.declaredScopesFor(context); if (rules.length === 0 || scopes.length === 0) return []; + // [ADR-0131 D6 regime C] A declared rule the activation ledger switches off + // is served nowhere: the row is deployment-level, so it covers every scope. + const off = await this.readSwitchedOffDeclaredRules(); + if (off.size > 0) rules = rules.filter((r) => !off.has(r.name)); + if (rules.length === 0) return []; const single = this.declaredScopes?.includes(null) === true; const holders = await this.engine.find('sys_sharing_rule', { @@ -995,13 +1022,70 @@ export class SharingRuleService implements ISharingRuleService { const out: SharingRuleRow[] = []; for (const scope of scopes) { for (const rule of rules) { - if (held.has(single ? rule.name : `${rule.name}::${scope}`)) continue; + if (held.has(single ? rule.name : `${rule.name}::${scope}`)) { + this.reportDeclarationOverAuthoredRow(rule, scope); + continue; + } out.push(projectDeclaredRule(rule, scope)); } } return out; } + /** + * [ADR-0131 D6 regime C] A declaration whose name a `sys_sharing_rule` row + * already holds in a scope — a package upgrade that ships a rule an + * organization authored under the same name, or a copy customised before + * same-name shadowing was retired. The row is never made an overlay of the + * declaration: it is that organization's own rule and answers as itself. The + * declaration is NOT served in that scope beside it — serving both would + * re-grant what a customised copy switched off — and that is said loudly, + * once per name and scope per process, with the remedy. + */ + private reportDeclarationOverAuthoredRow(rule: DeclaredSharingRule, scope: string | null): void { + const key = `${rule.name}::${scope ?? ''}`; + if (this.declarationOverRowReported.has(key)) return; + this.declarationOverRowReported.add(key); + const message = + `[sharing-rule] declared sharing rule '${rule.name}' arrives over a stored sys_sharing_rule row of the same ` + + `name${scope ? ` in organization '${scope}'` : ''}, so the declared rule is NOT in force there and grants nothing ` + + 'while the row keeps answering as its own rule. Remedy: rename the stored rule, which makes it a clone of its ' + + `own, and switch the declared rule off (POST /api/v1/security/_activation/sharing_rule/${rule.name} with ` + + '{ "enabled": false }) if it must not grant.'; + const log = this.logger?.error ?? this.logger?.warn; + log?.call(this.logger, message, { rule: rule.name, organization: scope }); + } + + /** + * [ADR-0131 D6 regime C] The declared rules the activation ledger + * (`sys_metadata_activation`, type `sharing_rule`) switches off. No row means + * the declared default — on. Read as {@link readPositionActive} reads the same + * ledger: a composition with no ledger object issues no read, and a read that + * fails switches nothing off for this pass, never a mass revocation. + */ + private async readSwitchedOffDeclaredRules(): Promise> { + const off = new Set(); + const registry = (this.engine as { registry?: { getObject?: (name: string) => unknown } }).registry; + if (typeof registry?.getObject === 'function' && !registry.getObject(METADATA_ACTIVATION_TABLE)) return off; + try { + const rows = await this.engine.find(METADATA_ACTIVATION_TABLE, { + filter: { metadata_type: SHARING_RULE_ACTIVATION_TYPE }, + fields: ['metadata_type', 'name', 'active'], + limit: 100000, + context: SYSTEM_CTX, + }); + for (const row of (Array.isArray(rows) ? rows : []) as Array<{ name?: unknown; active?: unknown }>) { + if (row.active === false || row.active === 0) off.add(String(row.name)); + } + } catch (err: any) { + this.logger?.warn?.( + `[sharing-rule] ${METADATA_ACTIVATION_TABLE} read failed — declared sharing rules treated as ACTIVE for this pass`, + { error: err?.message }, + ); + } + return off; + } + /** * [ADR-0131 D2] Which scopes a caller reads declared rules in: the one * organization-less scope under `single` (platform-global, every @@ -1019,8 +1103,9 @@ export class SharingRuleService implements ISharingRuleService { } /** - * [ruling B′] The declared rule named `name`, or `null` — whatever scope it - * is served in: a declaration is the application's in every organization, so + * [ADR-0131 D6 regime C] The declared rule named `name`, or `null` — whatever + * scope it is served in, and whether or not the activation ledger has it + * switched off: a declaration is the application's in every organization, so * its name is taken in every one ({@link DeclaredSharingRuleNameConflictError}). */ async declaredRuleNamed(name: string): Promise { @@ -1029,35 +1114,31 @@ export class SharingRuleService implements ISharingRuleService { } /** - * [ADR-0131 D6 regime O — ruling B′] Reconcile a declared rule's name-keyed - * grants in the scope a `sys_sharing_rule` write of that name touched, in the - * write's own request — not at the next boot's {@link sweepOrphanedRuleGrants}. - * - * The scope is the `held` key's ({@link declaredRules}): under `single` the - * one organization-less projection, so a row of the name in any organization - * answers everywhere; under a wall `organizationId`'s projection alone, so an - * organization's customised copy is its own. A row created under the name (the - * customised copy) or switched on or off leaves the declaration shadowed there, - * and its grants are revoked; a write that leaves no row holding the name - * serves the declaration again, and its grants are re-derived. Either way it is - * the one-rule pass every reconcile runs ({@link evaluateResolvedRule} over the - * {@link ruleGrantScope} of the projection). - * - * Scopes not known (a failed organization enumeration) reconcile nothing: the - * boot sweep's own rule for them — hold every declared grant — applies. + * [ADR-0131 D6 regime C] Reconcile a declared rule's name-keyed grants with + * its activation-ledger row, in the request that wrote the row + * ({@link bindSharingRuleActivationReconcile}) — not at the next boot's + * {@link sweepOrphanedRuleGrants}. + * + * The ledger row is deployment-level (it carries no organization), so the + * switch covers every scope the declaration is served in. Switched off, it is + * served nowhere ({@link declaredRules}) and every grant carrying its name is + * revoked; switched on, each projection's one-rule pass re-derives its grants. + * An organization's own rules keep their row ids, so no grant of theirs is + * touched. */ - async reconcileDeclaredRuleName(name: string, organizationId: string | null): Promise { - const declared = await this.declaredRuleNamed(name); - const scopes = this.declaredScopes; - if (!declared || scopes === null) return; - const single = scopes.includes(null); - const scope = single ? null : organizationId; - if (!single && (scope == null || !scopes.includes(scope))) return; - const served = (await this.declaredRules(SYSTEM_CTX, { name })).find((r) => r.organization_id === scope); - if (served) await this.evaluateResolvedRule(served); - else await this.purgeGrants(ruleGrantScope(projectDeclaredRule(declared, scope))); + async reconcileDeclaredRuleActivation(name: string): Promise { + // Unknown scopes serve nothing and judge nothing — the boot sweep's rule for + // them (hold every declared grant) applies here too. + if (this.declaredScopes === null || !(await this.declaredRuleNamed(name))) return; + const served = await this.declaredRules(SYSTEM_CTX, { name }); + if (served.length === 0) { + await this.purgeGrants({ source: 'rule', source_id: name }); + return; + } + for (const projection of served) await this.evaluateResolvedRule(projection); } + async getRule(idOrName: string, context: ExecutionContext): Promise { this.assertCanManageRules(context); // [ADR-0111 D6] if (!idOrName) return null; @@ -1144,10 +1225,12 @@ export class SharingRuleService implements ISharingRuleService { // [ADR-0131 D2] A declared rule is code: there is no row to delete, and // its grants follow the declaration. 403, never a silent no-op that // reads as success while the rule keeps granting. - if ((await this.declaredRules(context, { name: idOrName })).length > 0) { + // [ADR-0131 D6 regime C] Switched off or not: its name is the declaration's. + if (await this.declaredRuleNamed(idOrName)) { throw new Error( `PERMISSION_DENIED: sharing rule '${idOrName}' is declared in the application's code, not stored — ` + - 'it is changed or removed in that declaration, and its grants follow it.', + 'it is changed or removed in that declaration, and its grants follow it. To stop it granting here, ' + + 'switch it off through the activation ledger.', ); } return; diff --git a/packages/rest/src/sharing-routes.ts b/packages/rest/src/sharing-routes.ts index f474b4ad087..dfa1abd3df5 100644 --- a/packages/rest/src/sharing-routes.ts +++ b/packages/rest/src/sharing-routes.ts @@ -692,12 +692,6 @@ export function registerSharingRuleEndpoints(this: SharingRuleRouteHost, basePat // refuses a match-all criteria, so the typo surfaces as a // 400 naming the field instead of a silent 201. const input = { - // [ADR-0131 D6 regime O, guarded] The id of the rule this - // write EDITS: a declared rule's projection is listed (and - // saved back) under its name, which is what lets the - // service tell the edit that stores its customised copy - // from a new rule taking its name — refused, 409. - id: body.id, name: body.name, label: body.label, description: body.description, diff --git a/packages/runtime/src/domains/activation-gate.ts b/packages/runtime/src/domains/activation-gate.ts index 362734cb149..1c1caf71740 100644 --- a/packages/runtime/src/domains/activation-gate.ts +++ b/packages/runtime/src/domains/activation-gate.ts @@ -179,6 +179,19 @@ export const PERMISSION_SET_ACTIVATION_SUBJECT: ActivationSubject = { 'that needs a catalog of its own is given an environment of its own.', }; +/** + * [ADR-0131 D6 regime C] The declared-sharing-rule door's wording. A declared + * sharing rule is customised by a clone under another name plus the declared + * rule switched off; an organization still authors its own rules under a wall + * (ADR-0131 D3), so the remedy names that half and the operator for the switch. + */ +export const SHARING_RULE_ACTIVATION_SUBJECT: ActivationSubject = { + subject: 'a declared sharing rule', + remedy: + 'To switch a declared sharing rule off for this installation, ask your platform operator; sharing rules your ' + + 'organization authors under names of its own stay yours to create and switch.', +}; + /** * The activation body every `_activation` door reads: `{ enabled?: boolean }`, * unknown keys refused, `enabled` defaulting to `true`. diff --git a/packages/runtime/src/domains/catalog-activation.ts b/packages/runtime/src/domains/catalog-activation.ts index a1a26242877..ffa07d29c56 100644 --- a/packages/runtime/src/domains/catalog-activation.ts +++ b/packages/runtime/src/domains/catalog-activation.ts @@ -3,7 +3,10 @@ /** * [ADR-0126 §3 regime C, as ADR-0131 D6 amends it] THE ACTIVATION DOOR for the * security catalog: `POST /security/_activation/:type/:name`, body - * `{ enabled?: boolean }`, `:type` one of `position` / `permission`. + * `{ enabled?: boolean }`, `:type` one of `position` / `permission`, and — since + * ADR-0131 D6 charts declared sharing rules under regime C — `sharing_rule`, a + * rule a package declares, read from the sharing-rule service (`sharingRules`) + * rather than the security catalog. * * ## Why it exists * @@ -59,6 +62,7 @@ import { readActivationBody, POSITION_ACTIVATION_SUBJECT, PERMISSION_SET_ACTIVATION_SUBJECT, + SHARING_RULE_ACTIVATION_SUBJECT, type ActivationSubject, } from './activation-gate.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; @@ -72,9 +76,12 @@ export const CATALOG_ACTIVATION_SEGMENT = '_activation'; * types the resolver reads from the ledger; `capability` is code-only on the * regime and has no switch. */ -const CATALOG_ACTIVATION_SUBJECTS: Readonly> = { +const CATALOG_ACTIVATION_SUBJECTS: Readonly> = { position: POSITION_ACTIVATION_SUBJECT, permission: PERMISSION_SET_ACTIVATION_SUBJECT, + // [ADR-0131 D6 regime C] A declared sharing rule — read from the sharing-rule + // service, which serves no declaration this ledger switches off. + sharing_rule: SHARING_RULE_ACTIVATION_SUBJECT, }; type CatalogActivationType = keyof typeof CATALOG_ACTIVATION_SUBJECTS; @@ -87,8 +94,25 @@ function isCatalogActivationType(type: string): type is CatalogActivationType { const NOUN: Readonly> = { position: 'Position', permission: 'Permission set', + sharing_rule: 'Declared sharing rule', }; +/** + * [ADR-0131 D6 regime C] The declaration a `sharing_rule` row addresses, as the + * sharing-rule service (`sharingRules`, plugin-sharing) reads it: declared in + * the code, whether or not the ledger has it switched off. A rule an + * organization authored is a `sys_sharing_rule` row with a switch of its own + * (`active`) and is never addressed here. + */ +interface DeclaredSharingRuleReader { + declaredRuleNamed(name: string): Promise<{ name: string; packageId?: string } | null>; +} + +function declaredSharingRuleReaderOf(service: unknown): DeclaredSharingRuleReader | undefined { + const candidate = service as Partial | null | undefined; + return typeof candidate?.declaredRuleNamed === 'function' ? (candidate as DeclaredSharingRuleReader) : undefined; +} + /** * Does this request belong to the door? Every `POST` under `_activation`, with * no upper bound on depth, so the gate is exactly as wide as the arm: a @@ -198,8 +222,47 @@ export async function handleCatalogActivationWrite( if (!ql) { return { handled: true, response: deps.error('Data engine not available', 503) }; } - const reader = securityCatalogReaderOf(ql); - if (!reader) { + let entry: { name: string; packageId?: string } | undefined; + if (type === 'sharing_rule') { + const rules = declaredSharingRuleReaderOf(await deps.resolveService(context, 'sharingRules')); + if (!rules) { + return { + handled: true, + response: deps.error( + `Cannot verify the declaration of sharing rule '${name}' — no sharing-rule service is bound to this ` + + `deployment. Refusing rather than writing an activation row for a name nobody can confirm exists.`, + 503, + { code: 'SERVICE_UNAVAILABLE' }, + ), + }; + } + try { + entry = (await rules.declaredRuleNamed(name)) ?? undefined; + } catch (err: any) { + return { + handled: true, + response: deps.error( + `Cannot verify the declaration of sharing rule '${name}' — the declared rules could not be read ` + + `(${err?.message ?? String(err)}). Refusing rather than writing an activation row for a name nobody ` + + `can confirm exists.`, + 503, + { code: 'SERVICE_UNAVAILABLE' }, + ), + }; + } + if (!entry) { + return { + handled: true, + response: deps.error( + `Sharing rule '${name}' is not declared by the application — there is nothing to switch ` + + `${enabled ? 'on' : 'off'}. A rule your organization authored is switched by its own \`active\` field.`, + 404, + ), + }; + } + } + const reader = type === 'sharing_rule' ? undefined : securityCatalogReaderOf(ql); + if (type !== 'sharing_rule' && !reader) { return { handled: true, response: deps.error( @@ -211,9 +274,8 @@ export async function handleCatalogActivationWrite( ), }; } - let entry: SecurityCatalogEntry | undefined; try { - entry = await reader.resolve(type, name); + if (reader) entry = (await reader.resolve(type as 'position' | 'permission', name)) as SecurityCatalogEntry | undefined; } catch (err: any) { return { handled: true, diff --git a/packages/runtime/src/domains/security.ts b/packages/runtime/src/domains/security.ts index 45a96a28756..0a2661b6fbc 100644 --- a/packages/runtime/src/domains/security.ts +++ b/packages/runtime/src/domains/security.ts @@ -18,8 +18,9 @@ * GET /security/suggested-bindings?status=&packageId= → list (reconciles first) * POST /security/suggested-bindings/:id/confirm → accept it for the deployment * POST /security/suggested-bindings/:id/dismiss → decline it, or revoke an acceptance - * POST /security/_activation/:type/:name → switch a position (`position`) or a - * permission set (`permission`) on/off + * POST /security/_activation/:type/:name → switch a position (`position`), a + * permission set (`permission`) or a declared + * sharing rule (`sharing_rule`) on/off * for this deployment (`./catalog-activation.ts`) * POST /security/permission-sets/:name/clone → copy a permission set's whole definition * under a new name (`./permission-set-clone.ts`) From 72f7efa9a352f8c86132805ea40d5608eacb4c28 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 03:40:43 +0000 Subject: [PATCH 07/11] =?UTF-8?q?test(plugin-sharing,runtime,rest):=20pin?= =?UTF-8?q?=20regime=20C=20=E2=80=94=20the=20name=20guard,=20the=20ledger?= =?UTF-8?q?=20switch=20and=20its=20reconcile,=20the=20declaration-over-row?= =?UTF-8?q?=20report?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01MaH9HpfmNnJGzgWQPqNmhP Co-authored-by: Claude --- .../plugin-sharing/src/sharing-rule.test.ts | 251 ++++++++++-------- ...haring-rule-declared-name-conflict.test.ts | 31 +-- .../domains/catalog-activation-door.test.ts | 78 +++++- 3 files changed, 230 insertions(+), 130 deletions(-) diff --git a/packages/plugins/plugin-sharing/src/sharing-rule.test.ts b/packages/plugins/plugin-sharing/src/sharing-rule.test.ts index ea82e797a14..0fa446e2d96 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule.test.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule.test.ts @@ -6,7 +6,7 @@ import { assertEngineDeleteDispatch } from '@objectstack/objectql'; // assert an HTTP status they SOURCE rather than one they restate. import { HttpStatusErrorCodeMap } from '@objectstack/spec/api'; import { SharingService } from './sharing-service.js'; -import { SharingRuleService, isDeclaredSharingRule, bindDeclaredRuleNameGuard } from './sharing-rule-service.js'; +import { SharingRuleService, isDeclaredSharingRule, bindDeclaredRuleNameGuard, bindSharingRuleActivationReconcile } from './sharing-rule-service.js'; import { TeamGraphService, expandPrincipal } from './team-graph.js'; import { BusinessUnitGraphService } from './business-unit-graph.js'; // [commit 04d03c3a0] The ADDRESSING primitive, imported so the negative half of the @@ -16,7 +16,7 @@ import { PositionGraphService } from './position-graph.js'; import { celToFilter, celToFilterOutcome, createDeclaredSharingRuleSource } from './declared-sharing-rules.js'; import { isMatchAllCriteria } from './rule-criteria.js'; import { bindRuleCriteriaGuard } from './rule-hooks.js'; -import { resolveDeclaredRuleScopes, SharingServicePlugin } from './sharing-plugin.js'; +import { resolveDeclaredRuleScopes } from './sharing-plugin.js'; interface Row { [k: string]: any } @@ -2343,20 +2343,22 @@ describe('[ADR-0131 D2] declared sharing rules are read from the registry, never }); // ─────────────────────────────────────────────────────────────────── - // [ADR-0131 D6 regime O, guarded — ruling B′] A row of a declared rule's - // name replaces the declaration in its scope. Only the EDIT of the - // projection (saved back under its id, the rule's name) may create that - // row; a new rule, or a rename, taking the name is refused 409. And the - // write that creates, switches or removes the row reconciles the - // declaration's grants in that scope at once — not at the next boot's sweep. + // [ADR-0131 D6 regime C] A declared sharing rule is read-only and + // clonable. No `sys_sharing_rule` row may take its name — new or renamed, + // through any door — and it is switched off through the deployment-level + // activation ledger, whose writes reconcile its grants in the same request. + // A declaration arriving over a stored row of its name is never made an + // overlay: the row answers as itself, the declaration is withheld there, and + // that is reported loudly. // ─────────────────────────────────────────────────────────────────── - describe('regime O, guarded: the declared name is taken; the edit is the overlay', () => { + describe('regime C: read-only, clonable, switched through the activation ledger', () => { const adminOf = (org: string) => ({ userId: 'admin', tenantId: org, organizationId: org, systemPermissions: ['manage_sharing'] }) as any; const RULE_INPUT = { name: DECLARED.name, label: 'Red projects → exec (ours)', object: 'project', criteria: { status: 'red' }, recipientType: 'user', recipientId: 'exec', accessLevel: 'read', } as const; + const LEDGER = 'sys_metadata_activation'; /** The refusal's declared envelope — `code` + `status`, never the sentence. */ const refusalOf = async (call: Promise): Promise<{ code: unknown; status: unknown; rule: unknown }> => { @@ -2369,32 +2371,37 @@ describe('[ADR-0131 D2] declared sharing rules are read from the registry, never }; /** - * The plugin's `sys_sharing_rule` write trigger, bound over this harness's - * engine with the real rule service — the seam every door's write reaches. + * The plugin's two regime-C hook sets, bound over this harness's engine with + * the real rule service: the `sys_sharing_rule` name guard and the ledger's + * reconcile — the seams every door's write reaches. */ - function wireTrigger(h: ReturnType) { + function wire(h: ReturnType) { const hooks: Array<{ event: string; handler: (ctx: any) => any; options: Record }> = []; h.engine.registerHook = (event: string, handler: (ctx: any) => any, options: Record = {}) => { hooks.push({ event, handler, options }); }; - h.engine.unregisterHooksByPackage = (pkg: string) => { - let removed = 0; - for (let i = hooks.length - 1; i >= 0; i--) if (hooks[i].options.packageId === pkg) { hooks.splice(i, 1); removed++; } - return removed; - }; - const plugin = new SharingServicePlugin(); - (plugin as any).ruleService = h.rules; - (plugin as any).ruleGrantsBootReconciled = true; - const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; - (plugin as any).bindRuleRebindTriggers(h.engine, { logger }); bindDeclaredRuleNameGuard(h.engine, h.rules); - const fire = async (event: string, ctx: Record) => { - for (const x of hooks.filter((y) => y.event === event && y.options.object === 'sys_sharing_rule')) await x.handler(ctx); + bindSharingRuleActivationReconcile(h.engine, h.rules); + const fire = async (event: string, object: string, ctx: Record) => { + for (const x of hooks.filter((y) => y.event === event && y.options.object === object)) await x.handler(ctx); + }; + /** The activation door's write, as `ObjectStoreMetadataActivationStore.setActive` makes it. */ + const setActive = async (name: string, active: boolean, type = 'sharing_rule') => { + const ledger: Row[] = (h.engine._tables[LEDGER] ??= []); + const current = ledger.find((r) => r.metadata_type === type && r.name === name); + if (current) { + const previous = { ...current }; + current.active = active; + await fire('afterUpdate', LEDGER, { previous, input: { id: current.id, data: { id: current.id, active } }, result: { ...current } }); + } else { + const row = { id: `act_${name}`, metadata_type: type, name, active }; + ledger.push(row); + await fire('afterInsert', LEDGER, { input: { data: row }, result: { ...row } }); + } }; - return { fire, logger }; + return { fire, setActive }; } - const storedRow = (h: ReturnType, id: string): Row => - (h.engine._tables.sys_sharing_rule ?? []).find((r: Row) => r.id === id); + const declaredGrants = (h: ReturnType) => h.grants().filter((g) => g.source_id === DECLARED.name); describe('`single`', () => { let h: ReturnType; @@ -2409,7 +2416,7 @@ describe('[ADR-0131 D2] declared sharing rules are read from the registry, never it.each([ ['an org-scoped sharing admin', adminOf('org_default')], ['a system context', BOOT], - ])('%s: a NEW rule taking the declared name is refused 409 RESOURCE_CONFLICT, naming the vendor rule, and nothing lands', async (_who, ctx) => { + ])('%s: a rule taking the declared name is refused 409 RESOURCE_CONFLICT, naming the vendor rule, and nothing lands', async (_who, ctx) => { const before = h.grants().map((g) => g.id).sort(); const refusal = await refusalOf(h.rules.defineRule({ ...RULE_INPUT } as any, ctx)); expect(refusal).toEqual({ code: 'RESOURCE_CONFLICT', status: 409, rule: DECLARED.name }); @@ -2420,105 +2427,118 @@ describe('[ADR-0131 D2] declared sharing rules are read from the registry, never expect((await h.rules.listRules({}, BOOT)).map((r) => r.id)).toEqual([DECLARED.name]); }); - it('an id that is not the projection’s is a new rule too — refused', async () => { - const refusal = await refusalOf(h.rules.defineRule({ ...RULE_INPUT, id: 'srule_elsewhere' } as any, adminOf('org_default'))); + it('the projection’s id buys nothing: there is no customised-copy door', async () => { + const refusal = await refusalOf(h.rules.defineRule({ ...RULE_INPUT, id: DECLARED.name } as any, adminOf('org_default'))); expect(refusal.code).toBe('RESOURCE_CONFLICT'); expect(h.engine._tables.sys_sharing_rule ?? []).toEqual([]); }); - it('CONTROL: a name no declaration holds is created as before, with no id', async () => { - const row = await h.rules.defineRule({ ...RULE_INPUT, name: 'red_to_exec_too' } as any, adminOf('org_default')); - expect(row.id).toMatch(/^srule_/); + it('a declared rule switched OFF still holds its name', async () => { + const { setActive } = wire(h); + await setActive(DECLARED.name, false); + expect((await refusalOf(h.rules.defineRule({ ...RULE_INPUT } as any, adminOf('org_default')))).code).toBe('RESOURCE_CONFLICT'); }); - it('the EDIT of the projection (saved back under its id) stores the customised copy, which answers for the name', async () => { - const copy = await h.rules.defineRule({ ...RULE_INPUT, id: DECLARED.name, active: false } as any, adminOf('org_default')); - expect(copy).toMatchObject({ name: DECLARED.name, organization_id: 'org_default', active: false }); - expect(copy.id).not.toBe(DECLARED.name); - expect((await h.rules.listRules({}, BOOT)).map((r) => r.id)).toEqual([copy.id]); - // The copy is a stored row now: saving it again is the upsert, no id needed. - const again = await h.rules.defineRule({ ...RULE_INPUT, active: true } as any, adminOf('org_default')); - expect(again.id).toBe(copy.id); + it('CONTROL: a name no declaration holds is created as before', async () => { + const row = await h.rules.defineRule({ ...RULE_INPUT, name: 'red_to_exec_ours' } as any, adminOf('org_default')); + expect(row.id).toMatch(/^srule_/); }); - it('the copy’s creation revokes the declaration’s grants EVERYWHERE in the same write — before any boot sweep', async () => { - const { fire } = wireTrigger(h); - expect(h.grants().filter((g) => g.source_id === DECLARED.name)).toHaveLength(2); - const copy = await h.rules.defineRule({ ...RULE_INPUT, id: DECLARED.name, active: false } as any, adminOf('org_default')); - await fire('afterInsert', { result: storedRow(h, copy.id) }); - // Under `single` the one overlay answers everywhere: both organizations' grants go. - expect(h.grants()).toEqual([]); - // Nothing was left for the sweep. + it('customising = a clone under a new name plus the base switched off: the switch revokes the base’s grants everywhere, in the same write', async () => { + const { setActive } = wire(h); + const clone = await h.rules.defineRule({ ...RULE_INPUT, name: 'red_to_exec_ours', recipientId: 'alice' } as any, adminOf('org_default')); + await h.rules.evaluateRule(clone.id, BOOT); + expect(declaredGrants(h)).toHaveLength(2); + + await setActive(DECLARED.name, false); + // Deployment-level: both organizations' grants of the base go; the clone's stay. + expect(declaredGrants(h)).toEqual([]); + expect(h.grants().map((g) => g.source_id)).toEqual([clone.id]); + expect((await h.rules.listRules({}, BOOT)).map((r) => r.id)).toEqual([clone.id]); + // Nothing was left for the boot sweep. expect(await h.rules.sweepOrphanedRuleGrants()).toBe(0); }); - it('an `active` change on the copy reconciles the declaration’s grants again — a stale one goes', async () => { - const { fire } = wireTrigger(h); - const copy = await h.rules.defineRule({ ...RULE_INPUT, id: DECLARED.name } as any, adminOf('org_default')); - await fire('afterInsert', { result: storedRow(h, copy.id) }); - // The copy grants under its own id (an organization's row sweeps its own - // organization's records); the declaration grants nothing. - expect(h.grants().map((g) => [g.record_id, g.source_id])).toEqual([['p_red', copy.id]]); - // A declaration grant left standing (a write that failed half-way, say). - h.engine._tables.sys_record_share.push({ - id: 'share_stale', organization_id: 'org_default', object_name: 'project', record_id: 'p_red', - recipient_type: 'user', recipient_id: 'exec', access_level: 'read', source: 'rule', source_id: DECLARED.name, - }); - // The admin switches the copy off through the `/data` door. - const previous = { ...storedRow(h, copy.id) }; - Object.assign(storedRow(h, copy.id), { active: false }); - await fire('afterUpdate', { previous, input: { id: copy.id, data: { active: false } }, result: storedRow(h, copy.id) }); - expect(h.grants()).toEqual([]); + it('switching it back ON re-derives its grants in the same write', async () => { + const { setActive } = wire(h); + await setActive(DECLARED.name, false); + expect(declaredGrants(h)).toEqual([]); + await setActive(DECLARED.name, true); + expect(declaredGrants(h).map((g) => g.record_id).sort()).toEqual(['p_red', 'p_red_2']); }); - it('a reconcile failure stops before the copy’s own pass: the two are never both in force', async () => { - const { fire, logger } = wireTrigger(h); - const copy = await h.rules.defineRule({ ...RULE_INPUT, id: DECLARED.name } as any, adminOf('org_default')); - const evaluate = vi.spyOn(h.rules, 'evaluateRule'); - vi.spyOn(h.rules, 'reconcileDeclaredRuleName').mockRejectedValueOnce(new Error('driver down')); - await fire('afterInsert', { result: storedRow(h, copy.id) }); - expect(evaluate).not.toHaveBeenCalled(); - expect(h.grants().every((g) => g.source_id === DECLARED.name)).toBe(true); - expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('grant reconcile failed'), expect.anything()); + it('another type’s ledger row of the same name switches nothing here', async () => { + const { setActive } = wire(h); + await setActive(DECLARED.name, false, 'position'); + expect(declaredGrants(h)).toHaveLength(2); + expect((await h.rules.listRules({}, BOOT)).map((r) => r.id)).toEqual([DECLARED.name]); }); - it('removing the copy serves the declaration again, its grants re-derived in the same write', async () => { - const { fire } = wireTrigger(h); - const copy = await h.rules.defineRule({ ...RULE_INPUT, id: DECLARED.name, active: false } as any, adminOf('org_default')); - await fire('afterInsert', { result: storedRow(h, copy.id) }); - expect(h.grants()).toEqual([]); - const previous = { ...storedRow(h, copy.id) }; - h.engine._tables.sys_sharing_rule = []; - await fire('afterDelete', { previous, input: { id: copy.id } }); - expect(h.grants().map((g) => [g.record_id, g.source_id]).sort()).toEqual([ - ['p_red', DECLARED.name], ['p_red_2', DECLARED.name], - ]); + it('a ledger read that fails switches nothing off — never a mass revocation', async () => { + const find = h.engine.find; + h.engine.find = async (o: string, opts?: any) => { + if (o === LEDGER) throw new Error('ledger down'); + return find(o, opts); + }; + expect((await h.rules.listRules({}, BOOT)).map((r) => r.id)).toEqual([DECLARED.name]); + expect(await h.rules.sweepOrphanedRuleGrants()).toBe(0); + expect(declaredGrants(h)).toHaveLength(2); + }); + + it('a switched-off declared rule still cannot be deleted — 403, it is code', async () => { + const { setActive } = wire(h); + await setActive(DECLARED.name, false); + try { + await h.rules.deleteRule(DECLARED.name, adminOf('org_default')); + throw new Error('expected a refusal'); + } catch (err: any) { + expect(String(err?.message).split(':')[0]).toBe('PERMISSION_DENIED'); + } + }); + + it('a declaration over a stored row of its name is not made an overlay: withheld there, reported loudly once', async () => { + const errors: Array<[string, unknown]> = []; + (h.rules as any).logger = { warn: vi.fn(), error: (m: string, meta?: unknown) => errors.push([m, meta]) }; + h.engine._tables.sys_sharing_rule = [{ + id: 'srule_authored', organization_id: 'org_default', name: DECLARED.name, label: 'ours', + object_name: 'project', criteria_json: JSON.stringify({ status: 'red' }), recipient_type: 'user', + recipient_id: 'exec', access_level: 'read', active: false, managed_by: 'admin', customized: false, + }]; + expect((await h.rules.listRules({}, BOOT)).map((r) => r.id)).toEqual(['srule_authored']); + await h.rules.listRules({}, BOOT); + expect(errors).toHaveLength(1); + expect(errors[0][1]).toEqual({ rule: DECLARED.name, organization: null }); + // The declaration grants nothing beside it: the boot sweep revokes its grants. + expect(await h.rules.sweepOrphanedRuleGrants()).toBe(2); + // The stored row stays editable as itself (the upsert of a row that holds the name already). + const again = await h.rules.defineRule({ ...RULE_INPUT, active: true } as any, adminOf('org_default')); + expect(again.id).toBe('srule_authored'); }); describe('the `/data` door (Setup’s New and edit form)', () => { it('an INSERT of the declared name is refused 409 before it lands; a system write passes', async () => { - const { fire } = wireTrigger(h); + const { fire } = wire(h); const session = { userId: 'admin', isSystem: false }; - const refusal = await refusalOf(fire('beforeInsert', { session, input: { data: { name: DECLARED.name } } })); + const refusal = await refusalOf(fire('beforeInsert', 'sys_sharing_rule', { session, input: { data: { name: DECLARED.name } } })); expect(refusal).toEqual({ code: 'RESOURCE_CONFLICT', status: 409, rule: DECLARED.name }); - await expect(fire('beforeInsert', { session: { isSystem: true }, input: { data: { name: DECLARED.name } } })).resolves.toBeUndefined(); - await expect(fire('beforeInsert', { session, input: { data: { name: 'another_rule' } } })).resolves.toBeUndefined(); + await expect(fire('beforeInsert', 'sys_sharing_rule', { session: { isSystem: true }, input: { data: { name: DECLARED.name } } })).resolves.toBeUndefined(); + await expect(fire('beforeInsert', 'sys_sharing_rule', { session, input: { data: { name: 'another_rule' } } })).resolves.toBeUndefined(); }); - it('a RENAME onto the declared name is refused; a save that keeps the copy’s name, or renames away, passes', async () => { - const { fire } = wireTrigger(h); + it('a RENAME onto the declared name is refused; a save that keeps a row’s name, or renames away, passes', async () => { + const { fire } = wire(h); const session = { userId: 'admin', isSystem: false }; const other = { id: 'srule_other', name: 'another_rule' }; - const refusal = await refusalOf(fire('beforeUpdate', { session, previous: other, input: { id: other.id, data: { name: DECLARED.name } } })); + const refusal = await refusalOf(fire('beforeUpdate', 'sys_sharing_rule', { session, previous: other, input: { id: other.id, data: { name: DECLARED.name } } })); expect(refusal.code).toBe('RESOURCE_CONFLICT'); - const copy = { id: 'srule_copy', name: DECLARED.name }; - await expect(fire('beforeUpdate', { session, previous: copy, input: { id: copy.id, data: { name: DECLARED.name, active: false } } })).resolves.toBeUndefined(); - await expect(fire('beforeUpdate', { session, previous: copy, input: { id: copy.id, data: { name: 'renamed_away' } } })).resolves.toBeUndefined(); + const held = { id: 'srule_held', name: DECLARED.name }; + await expect(fire('beforeUpdate', 'sys_sharing_rule', { session, previous: held, input: { id: held.id, data: { name: DECLARED.name, active: false } } })).resolves.toBeUndefined(); + await expect(fire('beforeUpdate', 'sys_sharing_rule', { session, previous: held, input: { id: held.id, data: { name: 'renamed_away' } } })).resolves.toBeUndefined(); }); }); }); - describe('walled: an organization’s overlay is its own', () => { + describe('walled: the switch is deployment-level, an organization’s rules its own', () => { let h: ReturnType; beforeEach(async () => { h = harness([DECLARED], [ @@ -2529,19 +2549,38 @@ describe('[ADR-0131 D2] declared sharing rules are read from the registry, never await h.rules.evaluateRule(DECLARED.name, BOOT); }); - it('a NEW rule of the declared name is refused in an organization too', async () => { + it('a rule of the declared name is refused in an organization too', async () => { expect((await refusalOf(h.rules.defineRule({ ...RULE_INPUT } as any, adminOf('org_a')))).code).toBe('RESOURCE_CONFLICT'); }); - it('org_a’s copy revokes org_a’s declared grants at once; org_b keeps the declaration and its grants', async () => { - const { fire } = wireTrigger(h); - const copy = await h.rules.defineRule({ ...RULE_INPUT, id: DECLARED.name, active: false } as any, adminOf('org_a')); - await fire('afterInsert', { result: storedRow(h, copy.id) }); - expect(h.grants().map((g) => [g.record_id, g.organization_id, g.source_id])).toEqual([['b_red', 'org_b', DECLARED.name]]); - expect((await h.rules.listRules({}, BOOT)).map((r) => [r.id, r.organization_id])).toEqual([ - [copy.id, 'org_a'], [DECLARED.name, 'org_b'], + it('one ledger row switches the declared rule off in EVERY organization, and back on', async () => { + const { setActive } = wire(h); + await setActive(DECLARED.name, false); + expect(declaredGrants(h)).toEqual([]); + expect(await h.rules.listRules({}, BOOT)).toEqual([]); + await setActive(DECLARED.name, true); + expect(declaredGrants(h).map((g) => [g.record_id, g.organization_id]).sort()).toEqual([ + ['a_red', 'org_a'], ['b_red', 'org_b'], ]); - expect(await h.rules.sweepOrphanedRuleGrants()).toBe(0); + }); + + it('a stored row of the declared name withholds the declaration in ITS organization only, reported per organization', async () => { + const errors: Array<[string, unknown]> = []; + (h.rules as any).logger = { warn: vi.fn(), error: (m: string, meta?: unknown) => errors.push([m, meta]) }; + h.engine._tables.sys_sharing_rule = [{ + id: 'srule_a', organization_id: 'org_a', name: DECLARED.name, label: 'org_a own', + object_name: 'project', criteria_json: JSON.stringify({ status: 'red' }), recipient_type: 'user', + recipient_id: 'exec', access_level: 'read', active: false, managed_by: 'admin', customized: false, + }]; + expect((await h.rules.listRules({}, BOOT)).map((r) => [r.id, r.organization_id])).toEqual([['srule_a', 'org_a'], [DECLARED.name, 'org_b']]); + expect(errors.map(([, meta]) => meta)).toEqual([{ rule: DECLARED.name, organization: 'org_a' }]); + }); + + it('UNKNOWN scopes: a ledger flip reconciles nothing — the boot sweep’s hold rule applies', async () => { + const { setActive } = wire(h); + h.rules.setDeclaredRuleScopesUnknown(); + await setActive(DECLARED.name, true); + expect(declaredGrants(h)).toHaveLength(2); }); }); }); diff --git a/packages/rest/src/sharing-rule-declared-name-conflict.test.ts b/packages/rest/src/sharing-rule-declared-name-conflict.test.ts index 240fed92334..79f412dbdef 100644 --- a/packages/rest/src/sharing-rule-declared-name-conflict.test.ts +++ b/packages/rest/src/sharing-rule-declared-name-conflict.test.ts @@ -1,19 +1,15 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [ADR-0131 D6 regime O, guarded] `POST {basePath}/sharing/rules` and a - * declared rule's name. + * [ADR-0131 D6 regime C] `POST {basePath}/sharing/rules` and a declared rule's + * name. * - * A `sys_sharing_rule` row of a declared rule's name replaces the declaration - * in its scope. The sharing-rule service refuses a NEW rule taking that name - * with a declared envelope — `code: 'RESOURCE_CONFLICT'`, `status: 409` - * (`DeclaredSharingRuleNameConflictError`, plugin-sharing) — and accepts the - * EDIT of the declared rule, which the caller sends under the projection's id - * (the rule's name). This door therefore owes two things: - * - * - the refusal answers at its own status, in the nested envelope, through - * the classified-refusal arm of `handleError` — not as `500 RULE_DEFINE_FAILED`; - * - the body's `id` reaches the service, or no caller could make the edit. + * A declared sharing rule is read-only and clonable, so no `sys_sharing_rule` + * row may take its name. The sharing-rule service refuses such a write with a + * declared envelope — `code: 'RESOURCE_CONFLICT'`, `status: 409` + * (`DeclaredSharingRuleNameConflictError`, plugin-sharing). This door owes the + * refusal its own status, in the nested envelope, through the classified-refusal + * arm of `handleError` — not `500 RULE_DEFINE_FAILED`. * * The thrown error is built here in the service's declared shape: this package * does not depend on plugin-sharing, and the arm reads the shape, not the class. @@ -81,7 +77,7 @@ function conflict(): Error { ); } -describe('[ADR-0131 D6 regime O, guarded] POST /sharing/rules — a declared rule’s name', () => { +describe('[ADR-0131 D6 regime C] POST /sharing/rules — a declared rule’s name', () => { it('a new rule taking the name answers 409 RESOURCE_CONFLICT in the nested envelope, the service writing nothing else', async () => { const defineRule = vi.fn().mockRejectedValue(conflict()); const answer = await boot({ defineRule })(RULE); @@ -92,15 +88,6 @@ describe('[ADR-0131 D6 regime O, guarded] POST /sharing/rules — a declared rul expect(defineRule).toHaveBeenCalledTimes(1); }); - it('PRESERVATION: the body’s `id` reaches the service — the edit of the declared rule is addressed by it', async () => { - const row = { id: 'srule_copy', name: RULE.name }; - const defineRule = vi.fn().mockResolvedValue(row); - const answer = await boot({ defineRule })({ ...RULE, id: RULE.name, active: false }); - expect(answer.status).toBe(201); - expect(answer.body).toEqual(row); - expect(defineRule.mock.calls[0][0]).toMatchObject({ id: RULE.name, name: RULE.name, active: false, object: 'project' }); - }); - it('ANTI-VACUITY: an unexpected fault is still the route’s own 500', async () => { const answer = await boot({ defineRule: vi.fn().mockRejectedValue(new Error('kaboom')) })(RULE); expect(answer.status).toBe(500); diff --git a/packages/runtime/src/domains/catalog-activation-door.test.ts b/packages/runtime/src/domains/catalog-activation-door.test.ts index bf8369f05fa..f09f3d4191f 100644 --- a/packages/runtime/src/domains/catalog-activation-door.test.ts +++ b/packages/runtime/src/domains/catalog-activation-door.test.ts @@ -122,9 +122,13 @@ interface Harness { writes: Array<{ op: 'insert' | 'update'; data: Row }>; } -function boot(posture: 'single' | 'group' | 'isolated' | null, opts: EngineOptions = {}): Harness { +function boot( + posture: 'single' | 'group' | 'isolated' | null, + opts: EngineOptions = {}, + extraServices: Record = {}, +): Harness { const { engine, tables, writes } = makeEngine(opts); - const services: Record = { objectql: engine, data: engine }; + const services: Record = { objectql: engine, data: engine, ...extraServices }; if (posture) services.tenancy = { posture }; const resolve = (name: string): unknown => services[name] ?? null; const kernel = { @@ -579,3 +583,73 @@ describe('the catalog activation door — the resolver honours what it writes', expect(viaPositionOff.systemPermissions).not.toContain('cap_sales'); }); }); + +// [ADR-0131 D6 regime C] A DECLARED sharing rule is switched through the same +// door and the same ledger: `:type` `sharing_rule`, the name resolved by the +// sharing-rule service (`sharingRules`, plugin-sharing's `declaredRuleNamed`), +// which serves no declaration the ledger switches off. The effect on grants is +// pinned beside that service (`sharing-rule.test.ts`, "regime C"). +describe('the catalog activation door — a declared sharing rule', () => { + const RULE = 'share_red_projects'; + const sharingRules = (declared: Record = { [RULE]: { packageId: 'crm' } }) => ({ + sharingRules: { + async declaredRuleNamed(name: string) { + return declared[name] ? { name, ...declared[name] } : null; + }, + }, + }); + + it('`single`: a tenant admin holding `manage_metadata` switches it off — one ledger row, its package recorded', async () => { + const h = boot('single', {}, sharingRules()); + const res = await flip(h, TENANT_ADMIN(), `/_activation/sharing_rule/${RULE}`); + expect(statusOf(res)).toBe(200); + expect(res.response?.body?.data).toEqual({ type: 'sharing_rule', name: RULE, enabled: false }); + expect(h.tables[LEDGER]).toEqual([ + expect.objectContaining({ metadata_type: 'sharing_rule', name: RULE, package_id: 'crm', active: false }), + ]); + }); + + for (const posture of ['group', 'isolated'] as const) { + it(`\`${posture}\`: the switch is the platform operator's — a tenant admin is refused 403 and nothing is written`, async () => { + const h = boot(posture, {}, sharingRules()); + const refused = await flip(h, TENANT_ADMIN(), `/_activation/sharing_rule/${RULE}`); + expect(statusOf(refused)).toBe(403); + expect(codeOf(refused)).toBe('PERMISSION_DENIED'); + expect(messageOf(refused)).toContain('a declared sharing rule'); + expect(h.writes).toEqual([]); + + expect(statusOf(await flip(h, PLATFORM_OPERATOR(), `/_activation/sharing_rule/${RULE}`))).toBe(200); + expect(h.writes).toHaveLength(1); + }); + } + + it('a name no declaration holds is 404 and writes nothing — an organization\'s own rule has its own switch', async () => { + const h = boot('single', {}, sharingRules()); + const res = await flip(h, TENANT_ADMIN(), '/_activation/sharing_rule/red_to_exec_ours'); + expect(statusOf(res)).toBe(404); + expect(h.writes).toEqual([]); + }); + + it('no sharing-rule service is 503 SERVICE_UNAVAILABLE, never a write for an unconfirmed name', async () => { + const h = boot('single'); + const res = await flip(h, TENANT_ADMIN(), `/_activation/sharing_rule/${RULE}`); + expect(statusOf(res)).toBe(503); + expect(codeOf(res)).toBe('SERVICE_UNAVAILABLE'); + expect(h.writes).toEqual([]); + }); + + it('a declared-rule read that fails is 503, never a write', async () => { + const h = boot('single', {}, { + sharingRules: { async declaredRuleNamed() { throw new Error('registry down'); } }, + }); + const res = await flip(h, TENANT_ADMIN(), `/_activation/sharing_rule/${RULE}`); + expect(statusOf(res)).toBe(503); + expect(h.writes).toEqual([]); + }); + + it('a declaration no package names is switched too, its row carrying no package', async () => { + const h = boot('single', {}, sharingRules({ [RULE]: {} })); + expect(statusOf(await flip(h, TENANT_ADMIN(), `/_activation/sharing_rule/${RULE}`))).toBe(200); + expect(h.tables[LEDGER][0].package_id ?? null).toBeNull(); + }); +}); From 99b5a88f6e0565b1a0565b74d88a2258696ce247 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 03:44:47 +0000 Subject: [PATCH 08/11] chore(changeset,runtime): declare regime C's narrowings; name sharing_rule on the activation route's ledger note Claude-Session: https://claude.ai/code/session_01MaH9HpfmNnJGzgWQPqNmhP Co-authored-by: Claude --- packages/runtime/src/dispatcher-plugin.ts | 4 ++-- packages/runtime/src/route-ledger.ts | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 718ef188ae8..c832a721de5 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -1992,8 +1992,8 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu }; // ── Security catalog activation (ADR-0126 §3 regime C, ADR-0131 D3/D6) ── - // `POST /security/_activation/:type/:name` — switch a position or a - // permission set on/off for this deployment + // `POST /security/_activation/:type/:name` — switch a position, a + // permission set or a declared sharing rule on/off for this deployment // (`domains/catalog-activation.ts`). The rest of `/security` is the // REST server's on the wire; this one door is the dispatcher's, // because the activation gate and the two sibling activation doors diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 5c6a4427ba7..b5f3b699d87 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -376,8 +376,9 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ { route: 'POST /security/suggested-bindings/:id/confirm', domain: '/security', disposition: 'sdk', client: 'security.suggestedBindings.confirm' }, { route: 'POST /security/suggested-bindings/:id/dismiss', domain: '/security', disposition: 'sdk', client: 'security.suggestedBindings.dismiss' }, { route: 'POST /security/_activation/:type/:name', domain: '/security', disposition: 'server-only', - note: 'ADR-0126 §3 regime C, as ADR-0131 D6 amends it — switch ONE position (`:type` = `position`) or permission set ' - + '(`:type` = `permission`) on/off for this deployment. Writes one `sys_metadata_activation` row through the engine and ' + note: 'ADR-0126 §3 regime C, as ADR-0131 D6 amends it — switch ONE position (`:type` = `position`), permission set ' + + '(`:type` = `permission`) or declared sharing rule (`:type` = `sharing_rule`, resolved by the `sharingRules` service; ' + + 'its grants reconcile on the ledger write) on/off for this deployment. Writes one `sys_metadata_activation` row through the engine and ' + 'nothing else (⛔ no definition write, ⛔ no catalog row `active`: ADR-0131 D3 moved the switch into the ledger, and ' + '`resolveUserAuthzGrants` reads only the ledger for these two types). Body `{ enabled?: boolean }`, the same reader as ' + 'the action door. The same two authority tiers as `POST /automation/:name/toggle` and `POST /actions/_activation/:object/:action`: ' From 9abd3a122002aa3175d27a49dc647f89ccc7ddd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 03:45:10 +0000 Subject: [PATCH 09/11] chore(changeset): declare regime C's narrowings and the sharing_rule activation switch Claude-Session: https://claude.ai/code/session_01MaH9HpfmNnJGzgWQPqNmhP Co-authored-by: Claude --- .../24421-sharing-rule-declared-name-guard.md | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/.changeset/24421-sharing-rule-declared-name-guard.md b/.changeset/24421-sharing-rule-declared-name-guard.md index b0fbbee6c12..2a69a7e981f 100644 --- a/.changeset/24421-sharing-rule-declared-name-guard.md +++ b/.changeset/24421-sharing-rule-declared-name-guard.md @@ -1,18 +1,26 @@ --- '@objectstack/plugin-sharing': minor -'@objectstack/rest': patch +'@objectstack/runtime': minor --- -fix(plugin-sharing)!: a new sharing rule, or a rename, cannot take a declared rule's name — the edit of the declared rule stores its customised copy, and that write reconciles the declaration's grants at once +fix(plugin-sharing,runtime)!: a declared sharing rule is read-only and clonable — no rule, new or renamed, can take its name, and it is switched off through the activation ledger, which revokes its grants at once Clause-②: no (narrowing) - + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. -A `sys_sharing_rule` row holding a declared rule's name replaces the declared rule in its scope (ADR-0131 D6, regime O): that is how an organization narrows or switches off a vendor rule. Until now any write could take the name — Setup's New, a script, an AI tool — and so switch the vendor rule off without anyone asking to. +ADR-0131 D6 charts declared sharing rules under regime C: a rule a package declares is read-only, it is customised by a clone under another name, and it is switched off through the deployment-level activation ledger (`sys_metadata_activation`), like flows, actions, positions and permission sets. Same-name shadowing is retired. -- **Refused now:** `POST /api/v1/sharing/rules` (and `SharingRuleService.defineRule`) creating a rule under a declared rule's name, and a `/data` insert of `sys_sharing_rule` under one, or an update that renames a row onto one. FROM `201` / `200` with the new row replacing the vendor rule, TO `409 RESOURCE_CONFLICT`, naming the declared rule. Fix: to customise the vendor rule, save it back with its `id` (the declared rule's name, as `GET /api/v1/sharing/rules` lists it); otherwise choose another name. -- **Unchanged:** saving the declared rule with its `id` stores the organization's customised copy, exactly as a same-name `POST` did before; editing or switching off that copy, through either door, works as before; every rule whose name no declaration holds is created and renamed as before. -- **Reconciled at once:** the write that creates the customised copy, switches it on or off, or removes it reconciles the declaration's grants in that scope in the same request — revoked while the copy holds the name, re-derived when it no longer does. Before, the declaration's grants stood until the next boot's orphaned-grant sweep. Under a wall an organization's copy is its own (the other organizations keep the declared rule); under `single` the one copy answers everywhere. +The narrowings, FROM → TO, each with its one-line fix: + +- `POST /api/v1/sharing/rules` (and `SharingRuleService.defineRule`) creating a rule under a declared rule's name — FROM `201`, the new row replacing the declared rule in its scope, TO `409 RESOURCE_CONFLICT` naming the declared rule. Fix: create your rule under another name, and switch the declared one off with `POST /api/v1/security/_activation/sharing_rule/` and `{ "enabled": false }`. +- A `/data` insert of a `sys_sharing_rule` row under a declared rule's name, or an update that renames a row onto one — FROM `201` / `200`, TO `409 RESOURCE_CONFLICT`. Fix: the same. + +What is added: + +- **The switch.** `POST /api/v1/security/_activation/sharing_rule/:name` writes the declared rule's activation row, behind the same authority as the other activation doors: `manage_metadata`, and the platform operator under a wall, because the row covers every organization. A declared rule switched off is served nowhere. The write revokes every grant that carries the rule's name in the same request, and switching it back on re-derives them. The name must be a declared rule (`404` otherwise; a rule your organization authored keeps its own `active` field), and with no sharing-rule service bound the door answers `503`. +- **A declaration over a stored row is reported, never made an overlay.** When a package declares a rule whose name a `sys_sharing_rule` row already holds in a scope (a package upgrade, or a copy customised before this release), the row keeps answering as its own rule. The declaration is not served beside it, and that is logged at `error` once per name and scope per process, with the remedy. + +Unchanged: rules whose name no declaration holds are created, renamed and switched as before. A stored row that already holds a declared name stays editable as itself. `DELETE /api/v1/sharing/rules/:idOrName` on a declared rule is still `403 PERMISSION_DENIED`, and is now answered the same way when the rule is switched off. From 75bcef5f7de197c804eb2dba5e99f38a249c312e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 03:45:30 +0000 Subject: [PATCH 10/11] chore(changeset): state the refusals inline, as the ADR-0087 disposition reads them Claude-Session: https://claude.ai/code/session_01MaH9HpfmNnJGzgWQPqNmhP Co-authored-by: Claude --- .changeset/24421-sharing-rule-declared-name-guard.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/24421-sharing-rule-declared-name-guard.md b/.changeset/24421-sharing-rule-declared-name-guard.md index 2a69a7e981f..b744b974166 100644 --- a/.changeset/24421-sharing-rule-declared-name-guard.md +++ b/.changeset/24421-sharing-rule-declared-name-guard.md @@ -13,7 +13,7 @@ Clause-②: no (narrowing) ADR-0131 D6 charts declared sharing rules under regime C: a rule a package declares is read-only, it is customised by a clone under another name, and it is switched off through the deployment-level activation ledger (`sys_metadata_activation`), like flows, actions, positions and permission sets. Same-name shadowing is retired. -The narrowings, FROM → TO, each with its one-line fix: +What is refused now: - `POST /api/v1/sharing/rules` (and `SharingRuleService.defineRule`) creating a rule under a declared rule's name — FROM `201`, the new row replacing the declared rule in its scope, TO `409 RESOURCE_CONFLICT` naming the declared rule. Fix: create your rule under another name, and switch the declared one off with `POST /api/v1/security/_activation/sharing_rule/` and `{ "enabled": false }`. - A `/data` insert of a `sys_sharing_rule` row under a declared rule's name, or an update that renames a row onto one — FROM `201` / `200`, TO `409 RESOURCE_CONFLICT`. Fix: the same. From 768fc0b2c1a03dba28e9dc01267aab5fab49f2f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 04:09:25 +0000 Subject: [PATCH 11/11] fix(plugin-sharing): keep the logger's receiver on the declaration-over-row report Claude-Session: https://claude.ai/code/session_01MaH9HpfmNnJGzgWQPqNmhP Co-authored-by: Claude --- packages/plugins/plugin-sharing/src/sharing-rule-service.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts index 79e9ac1e612..8281b36eb7a 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts @@ -1052,8 +1052,9 @@ export class SharingRuleService implements ISharingRuleService { 'while the row keeps answering as its own rule. Remedy: rename the stored rule, which makes it a clone of its ' + `own, and switch the declared rule off (POST /api/v1/security/_activation/sharing_rule/${rule.name} with ` + '{ "enabled": false }) if it must not grant.'; - const log = this.logger?.error ?? this.logger?.warn; - log?.call(this.logger, message, { rule: rule.name, organization: scope }); + const meta = { rule: rule.name, organization: scope }; + if (this.logger?.error) this.logger.error(message, meta); + else this.logger?.warn?.(message, meta); } /**