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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/24421-sharing-rule-declared-name-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@objectstack/plugin-sharing': minor
'@objectstack/runtime': minor
---

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)

<!-- adr-0087: not-required (no-migration-prescription) no spec key, export, option or stored metadata shape is added, removed or renamed, so `objectstack migrate meta` has nothing to rewrite; what changes is the accept set of two write doors, whose remedy is another name for the caller's rule plus one activation call -->

**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.

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.

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/<name>` 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.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions packages/plugins/plugin-sharing/src/declared-sharing-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 } : {}),
},
};
}
Expand Down
10 changes: 9 additions & 1 deletion packages/plugins/plugin-sharing/src/sharing-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ import {
type SharingSecurityProbe,
type SharingTenancyProbe,
} from './sharing-service.js';
import { SharingRuleService } 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';
Expand Down Expand Up @@ -849,6 +849,14 @@ 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 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
// rule is not served beside it (ADR-0131 D2), and the ownership
Expand Down
Loading
Loading