diff --git a/.changeset/adr-0057-erp-authz.md b/.changeset/adr-0057-erp-authz.md new file mode 100644 index 0000000000..c31847699f --- /dev/null +++ b/.changeset/adr-0057-erp-authz.md @@ -0,0 +1,19 @@ +--- +"@objectstack/spec": minor +"@objectstack/plugin-security": minor +"@objectstack/plugin-sharing": minor +"@objectstack/runtime": minor +"@objectstack/platform-objects": minor +"@objectstack/objectql": minor +"@objectstack/plugin-approvals": patch +--- + +ADR-0057 — ERP authorization core. Adds permission-grant access DEPTH +(`own`/`own_and_reports`/`unit`/`unit_and_below`/`org`), renames `sys_department` +→ `sys_business_unit` (pre-launch, no aliases), introduces the platform-owned +`sys_user_role` assignment, and seeds stack-declared `roles`/`sharingRules` into +`sys_role`/`sys_sharing_rule` at boot (closes #2077). Hierarchy-relative scopes are +delegated to a pluggable `IHierarchyScopeResolver` (open edition fails closed to +owner-only; `defineStack` errors without `requires: ['hierarchy-security']`). Also +fixes a latent over-grant where `engine.find({ filter })` was ignored (driver reads +`where`) — normalized `filter`→`where` in the engine. diff --git a/docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md b/docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md new file mode 100644 index 0000000000..ea5d70dea5 --- /dev/null +++ b/docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md @@ -0,0 +1,359 @@ +# ADR-0057: ERP-Grade Authorization Core — Business-Unit Partitioning, Scope-Depth Grants, and Hierarchy Rollup + +**Status**: Proposed (2026-06-21) +**Deciders**: ObjectStack Protocol Architects +**Builds on**: [ADR-0010](./0010-metadata-protection.md) (metadata protection / object ownership), +[ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove), +[ADR-0054](./0054-runtime-proof-for-authorable-surface.md) (runtime proof), +[ADR-0055](./0055-master-detail-controlled-by-parent.md) (RLS reuses pre-resolved membership IN-form; **no compiler subquery**), +[ADR-0056](./0056-permission-model-landing-verification.md) (permission-model landing verification) +**Consumers**: `@objectstack/plugin-security`, `@objectstack/plugin-sharing`, `@objectstack/plugin-org-scoping`, `@objectstack/plugin-auth`, `@objectstack/runtime`, `@objectstack/spec`, `@objectstack/rest`, `@objectstack/verify` +**Supersedes / subsumes**: issue #2077 (seed declarative roles + sharing rules), ADR-0056 **D5** (sharing-rule spec<->runtime reconcile) and **D6** (role-hierarchy widening); references #1887 (SharingRuleSchema disconnected from the live engine). + +--- + +## TL;DR + +ADR-0056 verified that ObjectStack's **Salesforce-shaped widening layer** is mostly landed: OWD +(`object.sharingModel`), sharing rules, manual shares, FLS, and RLS predicates all enforce. What that +audit did **not** question is the *shape of the model itself*. Measured against real ERP authorization +needs (multi-entity partitioning, "see my unit / my unit and below / the whole org", manager rollup), +ObjectStack is **"Salesforce-lite"**: it has no first-class data-partition tree, its permission grants +are flat (own<->all, via `viewAllRecords`), and its three candidate visibility hierarchies +(`sys_user.manager_id`, the `sys_department` tree, `sys_role.parent`) are unused, duplicated, or +**broken** (the role hierarchy queries a `parent` column `sys_role` does not have). + +This ADR adopts a **Dataverse-core / Salesforce-discipline hybrid** and makes the role system +**self-owned** (no longer borrowing better-auth's membership role). Three load-bearing additions, plus +assignment decoupling and a naming/honesty pass: + +1. **Scope-depth on permission grants** — each object grant gains a `scope` + (`own` / `unit` / `unit_and_below` / `org`), the single highest-leverage ERP feature. Resolves at + request time into an `owner` `IN`-filter; **no RLS-compiler change**. +2. **`sys_business_unit`** — promote `sys_department` to the canonical owning-unit tree (it was always + the BU; "department" is one `kind`). Powers the `unit`/`unit_and_below` scopes. +3. **Hierarchy rollup as pre-resolved `IN` membership sets** — `current_user.unit_user_ids` / + `unit_subtree_user_ids` / `subordinate_user_ids`, computed by BFS over the BU tree and the manager + chain, merged into `ctx.rlsMembership`. Reuses the existing IN-form (ADR-0055); no subquery. +4. **Decouple RBAC assignment from better-auth** — a platform-owned `sys_user_role` becomes the source + of truth; `sys_member.role` shrinks to org-administration (owner/admin/member) and is relabelled. +5. **Honesty/naming pass** — renames with one-release aliases; per ADR-0049, any unenforceable grant + scope is a **compile error**, never a silent fail-open. + +This is explicitly **not** a rewrite of the widening layer (OWD/FLS/RLS/sharing stay) and **not** full +Dataverse (position hierarchy, record-owning teams, and matrix cross-BU sharing are deferred, +evidence-gated). It is the *minimum Dataverse subset that unlocks ERP*, framed for AI-safety. + +--- + +## Context + +### The orthogonal axes every mature platform converges on + +| Axis | Salesforce | Dataverse | ServiceNow / SAP / NetSuite | ObjectStack today | +| :-- | :-- | :-- | :-- | :-- | +| **Capability** (ops x object) | Profile + Permission Set | **Security Role w/ depth** | Role(nest) / Auth Object / Role(own.sub.none) | `sys_permission_set` — **flat CRUD + viewAll/modifyAll only** | +| **Data partition tree** | — (Role/Territory approximation) | **Business Unit tree** | Domain / Company Code.Plant / Subsidiary | flat `sys_organization` tenant only; **`sys_department` tree unused as a partition** | +| **Visibility rollup** | Role hierarchy (1 role/user) | **Hierarchy Security (manager + position)** | Group `parent` / supervisor chain | `sys_user.manager_id` ok, `sys_department` tree ok, **`sys_role.parent` BROKEN** | +| **Assignment** | UserRole | role->user/team | sys_user_has_role / derived role | **`sys_member.role`** (better-auth string) | +| **Grouping + sharing** | Public Group/Queue + Sharing Rule + manual | Team(owner/access/group) + share | Group + ACL | `sys_team` + `sys_sharing_rule`/`sys_record_share` (#1887 divergence) | +| **OWD / FLS / RLS** | OWD + FLS + (Apex) | default access level + column profiles | ACL / ir.rule | `sharingModel` + FLS + `RowLevelSecurityPolicy` ok | + +Three rules every serious system obeys: **(a)** capability and visibility are decoupled; **(b)** the +data-partition tree and the rollup tree are distinct (BU is coarse/static; manager/position is +fine/additive); **(c)** the highest-value ERP feature is *scope-depth baked into the grant* +(own/unit/unit+below/org) — without it, "see my unit's data" collapses onto hand-written RLS, one per +object, which an AI author cannot reliably produce and ADR-0049 forbids shipping inert. + +### Two facts that make the core cheap and safe + +- **`sys_role` is ObjectStack-native**, not better-auth. better-auth's 18 managed tables do not include + it; better-auth only owns the `role` *string* on `sys_member` + (`packages/platform-objects/src/identity/sys-member.object.ts:152`, `managedBy:'better-auth'`, enum + owner/admin/member) and `sys_user`. The word "role" collides; the *table* does not. The platform may + own the concept outright. +- **Scope-depth and rollup need no RLS-compiler change.** The compiler already compiles + `field IN (current_user.)` against arbitrary pre-resolved sets supplied through + `ExecutionContext.rlsMembership` (`packages/plugins/plugin-security/src/rls-compiler.ts:126`, `:225`); + `org_user_ids` is the existing precedent + (`packages/runtime/src/security/resolve-execution-context.ts:260`). New scopes resolve into new keys; + the engine's existing AND-injection (`packages/plugins/plugin-security/src/security-plugin.ts ~504`) + applies them. + +### Salesforce vs Dataverse — why hybrid, not either pure + +Dataverse is **strictly more expressive**: it can model SF's role hierarchy (~ manager/position +rollup) and sharing rules (~ access teams) **plus** a first-class BU partition, scope-depth grants, and +dual hierarchies. But its combinatorial config space (BU x depth x hierarchy x team types x matrix +sharing) is where misconfiguration and silent fail-open hide — hostile to an AI-authored, "prove every +primitive" platform. Salesforce is the inverse: a clean *private-baseline + additive-widening* mental +model that is easy to reason about, audit, and prove, but with a hard ERP ceiling (no BU, no +scope-depth). **The right design for ObjectStack takes Dataverse's core and Salesforce's discipline.** + +--- + +## Decision + +Governing rules (ADR-0049): every authorization property is **enforced / `experimental` / removed**. +(ADR-0054): every enforced high-risk primitive carries a runtime proof. (ADR-0055): rollup is realized +as pre-resolved `IN` sets, never compiler subqueries. One decision per gap. + +### D1 — Scope-depth on object grants (the ERP core) + +Extend `ObjectPermissionSchema` (`packages/spec/src/security/permission.zod.ts:17`) so each per-object +grant carries a **read scope** and a **write scope** drawn from a canonical enum: + +``` +own -> records the principal owns (owner_id == me) +unit -> records owned within the principal's business unit +unit_and_below -> the principal's BU plus all descendant BUs +org -> the whole tenant (today's default-allow) +``` + +- The scope resolves at request time into an `owner_id IN (current_user.)` filter + (`own`->`[me]`, `unit`->`unit_user_ids`, `unit_and_below`->`unit_subtree_user_ids`, `org`->no filter) + and is **AND-injected by `plugin-security`** alongside existing RLS — no new enforcement site, no + compiler change. +- **Back-compat:** existing boolean grants map to today's behaviour — the new `scope` is optional and + defaults to preserve current semantics; `viewAllRecords` / `modifyAllRecords` remain the "bypass + sharing entirely" god-flags (= Dataverse Org / SF "View All Data"). +- **Authoring gate (ADR-0049 / ADR-0056 D4):** a `scope` value the runtime cannot resolve is a + **compile error**; a `unit*` scope while the stack declares no BU tree is a compile error. No + silently-inert grant ships. + +### D2 — `sys_business_unit` as the canonical data-partition tree + +Promote `sys_department` to the owning-unit tree and **rename it `sys_business_unit`** (it was always +the BU — its own description reads "department / division / **business unit** / office", and `kind` +already subtypes company/division/department/team/office; +`packages/platform-objects/src/identity/sys-department.object.ts`). The deliberate avoidance of +"organization" (better-auth's tenant) argues **for** `business_unit` and **against** `org_unit` (which +re-introduces the "org" collision). + +- Records gain an **owning unit**: derive from the owner's primary unit, or an explicit + `owning_unit_id` system field where authored. This is the coordinate the D1 `unit`/`unit_and_below` + scopes filter on. +- Keep the `kind` enum for company/division/region/legal-entity/department subtyping. Reuse + `DepartmentGraphService` (-> `BusinessUnitGraphService`) and `sys_department_member` + (-> `sys_business_unit_member`) — these already BFS the tree + (`packages/plugins/plugin-sharing/src/department-graph.ts`). +- **One-release deprecated aliases** for the object name, member table, the + `recipient_type:'department'` sharing value, and the `dept:` approver prefix. + +### D3 — Hierarchy rollup as pre-resolved membership sets (no compiler change) + +`resolve-execution-context` pre-resolves and merges into `ctx.rlsMembership`: + +``` +unit_user_ids -> users in my business unit +unit_subtree_user_ids -> users in my BU + all descendant BUs (BFS, cycle-safe, depth-bounded) +subordinate_user_ids -> users below me on the sys_user.manager_id chain (manager hierarchy) +``` + +- Sourced by `BusinessUnitGraphService` (D2) and the existing `managerOf` chain + (`packages/plugins/plugin-sharing/src/team-graph.ts:94`). Bounded (hard cap + cache, mirroring the + `org_user_ids` cap) and **org-scoped**. +- Rollup is **additive only** (widens, never restricts) and respects tenant isolation. It powers both + D1's `unit*` scopes and sharing-rule recipients (D6). Because it is pure pre-resolution, the choice + of *which* tree drives visibility is an authoring/modelling decision, not a runtime-mechanism one. + +### D4 — Decouple RBAC assignment from better-auth (`sys_user_role`) + +Introduce a platform-owned **`sys_user_role`** (`user_id`, `role`, `organization_id`, +`business_unit_id?`, `granted_by`, timestamps) as the **source of truth** for "who holds which RBAC +role". + +- `resolve-execution-context` resolves `ctx.roles` from `sys_user_role` (union `sys_member.role` during + a transition window), replacing the current sole dependence on the better-auth membership string + (`packages/runtime/src/security/resolve-execution-context.ts:226`). +- `sys_member.role` is **reframed to org-administration only** (owner/admin/member) and **relabelled** + in ObjectStack's projection to `org_membership_level` (the underlying better-auth column/API param + stays `role`; we relabel the platform schema projection in + `packages/plugins/plugin-auth/src/auth-schema-config.ts`). +- Continue feeding declared role names to better-auth `additionalOrgRoles` + (`packages/plugins/plugin-auth/src/auth-manager.ts:657`) **only** so invitations to those role names + are accepted — never as the authority for RBAC. + +### D5 — `sys_role` is a *job role* (capability bundle), not a second hierarchy + +Keep `sys_role` (platform-owned, correctly named). Its job is a **named, assignable bundle of +permission sets** (via the existing `sys_role_permission_set`) plus an optional default unit placement +— the NetSuite/Workday "job role / job profile" shape. **Visibility hierarchy does NOT live on +`sys_role`.** Therefore: + +- **Retire the broken `sys_role.parent` path.** `role-graph.ts`'s `childRoles` query targets a `parent` + column `sys_role` does not have (`packages/plugins/plugin-sharing/src/role-graph.ts:52`; the ADR-0056 + D6 "landed" claim was proven only against a mock engine). Rather than add the missing column, the + `role_and_subordinates` sharing recipient is **re-homed onto the BU subtree** (D2/D3) and renamed + `unit_and_subordinates`, with `role_and_subordinates` kept as a deprecated alias that resolves + through the unit tree. This reconciles ADR-0056 D6 honestly (implement via the working tree, not the + broken one). +- (Alternative retained in the record: a customer who genuinely needs a *Salesforce-style role tree* + distinct from the BU tree can opt into adding `sys_role.parent` later; it is not on the v1 path.) + +### D6 — Reconcile and seed roles, units, and sharing rules at boot (subsumes #2077, ADR-0056 D5) + +Stack-declared `roles`, business units, role assignments, and `sharingRules` are seeded +**idempotently** into their system tables at boot (verify harness + CLI), so the existing evaluators +activate — closing the #2077 "decorative metadata" gap. + +- **Roles -> `sys_role`** and **assignments -> `sys_user_role`**: read `metadataService.list('role')`, + upsert by name, mark provenance so re-seed reconciles updates/removals without clobbering + UI-created rows. Home: `plugin-security` (sibling to `bootstrapPlatformAdmin`, + `packages/plugins/plugin-security/src/bootstrap-platform-admin.ts`). +- **Sharing rules -> `sys_sharing_rule`**: seed inside `SharingServicePlugin.start()`'s `kernel:ready` + **before** `listRules()`/`bindRuleHooks` (`packages/plugins/plugin-sharing/src/sharing-plugin.ts:168`) + so hooks bind to a populated table. +- **#1887 / ADR-0056 D5 — pick the canonical sharing-rule shape.** The spec `SharingRuleSchema` (CEL + `condition`, `ownedBy`, `sharedWith` enum incl. `group`/`guest`) diverges from the runtime + `sys_sharing_rule` (`criteria_json` JSON filter, `recipient_type`/`recipient_id`) and is flagged + `EXPERIMENTAL — NOT ENFORCED` (`packages/spec/src/security/sharing.zod.ts:97`). Decision: the + **runtime shape is canonical**. The seeder translates the directly-mappable authoring fields + (`object`->`object_name`, `accessLevel`->`access_level`, + `sharedWith{type,value}`->`recipient_type`/`recipient_id` for user/role/unit-and-subordinates), and + the unmappable parts (CEL `condition`, `owner`-type `ownedBy`, `group`/`guest`) stay + **`[EXPERIMENTAL — not enforced]`** until a minimal CEL->FilterCondition compiler lands (tracked under + #1887). This keeps #2077 deliverable without blocking on the full CEL reconciliation. + +### D7 — Honesty / naming pass (ADR-0049 no silent fail-open) + +One coordinated rename + alias wave: `sys_department`->`sys_business_unit`, +`sys_member.role`->`org_membership_level` (projection label), recipient enum +`role_and_subordinates`->`unit_and_subordinates`, with one-release deprecated aliases throughout. No +behaviour change in this pass. Any grant scope, recipient, or `using`/`check` the runtime cannot +resolve is a **compile error**, never silently inert. + +### D8 — Bind the new primitives into the conformance matrix + liveness ledger + +Extend the ADR-0056 **D10 Authorization Conformance Matrix** and the ADR-0054 proof registry with one +row + dogfood proof per new primitive: **scope `own`/`unit`/`unit_and_below`/`org`**, **BU rollup**, +**manager rollup**, **`sys_user_role` assignment resolution**, and the **`unit_and_subordinates` +sharing recipient e2e** (a manager + their unit subordinates gain access via the widening — the +#2077 / ADR-0056 D6 demo). Ratchet, not retrofit: each proof lands with its phase's PR. + +--- + +## Consequences + +**Positive.** +- ObjectStack moves from Salesforce-lite to **ERP-grade**: "regional manager edits their unit and + below" becomes a one-line declarative grant (`Account.edit = unit_and_below`), enforceable and + provable, instead of N hand-written RLS policies. +- The model becomes **self-owned**: RBAC no longer borrows better-auth's membership role; better-auth + is cleanly confined to identity + org-administration. +- **AI-safe**: scope-depth is trivially authorable and gated (unknown/unenforceable scope = compile + error); the three-half-trees ambiguity collapses to one BU tree + the manager chain. +- Closes #2077 honestly and reconciles ADR-0056 D5/D6 via the *working* hierarchy. + +**Negative / costs.** +- Renames (D2/D7) touch many registration points (graph services, member table, recipient enums, + translations, nav contributions, approver prefixes) — mitigated by one-release aliases. +- Owning-unit derivation (D2) introduces a record-level coordinate that existing example apps must + adopt to benefit from `unit*` scopes; the default (`org`) preserves current behaviour, so adoption + is opt-in. +- Activating previously-inert roles/rules (D6) changes who-can-see-what for apps that declared them + (`app-showcase`, `app-crm`) — requires a full dogfood-suite run; any change must be intentional and + documented (mirrors ADR-0056's behaviour-change discipline). + +**Neutral / open.** +- Whether owning-unit is always derived from the owner's primary unit, or sometimes explicitly stamped + per object — settled per-object in the D2 PR. +- Whether a customer ever needs a Salesforce-style role tree distinct from the BU tree (D5 alternative) + — evidence-gated. + +## Non-goals + +- **Not** a rewrite of the widening layer — OWD (`sharingModel`), FLS, RLS predicates, sharing rules, + manual shares all stay. +- **Not** full Dataverse: **position hierarchy**, **record-owning teams**, and **matrix cross-BU + sharing** are deferred (P4, evidence-gated). +- **Not** adding RLS-compiler subquery support (ADR-0055 stands; rollup uses pre-resolved IN sets). +- **Not** a full CEL->FilterCondition compiler for sharing rules — only the minimal mappable subset is + seeded now; the rest stays `experimental` under #1887. + +## Alternatives considered + +- **(a) Pure Salesforce** (role hierarchy + sharing only). Rejected for an ERP-aspiring platform: no BU + partition, no scope-depth -> every "see my unit" need collapses onto hand-written RLS; SF's + materialised-share runtime also scales worse than ObjectStack's RLS-IN injection. +- **(b) Pure Dataverse** (BU-per-user, depth baked into a heavy role, dual hierarchy, three team types, + matrix sharing). Rejected as too large a config surface to AI-author safely and prove per ADR-0049. +- **(c) Add `sys_role.parent` (Salesforce role tree).** Rejected as the default: it builds a third + hierarchy duplicating the working BU/manager trees; re-homing `role_and_subordinates` onto the BU + subtree (D5) is honest and reuses a tree that already enforces. Retained as an opt-in. +- **(d, chosen) Dataverse-core / Salesforce-discipline hybrid** — minimum Dataverse subset (BU + + scope-depth + rollup) with SF's additive-widening framing and capability/visibility decoupling. + +## Phasing (each phase independently shippable, each with proofs) + +- **P1 — Naming + honesty (no behaviour change).** D7 renames + aliases; `sys_user_role` schema + + `BusinessUnitGraphService` scaffolding; D8 conformance-matrix skeleton. Compile-error gates for + unresolvable scopes/recipients. +- **P2 — ERP core.** D1 scope-depth grants + D2 owning-unit partition + D3 rollup IN-sets. Each with a + dogfood proof (own / unit / unit_and_below / org). +- **P3 — Assignment + activation (subsumes #2077, ADR-0056 D6 e2e).** D4 `sys_user_role` decoupling + + D6 seeding (roles, assignments, units, sharing rules) + the `unit_and_subordinates` showcase demo + proof. +- **P4 — Evidence-gated.** Position hierarchy, record-owning teams, matrix cross-BU sharing, minimal + CEL->filter compiler (#1887 full close). + +## References + +- ADRs: 0010, 0049, 0054, 0055, 0056. Issues: #2077 (seed roles/rules), #1887 (SharingRuleSchema + disconnected). +- Capability shape: `packages/spec/src/security/permission.zod.ts:17` (`allow*` + + `viewAllRecords`/`modifyAllRecords`). +- RLS IN-form + membership injection: `packages/plugins/plugin-security/src/rls-compiler.ts:126`, + `packages/runtime/src/security/resolve-execution-context.ts:226`. +- BU tree: `packages/platform-objects/src/identity/sys-department.object.ts`, + `packages/plugins/plugin-sharing/src/department-graph.ts`. +- Role concept: `packages/plugins/plugin-security/src/objects/sys-role.object.ts` (no `parent`), + `packages/plugins/plugin-sharing/src/role-graph.ts:52` (broken walk). +- better-auth boundary: `packages/platform-objects/src/identity/sys-member.object.ts:152`, + `packages/plugins/plugin-auth/src/auth-manager.ts:657`, + `packages/plugins/plugin-auth/src/auth-schema-config.ts`. +- Seeding precedent: `packages/plugins/plugin-security/src/bootstrap-platform-admin.ts`; sharing boot: + `packages/plugins/plugin-sharing/src/sharing-plugin.ts:168`. + +--- + +## Implementation status (2026-06-21) + +Landed incrementally on branch `adr/0057-erp-authz-core`, each with a runtime proof: + +| Decision | Status | Proof / artifact | +| :-- | :-- | :-- | +| D1 — scope-depth grants (own/unit/unit_and_below/org) | ✅ landed | `showcase-scope-depth.dogfood` (4 cases) | +| D2 — `sys_business_unit` canonical partition tree | ✅ landed | repo-wide rename, all suites green | +| D3 — BU rollup via owner-set expansion (no compiler change) | ✅ landed | scope-depth + bu-hierarchy dogfoods | +| D4 — `sys_user_role` assignment, decoupled from better-auth | ✅ landed (read-merge) | `resolve-execution-context` reads `sys_user_role` ∪ `sys_member.role` | +| D5 — `role_and_subordinates` re-homed onto the BU subtree | ✅ landed | `showcase-bu-hierarchy-sharing.dogfood` | +| D6 — seed declared roles + sharingRules at boot (#2077) | ✅ landed | `showcase-declarative-rbac-seeding.dogfood` | +| D8 — conformance matrix rows for the new primitives | ✅ landed | `authz-conformance.matrix.ts` + `.test.ts` | +| (latent) `find({filter})` ignored by engine → over-grant | ✅ fixed | engine `filter`→`where` normalization; objectql 670 tests green | + +**Deferred (evidence-gated, P4):** `sys_member.role`→`org_membership_level` relabel; manager-chain +rollup (`subordinate_user_ids`); position hierarchy; record-owning teams; matrix cross-BU sharing; +a full CEL→FilterCondition compiler (#1887) beyond the field-equality subset. + +### Open/paid seam — hierarchy scopes are a pluggable enterprise capability (2026-06-21) + +Hierarchy-relative visibility (scope `unit` / `unit_and_below` / `own_and_reports` — +"you see records by where you sit in the org") is the Salesforce/Dynamics commercial +hallmark and is **not** required to build a secure app (explicit RLS + sharing rules + +`own`/`org` scope suffice). It is therefore an **enterprise** capability, not open-core: + +- **Open (framework):** the `IHierarchyScopeResolver` contract (`spec/contracts`), + `own`/`org` scope, the BU data model + graph + `business_unit` *explicit* sharing + recipient, roles, `#2077` seeding, the (future) predicate compiler. `SharingService` + delegates hierarchy scopes to the resolver and **fails closed to owner-only** when none + is registered — never fail-open. `defineStack` **errors** if a grant uses a hierarchy + scope without `requires: ['hierarchy-security']` (no silent lie, ADR-0049). +- **Paid (`@objectstack/security-enterprise`, private cloud repo):** the + `hierarchy-scope-resolver` implementation (BU subtree + manager-chain rollup), plus the + P4 heavy org-modeling (position hierarchy, matrix cross-BU, owner-teams) and governance + (SSO/SCIM, audit/access-review, SoD). + +The commercial boundary ADR lives in `cloud/docs/adr/`; this note records only the +open-side technical seam. Proof: `showcase-scope-depth.dogfood` (reference resolver + +fail-closed case). diff --git a/packages/dogfood/test/authz-conformance.matrix.ts b/packages/dogfood/test/authz-conformance.matrix.ts index bc10abdd31..3cf25e68f5 100644 --- a/packages/dogfood/test/authz-conformance.matrix.ts +++ b/packages/dogfood/test/authz-conformance.matrix.ts @@ -44,6 +44,14 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ { id: 'default-profile', summary: 'app-declared default profile (isDefault)', state: 'enforced', enforcement: 'plugin-security/security-plugin.ts fallback resolution', proof: 'showcase-default-profile.dogfood.test.ts' }, + // ── ADR-0057 — ERP authorization core (enforced + e2e proven) ────────── + { id: 'scope-depth', summary: 'permission-grant access DEPTH (own/own_and_reports/unit/unit_and_below/org)', state: 'enforced', + enforcement: 'plugin-security getEffectiveScope (stash) + plugin-sharing delegates HIERARCHY scopes to a pluggable IHierarchyScopeResolver (open: fail-closed to own; enterprise @objectstack/security-enterprise; reference resolver in this proof) — ADR-0057 D1', proof: 'showcase-scope-depth.dogfood.test.ts' }, + { id: 'declarative-rbac-seeding', summary: 'stack-declared roles + sharingRules seeded at boot (#2077)', state: 'enforced', + enforcement: 'plugin-security bootstrapDeclaredRoles + plugin-sharing bootstrapDeclaredSharingRules — ADR-0057 D6', proof: 'showcase-declarative-rbac-seeding.dogfood.test.ts' }, + { id: 'rbac-role-assignment', summary: 'platform-owned RBAC assignment (sys_user_role, decoupled from better-auth membership)', state: 'enforced', + enforcement: 'runtime/resolve-execution-context.ts reads sys_user_role (union sys_member.role) — ADR-0057 D4' }, + // ── Enforced (unit-proven; e2e proof is a follow-on) ─────────────────── { id: 'object-crud', summary: 'object CRUD permissions', state: 'enforced', enforcement: 'plugin-security/security-plugin.ts checkObjectPermission (fail-closed 403)' }, @@ -54,9 +62,9 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ { id: 'record-share', summary: 'manual record shares (sys_record_share)', state: 'enforced', enforcement: 'plugin-sharing/sharing-service.ts buildReadFilter/canEdit' }, { id: 'sharing-rules', summary: 'criteria/owner sharing rules', state: 'enforced', - enforcement: 'plugin-sharing/sharing-rule-service.ts (materialized into sys_record_share)' }, - { id: 'role-hierarchy', summary: 'role-hierarchy widening (role_and_subordinates)', state: 'enforced', - enforcement: 'plugin-sharing/role-graph.ts RoleGraphService (sys_role.parent)' }, + enforcement: 'plugin-sharing/sharing-rule-service.ts (materialized into sys_record_share)', proof: 'showcase-bu-hierarchy-sharing.dogfood.test.ts' }, + { id: 'hierarchy-widening', summary: 'hierarchy widening — a unit + its subordinate units gain access', state: 'enforced', + enforcement: 'plugin-sharing/business-unit-graph.ts BusinessUnitGraphService subtree (business_unit recipient) — ADR-0057 D5 re-homed off the never-existent sys_role.parent', proof: 'showcase-bu-hierarchy-sharing.dogfood.test.ts' }, { id: 'rls-compiler-fail-closed', summary: 'uncompilable RLS predicate is surfaced/denied, not dropped', state: 'enforced', enforcement: 'plugin-security/rls-compiler.ts isSupportedRlsExpression + warn' }, { id: 'system-permissions', summary: 'systemPermissions / tab-app gating', state: 'enforced', diff --git a/packages/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts b/packages/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts new file mode 100644 index 0000000000..cb2817d353 --- /dev/null +++ b/packages/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts @@ -0,0 +1,103 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0057 D6 (reconciles ADR-0056 D6 / #2077 demo) — a sharing rule whose +// recipient is a BUSINESS UNIT widens access DOWN the hierarchy: the unit's +// members AND every subordinate (descendant) unit's members gain access via the +// `sys_business_unit` tree (BFS). This is the honest re-homing of the broken +// `role_and_subordinates` (sys_role.parent never existed) onto the working +// business-unit tree. Proven end-to-end: the rule materialises sys_record_share +// rows; a non-owner in the unit subtree can then read a private record. +// +// @proof: showcase-bu-hierarchy-sharing + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const OBJ = '/data/showcase_private_note'; +const SYS = { isSystem: true } as const; + +describe('showcase: business-unit hierarchy sharing rule (ADR-0057 D6 / #2077)', () => { + let stack: VerifyStack; + let ql: any; + let ownerTok: string, mgrTok: string, contribTok: string, outsiderTok: string; + let noteId: string; + + beforeAll(async () => { + stack = await bootStack(showcaseStack); + await stack.signIn(); + ownerTok = await stack.signUp('bu-owner@verify.test'); + mgrTok = await stack.signUp('bu-mgr@verify.test'); // parent BU + contribTok = await stack.signUp('bu-contrib@verify.test'); // child BU + outsiderTok = await stack.signUp('bu-outsider@verify.test'); // no BU + + ql = await stack.kernel.getServiceAsync('objectql'); + const sys = (o: string, d: any) => ql.insert(o, d, { context: SYS }); + const uid = async (e: string) => (await ql.findOne('sys_user', { where: { email: e }, context: SYS }))?.id; + const mgrId = await uid('bu-mgr@verify.test'); + const contribId = await uid('bu-contrib@verify.test'); + + let org = await ql.findOne('sys_organization', { where: {}, context: SYS }).catch(() => null); + const orgId = org?.id ?? 'org_bu'; + if (!org) await sys('sys_organization', { id: orgId, name: 'BU Org', slug: 'bu_org' }).catch(() => {}); + + // parent ⊃ child + await sys('sys_business_unit', { id: 'bu_h_parent', name: 'Region', kind: 'division', organization_id: orgId, active: true }); + await sys('sys_business_unit', { id: 'bu_h_child', name: 'Team', kind: 'department', parent_business_unit_id: 'bu_h_parent', organization_id: orgId, active: true }); + await sys('sys_business_unit_member', { id: 'bm_mgr', business_unit_id: 'bu_h_parent', user_id: mgrId }); + await sys('sys_business_unit_member', { id: 'bm_contrib', business_unit_id: 'bu_h_child', user_id: contribId }); + + // owner creates a PRIVATE note — only the owner can see it by default. + const c = await stack.apiAs(ownerTok, 'POST', OBJ, { title: 'BU-shared note' }); + expect(c.status, 'owner creates note').toBeLessThan(300); + noteId = (await c.json())?.id ?? (await c.json())?.record?.id; + if (!noteId) { + const row = await ql.findOne('showcase_private_note', { where: { title: 'BU-shared note' }, context: SYS }); + noteId = row?.id; + } + + // Define a sharing rule: share ALL private notes with the PARENT business + // unit (and, via the tree, its descendants). Recipient = business_unit. + const rules: any = stack.kernel.getService('sharingRules'); + await rules.defineRule({ + name: 'share_notes_with_region', + label: 'Notes → Region (BU subtree)', + object: 'showcase_private_note', + recipientType: 'business_unit', + recipientId: 'bu_h_parent', + accessLevel: 'read', + active: true, + }, SYS); + // Materialise grants for existing records. + await rules.evaluateRule('share_notes_with_region', SYS); + }, 90_000); + + afterAll(async () => { await stack?.stop(); }); + + it('materialises sys_record_share rows for the BU subtree', async () => { + const shares = await ql.find('sys_record_share', { + where: { object_name: 'showcase_private_note', record_id: noteId, source: 'rule' }, + context: SYS, + }); + const recipients = (shares ?? []).map((s: any) => s.recipient_id); + const mgrId = (await ql.findOne('sys_user', { where: { email: 'bu-mgr@verify.test' }, context: SYS }))?.id; + const contribId = (await ql.findOne('sys_user', { where: { email: 'bu-contrib@verify.test' }, context: SYS }))?.id; + expect(recipients, 'parent-BU member granted').toContain(mgrId); + expect(recipients, 'child-BU (subordinate) member granted via hierarchy').toContain(contribId); + }); + + it('a manager in the unit can READ the owner\'s private note', async () => { + const r = await stack.apiAs(mgrTok, 'GET', `${OBJ}/${noteId}`); + expect(r.status, 'manager reads via BU share').toBe(200); + }); + + it('a contributor in a SUBORDINATE unit can READ it too (hierarchy widening)', async () => { + const r = await stack.apiAs(contribTok, 'GET', `${OBJ}/${noteId}`); + expect(r.status, 'subordinate-unit member reads via hierarchy widening').toBe(200); + }); + + it('an outsider (no business unit) is NOT granted access', async () => { + const r = await stack.apiAs(outsiderTok, 'GET', `${OBJ}/${noteId}`); + expect(r.status, 'outsider stays denied').not.toBe(200); + }); +}); diff --git a/packages/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts b/packages/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts new file mode 100644 index 0000000000..aae4430ec7 --- /dev/null +++ b/packages/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0057 D6 / #2077 — stack-declared `roles` + `sharingRules` are seeded into +// sys_role / sys_sharing_rule at boot, so they stop being decorative. The issue +// reported booting the showcase yielded sys_role count = 0 and sys_sharing_rule +// count = 0. This proves the opposite, plus the spec→runtime translation. +// +// @proof: showcase-declarative-rbac-seeding + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +describe('showcase: declarative RBAC seeding (ADR-0057 D6 / #2077)', () => { + let stack: VerifyStack; + let ql: any; + + beforeAll(async () => { + stack = await bootStack(showcaseStack); + await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + }, 60_000); + afterAll(async () => { await stack?.stop(); }); + + it('declared roles land in sys_role (was count = 0)', async () => { + const roles = await ql.find('sys_role', { where: {}, context: { isSystem: true } }); + const names = (roles ?? []).map((r: any) => r.name); + expect(names, 'showcase declares contributor/manager/exec').toEqual( + expect.arrayContaining(['contributor', 'manager', 'exec']), + ); + }); + + it('declared criteria sharing rule lands in sys_sharing_rule, CEL→criteria_json translated', async () => { + const rules = await ql.find('sys_sharing_rule', { where: {}, context: { isSystem: true } }); + const red = (rules ?? []).find((r: any) => r.name === 'share_red_projects_with_execs'); + expect(red, 'criteria rule seeded (was count = 0)').toBeTruthy(); + expect(red.object_name).toBe('showcase_project'); + expect(red.recipient_type).toBe('role'); + expect(red.recipient_id).toBe('exec'); + // condition "record.health == 'red'" → JSON FilterCondition { health: 'red' } + const criteria = JSON.parse(red.criteria_json); + expect(criteria).toEqual({ health: 'red' }); + }); + + it('owner-based rule is NOT seeded as a match-all (experimental, ADR-0049 honesty)', async () => { + const rules = await ql.find('sys_sharing_rule', { where: {}, context: { isSystem: true } }); + const owner = (rules ?? []).find((r: any) => r.name === 'share_contributor_tasks_with_manager'); + // owner-type has no static criteria_json equivalent → skipped, not seeded over-broadly. + expect(owner, 'owner-based rule must not silently over-share').toBeFalsy(); + }); + + it('re-seed is idempotent (no duplicate rows on a second boot)', async () => { + const roles = await ql.find('sys_role', { where: { name: 'manager' }, context: { isSystem: true } }); + expect((roles ?? []).length, 'exactly one manager role').toBe(1); + }); +}); diff --git a/packages/dogfood/test/showcase-scope-depth.dogfood.test.ts b/packages/dogfood/test/showcase-scope-depth.dogfood.test.ts new file mode 100644 index 0000000000..f985b7cdd8 --- /dev/null +++ b/packages/dogfood/test/showcase-scope-depth.dogfood.test.ts @@ -0,0 +1,176 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0057 D1 — scope-depth read grants on the REAL showcase app. +// A grant's `readScope` widens the owner-match for an owner-scoped (`private`) +// object: `unit` → records owned by my business-unit co-members; `unit_and_below` +// → my BU plus all descendant BUs (BFS). Sharing still widens on top; cross-BU +// stays isolated. ('own' depth is already proven by showcase-private-owd.) +// +// NOTE: hierarchy-scope resolution is an ENTERPRISE capability (lives in +// @objectstack/security-enterprise). The open edition fails closed to owner-only. +// This test registers a REFERENCE resolver (a test fixture) to prove the seam + +// the contract end-to-end; production ships the enterprise resolver. +// +// @proof: showcase-scope-depth + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { PermissionSetSchema } from '@objectstack/spec/security'; + +const OBJ = '/data/showcase_private_note'; +const WHO = ['alice', 'bob', 'carol', 'dave'] as const; +type Who = (typeof WHO)[number]; + +function scopeProfile(scope: 'unit' | 'unit_and_below') { + return PermissionSetSchema.parse({ + name: `scope_${scope}_profile`, + label: `Scope ${scope}`, + isProfile: true, + isDefault: true, + objects: { + showcase_private_note: { + allowRead: true, allowCreate: true, allowEdit: true, + readScope: scope, writeScope: scope, + }, + }, + }); +} + +interface World { stack: VerifyStack; tokens: Record; } + +// Build a BU world: bu_parent ⊃ bu_child (sibling bu_other is separate). +// alice+carol ∈ bu_parent, bob ∈ bu_child, dave ∈ bu_other. Each owns one note. +async function bootScopeWorld(scope: 'unit' | 'unit_and_below', withResolver = true): Promise { + const stack = await bootStack(showcaseStack, { + security: new SecurityPlugin({ + defaultPermissionSets: [...securityDefaultPermissionSets, scopeProfile(scope)], + }), + }); + await stack.signIn(); + const tokens = {} as Record; + for (const who of WHO) tokens[who] = await stack.signUp(`scope-${who}-${scope}@verify.test`); + + const ql: any = await stack.kernel.getServiceAsync('objectql'); + const sys = (o: string, d: any) => ql.insert(o, d, { context: { isSystem: true } }); + + // Reference hierarchy-scope resolver (test fixture; prod = @objectstack/security-enterprise). + // Inlined (no plugin-sharing import) — proves the IHierarchyScopeResolver seam end-to-end. + const refResolver = { + async resolveOwnerIds(c: any, sc: string): Promise { + const meId = c.userId as string; + const ids = new Set([meId]); + if (sc === 'own_and_reports') { + let frontier: string[] = [meId]; const seen = new Set([meId]); + for (let d = 0; d < 20 && frontier.length; d++) { + const rows = await ql.find('sys_user', { where: { manager_id: { $in: frontier } }, fields: ['id'], context: { isSystem: true } }); + const next: string[] = []; + for (const r of rows ?? []) { const id = String(r.id ?? ''); if (id && !seen.has(id)) { seen.add(id); ids.add(id); next.push(id); } } + frontier = next; + } + return [...ids]; + } + const myBus = await ql.find('sys_business_unit_member', { where: { user_id: meId }, fields: ['business_unit_id'], context: { isSystem: true } }); + let buIds: string[] = [...new Set((myBus ?? []).map((r: any) => String(r.business_unit_id ?? '')).filter(Boolean))] as string[]; + if (!buIds.length) return [meId]; + if (sc === 'unit_and_below') { + const allBu = new Set(buIds); let frontier: string[] = [...buIds]; + for (let d = 0; d < 20 && frontier.length; d++) { + const kids = await ql.find('sys_business_unit', { where: { parent_business_unit_id: { $in: frontier } }, fields: ['id'], context: { isSystem: true } }); + const next: string[] = []; + for (const k of kids ?? []) { const id = String(k.id ?? ''); if (id && !allBu.has(id)) { allBu.add(id); next.push(id); } } + frontier = next; + } + buIds = [...allBu]; + } + const m = await ql.find('sys_business_unit_member', { where: { business_unit_id: { $in: buIds } }, fields: ['user_id'], context: { isSystem: true } }); + for (const x of m ?? []) { const u = String(x.user_id ?? ''); if (u) ids.add(u); } + return [...ids]; + }, + }; + if (withResolver) (stack.kernel as any).registerService('hierarchy-scope-resolver', refResolver); + const uid = async (who: Who) => + (await ql.findOne('sys_user', { where: { email: `scope-${who}-${scope}@verify.test` }, context: { isSystem: true } }))?.id; + const id = {} as Record; + for (const who of WHO) id[who] = await uid(who); + + // org (BU.organization_id is required) — reuse any existing, else create one. + let org = await ql.findOne('sys_organization', { where: {}, context: { isSystem: true } }).catch(() => null); + let orgId = org?.id; + if (!orgId) { orgId = 'org_scope'; await sys('sys_organization', { id: orgId, name: 'Scope Org', slug: `scope_${scope}` }).catch(() => {}); } + + const p = `${scope}`; + await sys('sys_business_unit', { id: `bu_parent_${p}`, name: 'Parent', kind: 'division', organization_id: orgId, active: true }); + await sys('sys_business_unit', { id: `bu_child_${p}`, name: 'Child', kind: 'department', parent_business_unit_id: `bu_parent_${p}`, organization_id: orgId, active: true }); + await sys('sys_business_unit', { id: `bu_other_${p}`, name: 'Other', kind: 'division', organization_id: orgId, active: true }); + await sys('sys_business_unit_member', { id: `m_a_${p}`, business_unit_id: `bu_parent_${p}`, user_id: id.alice }); + await sys('sys_business_unit_member', { id: `m_c_${p}`, business_unit_id: `bu_parent_${p}`, user_id: id.carol }); + await sys('sys_business_unit_member', { id: `m_b_${p}`, business_unit_id: `bu_child_${p}`, user_id: id.bob }); + await sys('sys_business_unit_member', { id: `m_d_${p}`, business_unit_id: `bu_other_${p}`, user_id: id.dave }); + + for (const who of WHO) { + const r = await stack.apiAs(tokens[who], 'POST', OBJ, { title: `${who} note` }); + expect(r.status, `${who} creates note`).toBeLessThan(300); + } + return { stack, tokens }; +} + +async function titles(stack: VerifyStack, token: string): Promise { + const r = await stack.apiAs(token, 'GET', OBJ); + expect(r.status).toBe(200); + const b: any = await r.json(); + return (b.records ?? b.data ?? b ?? []).map((x: any) => x.title).filter(Boolean); +} + +describe('showcase: scope-depth read — `unit` (ADR-0057 D1)', () => { + let world: World; + beforeAll(async () => { world = await bootScopeWorld('unit'); }, 120_000); + afterAll(async () => { await world?.stack?.stop(); }); + + it('widens to BU co-members, NOT child or sibling BUs', async () => { + const t = await titles(world.stack, world.tokens.alice); + expect(t).toContain('alice note'); // own + expect(t).toContain('carol note'); // same BU (widened) + expect(t).not.toContain('bob note'); // child BU — `unit` does not descend + expect(t).not.toContain('dave note'); // sibling BU — isolated + }); + + it('a lone child member sees only their own', async () => { + const t = await titles(world.stack, world.tokens.bob); + expect(t.sort()).toEqual(['bob note']); + }); +}); + +describe('showcase: scope-depth read — `unit_and_below` (ADR-0057 D1)', () => { + let world: World; + beforeAll(async () => { world = await bootScopeWorld('unit_and_below'); }, 120_000); + afterAll(async () => { await world?.stack?.stop(); }); + + it('descends into child BUs (BFS subtree)', async () => { + const t = await titles(world.stack, world.tokens.alice); + expect(t).toContain('alice note'); // own + expect(t).toContain('carol note'); // same BU + expect(t).toContain('bob note'); // child BU — subtree descent + expect(t).not.toContain('dave note'); // sibling root — still isolated + }); + + it('the child member does NOT roll up into the parent', async () => { + const t = await titles(world.stack, world.tokens.bob); + expect(t.sort()).toEqual(['bob note']); // child has no descendants; no upward visibility + }); +}); + +describe('open edition — hierarchy scope fails CLOSED without the enterprise resolver (ADR-0057)', () => { + let world: World; + beforeAll(async () => { world = await bootScopeWorld('unit', /* withResolver */ false); }, 120_000); + afterAll(async () => { await world?.stack?.stop(); }); + + it('a `unit` grant degrades to owner-only — no widening, never fail-open', async () => { + const t = await titles(world.stack, world.tokens.alice); + expect(t).toContain('alice note'); // own still works + expect(t).not.toContain('carol note'); // NO unit widening without @objectstack/security-enterprise + expect(t).not.toContain('bob note'); + expect(t).not.toContain('dave note'); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 9fb5669c7b..74d2deee2f 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -1866,6 +1866,15 @@ export class ObjectQL implements IDataEngine { const ast: QueryAST = { object, ...query }; // Remove context from the AST — it's not a driver concern delete (ast as any).context; + // Normalize the `filter` alias → `where`. The DataEngine contract + // (`spec/data/data-engine.zod.ts`) exposes both, but the driver AST only + // understands `where`; an internal caller passing `{ filter }` would + // otherwise match ALL rows (silent over-grant — surfaced by ADR-0057's + // sharing/graph read path). `where` wins when both are present. + if ((ast as any).filter != null && ast.where == null) { + ast.where = (ast as any).filter; + } + delete (ast as any).filter; // Normalize OData `top` alias → standard `limit` if ((ast as any).top != null && ast.limit == null) { ast.limit = (ast as any).top; diff --git a/packages/platform-objects/scripts/i18n-extract.config.ts b/packages/platform-objects/scripts/i18n-extract.config.ts index 31443a411d..dba4c9720a 100644 --- a/packages/platform-objects/scripts/i18n-extract.config.ts +++ b/packages/platform-objects/scripts/i18n-extract.config.ts @@ -37,8 +37,8 @@ import { SysInvitation, SysTeam, SysTeamMember, - SysDepartment, - SysDepartmentMember, + SysBusinessUnit, + SysBusinessUnitMember, SysApiKey, SysTwoFactor, SysDeviceCode, @@ -130,8 +130,8 @@ export default defineStack({ SysInvitation, SysTeam, SysTeamMember, - SysDepartment, - SysDepartmentMember, + SysBusinessUnit, + SysBusinessUnitMember, SysApiKey, SysTwoFactor, SysDeviceCode, diff --git a/packages/platform-objects/src/apps/setup-nav.contributions.ts b/packages/platform-objects/src/apps/setup-nav.contributions.ts index 89e0f7a506..736c789a6c 100644 --- a/packages/platform-objects/src/apps/setup-nav.contributions.ts +++ b/packages/platform-objects/src/apps/setup-nav.contributions.ts @@ -47,7 +47,7 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [ priority: BASE_PRIORITY, items: [ { id: 'nav_users', type: 'object', label: 'Users', objectName: 'sys_user', icon: 'user' }, - { id: 'nav_departments', type: 'object', label: 'Departments', objectName: 'sys_department', icon: 'building', requiresObject: 'sys_department' }, + { id: 'nav_departments', type: 'object', label: 'Departments', objectName: 'sys_business_unit', icon: 'building', requiresObject: 'sys_business_unit' }, { id: 'nav_teams', type: 'object', label: 'Teams', objectName: 'sys_team', icon: 'users-round' }, { id: 'nav_organizations', type: 'object', label: 'Organizations', objectName: 'sys_organization', icon: 'building-2' }, { id: 'nav_invitations', type: 'object', label: 'Invitations', objectName: 'sys_invitation', icon: 'mail' }, diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 6708584373..67734b2175 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -577,9 +577,9 @@ export const enObjects: NonNullable = { } } }, - sys_department: { - label: "Department", - pluralLabel: "Departments", + sys_business_unit: { + label: "Business Unit", + pluralLabel: "Business Units", description: "Hierarchical org-skeleton node (department / division / business unit / office).", fields: { name: { @@ -601,8 +601,8 @@ export const enObjects: NonNullable = { cost_center: "cost_center" } }, - parent_department_id: { - label: "Parent Department", + parent_business_unit_id: { + label: "Parent Business Unit", help: "Self-reference for the org tree. Null = root of tenant." }, organization_id: { @@ -610,7 +610,7 @@ export const enObjects: NonNullable = { help: "Tenant scope." }, manager_user_id: { - label: "Department Head", + label: "Business Unit Head", help: "User responsible for this org unit (department head / lead)." }, active: { @@ -630,7 +630,7 @@ export const enObjects: NonNullable = { help: "ID in upstream HRIS (Workday / SAP HR / 北森)." }, id: { - label: "Department ID" + label: "Business Unit ID" }, created_at: { label: "Created At" @@ -654,22 +654,22 @@ export const enObjects: NonNullable = { } } }, - sys_department_member: { - label: "Department Member", - pluralLabel: "Department Members", + sys_business_unit_member: { + label: "Business Unit Member", + pluralLabel: "Business Unit Members", description: "User assignment to a department (matrix-org friendly, effective-dated).", fields: { id: { label: "Member ID" }, - department_id: { - label: "Department" + business_unit_id: { + label: "Business Unit" }, user_id: { label: "User" }, - role_in_department: { - label: "Role in Department", + role_in_business_unit: { + label: "Role in Business Unit", help: "`lead` is the day-to-day head; `deputy` may stand in for the lead in approval routing.", options: { member: "member", diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index c8676bddff..90421bc954 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -577,7 +577,7 @@ export const esESObjects: NonNullable = { } } }, - sys_department: { + sys_business_unit: { label: "Departamento", pluralLabel: "Departamentos", description: "Nodo jerárquico de la estructura organizativa (departamento / división / unidad de negocio / oficina).", @@ -601,7 +601,7 @@ export const esESObjects: NonNullable = { cost_center: "Centro de coste" } }, - parent_department_id: { + parent_business_unit_id: { label: "Departamento principal", help: "Autorreferencia para el árbol organizativo. Null = raíz del tenant." }, @@ -654,7 +654,7 @@ export const esESObjects: NonNullable = { } } }, - sys_department_member: { + sys_business_unit_member: { label: "Miembro del departamento", pluralLabel: "Miembros del departamento", description: "Asignación de usuario a un departamento (compatible con organizaciones matriciales y con vigencia temporal).", @@ -662,13 +662,13 @@ export const esESObjects: NonNullable = { id: { label: "ID de miembro" }, - department_id: { + business_unit_id: { label: "Departamento" }, user_id: { label: "Usuario" }, - role_in_department: { + role_in_business_unit: { label: "Rol en el departamento", help: "`lead` es el responsable del día a día; `deputy` puede sustituir al responsable en el enrutamiento de aprobaciones.", options: { diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 9714cab86b..ea93b24f8d 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -577,7 +577,7 @@ export const jaJPObjects: NonNullable = { } } }, - sys_department: { + sys_business_unit: { label: "部門", pluralLabel: "部門", description: "階層的な組織ツリーノード(部門 / 事業部 / ビジネスユニット / オフィス)。", @@ -601,7 +601,7 @@ export const jaJPObjects: NonNullable = { cost_center: "コストセンター" } }, - parent_department_id: { + parent_business_unit_id: { label: "親部門", help: "組織ツリーの自己参照。null = テナントのルート。" }, @@ -654,7 +654,7 @@ export const jaJPObjects: NonNullable = { } } }, - sys_department_member: { + sys_business_unit_member: { label: "部門メンバー", pluralLabel: "部門メンバー", description: "部門へのユーザー割り当て(マトリクス組織対応、有効期限付き)。", @@ -662,13 +662,13 @@ export const jaJPObjects: NonNullable = { id: { label: "メンバー ID" }, - department_id: { + business_unit_id: { label: "部門" }, user_id: { label: "ユーザー" }, - role_in_department: { + role_in_business_unit: { label: "部門内ロール", help: "`lead` は日常の責任者、`deputy` は承認ルーティングでリードの代理を務める場合があります。", options: { diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 6398b436ae..bfe1d484e7 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -577,7 +577,7 @@ export const zhCNObjects: NonNullable = { } } }, - sys_department: { + sys_business_unit: { label: "部门", pluralLabel: "部门", description: "层级化组织骨架节点(部门 / 事业部 / 业务单元 / 办公地点)。", @@ -601,7 +601,7 @@ export const zhCNObjects: NonNullable = { cost_center: "成本中心" } }, - parent_department_id: { + parent_business_unit_id: { label: "上级部门", help: "组织树的自关联字段。Null 表示租户根节点。" }, @@ -654,7 +654,7 @@ export const zhCNObjects: NonNullable = { } } }, - sys_department_member: { + sys_business_unit_member: { label: "部门成员", pluralLabel: "部门成员", description: "用户到部门的任职关系(适配矩阵组织并支持生效时间)。", @@ -662,13 +662,13 @@ export const zhCNObjects: NonNullable = { id: { label: "成员 ID" }, - department_id: { + business_unit_id: { label: "部门" }, user_id: { label: "用户" }, - role_in_department: { + role_in_business_unit: { label: "部门内角色", help: "`lead` 表示日常负责人;`deputy` 可在审批路由中代替负责人。", options: { diff --git a/packages/platform-objects/src/identity/index.ts b/packages/platform-objects/src/identity/index.ts index f97f17dbcd..a052443012 100644 --- a/packages/platform-objects/src/identity/index.ts +++ b/packages/platform-objects/src/identity/index.ts @@ -20,8 +20,8 @@ export { SysTeam } from './sys-team.object.js'; export { SysTeamMember } from './sys-team-member.object.js'; // ── Org Skeleton (hierarchical, distinct from flat sys_team) ──────────────── -export { SysDepartment } from './sys-department.object.js'; -export { SysDepartmentMember } from './sys-department-member.object.js'; +export { SysBusinessUnit } from './sys-business-unit.object.js'; +export { SysBusinessUnitMember } from './sys-business-unit-member.object.js'; // ── Additional Auth Objects ──────────────────────────────────────────────── export { SysApiKey } from './sys-api-key.object.js'; diff --git a/packages/platform-objects/src/identity/sys-department-member.object.ts b/packages/platform-objects/src/identity/sys-business-unit-member.object.ts similarity index 69% rename from packages/platform-objects/src/identity/sys-department-member.object.ts rename to packages/platform-objects/src/identity/sys-business-unit-member.object.ts index f1390266e0..cdea1b1d52 100644 --- a/packages/platform-objects/src/identity/sys-department-member.object.ts +++ b/packages/platform-objects/src/identity/sys-business-unit-member.object.ts @@ -3,9 +3,9 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; /** - * sys_department_member — User ↔ Department Assignment + * sys_business_unit_member — User ↔ Department Assignment * - * Many-to-many between `sys_user` and `sys_department`. A user can belong + * Many-to-many between `sys_user` and `sys_business_unit`. A user can belong * to multiple departments (matrix orgs) but exactly one is marked * `is_primary` to drive the default reporting view. * @@ -14,16 +14,16 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * * @namespace sys */ -export const SysDepartmentMember = ObjectSchema.create({ - name: 'sys_department_member', - label: 'Department Member', - pluralLabel: 'Department Members', +export const SysBusinessUnitMember = ObjectSchema.create({ + name: 'sys_business_unit_member', + label: 'Business Unit Member', + pluralLabel: 'Business Unit Members', icon: 'user-cog', isSystem: true, managedBy: 'platform', - description: 'User assignment to a department (matrix-org friendly, effective-dated).', - titleFormat: '{user_id} in {department_id}', - compactLayout: ['user_id', 'department_id', 'role_in_department', 'is_primary'], + description: 'User assignment to a business unit (matrix-org friendly, effective-dated).', + titleFormat: '{user_id} in {business_unit_id}', + compactLayout: ['user_id', 'business_unit_id', 'role_in_business_unit', 'is_primary'], fields: { id: Field.text({ @@ -33,8 +33,8 @@ export const SysDepartmentMember = ObjectSchema.create({ group: 'System', }), - department_id: Field.lookup('sys_department', { - label: 'Department', + business_unit_id: Field.lookup('sys_business_unit', { + label: 'Business Unit', required: true, group: 'Assignment', }), @@ -45,10 +45,10 @@ export const SysDepartmentMember = ObjectSchema.create({ group: 'Assignment', }), - role_in_department: Field.select( + role_in_business_unit: Field.select( ['member', 'lead', 'deputy'], { - label: 'Role in Department', + label: 'Role in Business Unit', required: false, defaultValue: 'member', description: '`lead` is the day-to-day head; `deputy` may stand in for the lead in approval routing.', @@ -60,7 +60,7 @@ export const SysDepartmentMember = ObjectSchema.create({ label: 'Primary Assignment', required: false, defaultValue: true, - description: 'When the user is in multiple departments, this marks the canonical one for reporting.', + description: 'When the user is in multiple business units, this marks the canonical one for reporting.', group: 'Assignment', }), @@ -92,7 +92,7 @@ export const SysDepartmentMember = ObjectSchema.create({ }, indexes: [ - { fields: ['department_id', 'user_id'], unique: true }, + { fields: ['business_unit_id', 'user_id'], unique: true }, { fields: ['user_id'] }, { fields: ['is_primary'] }, ], diff --git a/packages/platform-objects/src/identity/sys-department.object.ts b/packages/platform-objects/src/identity/sys-business-unit.object.ts similarity index 81% rename from packages/platform-objects/src/identity/sys-department.object.ts rename to packages/platform-objects/src/identity/sys-business-unit.object.ts index c23697a8b8..d2e3404af4 100644 --- a/packages/platform-objects/src/identity/sys-department.object.ts +++ b/packages/platform-objects/src/identity/sys-business-unit.object.ts @@ -3,7 +3,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; /** - * sys_department — Enterprise Org-Skeleton Node + * sys_business_unit — Business Unit (canonical org/data-partition tree, ADR-0057 D2) * * The persistent, hierarchical org chart node. **This is distinct from * `sys_team`** (which is the flat better-auth collaboration grouping). @@ -14,31 +14,31 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * structure works identically regardless of value. * * Drives: - * - `recipient_type='department'` sharing rules - * - `dept:` approver prefix in the approval engine + * - `recipient_type='business_unit'` sharing rules + * - `bu:` approver prefix in the approval engine * - Report rollups and manager chains in CRM/PM apps * * @namespace sys */ -export const SysDepartment = ObjectSchema.create({ - name: 'sys_department', - label: 'Department', - pluralLabel: 'Departments', +export const SysBusinessUnit = ObjectSchema.create({ + name: 'sys_business_unit', + label: 'Business Unit', + pluralLabel: 'Business Units', icon: 'building', isSystem: true, managedBy: 'platform', - description: 'Hierarchical org-skeleton node (department / division / business unit / office).', + description: 'Canonical Business Unit tree — hierarchical org/data-partition node (company / division / department / region / office). ADR-0057 D2.', displayNameField: 'name', titleFormat: '{name}', - compactLayout: ['name', 'kind', 'parent_department_id', 'manager_user_id'], + compactLayout: ['name', 'kind', 'parent_business_unit_id', 'manager_user_id'], listViews: { active: { type: 'grid', name: 'active', label: 'Active', - data: { provider: 'object', object: 'sys_department' }, - columns: ['name', 'code', 'kind', 'parent_department_id', 'manager_user_id', 'effective_from'], + data: { provider: 'object', object: 'sys_business_unit' }, + columns: ['name', 'code', 'kind', 'parent_business_unit_id', 'manager_user_id', 'effective_from'], filter: [{ field: 'active', operator: 'equals', value: true }], sort: [{ field: 'name', order: 'asc' }], pagination: { pageSize: 100 }, @@ -47,7 +47,7 @@ export const SysDepartment = ObjectSchema.create({ type: 'grid', name: 'inactive', label: 'Inactive', - data: { provider: 'object', object: 'sys_department' }, + data: { provider: 'object', object: 'sys_business_unit' }, columns: ['name', 'code', 'kind', 'effective_to'], filter: [{ field: 'active', operator: 'equals', value: false }], sort: [{ field: 'effective_to', order: 'desc' }], @@ -57,8 +57,8 @@ export const SysDepartment = ObjectSchema.create({ type: 'grid', name: 'by_kind', label: 'By Kind', - data: { provider: 'object', object: 'sys_department' }, - columns: ['kind', 'name', 'code', 'parent_department_id', 'manager_user_id', 'active'], + data: { provider: 'object', object: 'sys_business_unit' }, + columns: ['kind', 'name', 'code', 'parent_business_unit_id', 'manager_user_id', 'active'], sort: [{ field: 'kind', order: 'asc' }, { field: 'name', order: 'asc' }], grouping: { fields: [{ field: 'kind', order: 'asc', collapsed: false }] }, pagination: { pageSize: 100 }, @@ -67,8 +67,8 @@ export const SysDepartment = ObjectSchema.create({ type: 'grid', name: 'all_departments', label: 'All', - data: { provider: 'object', object: 'sys_department' }, - columns: ['name', 'code', 'kind', 'parent_department_id', 'manager_user_id', 'active'], + data: { provider: 'object', object: 'sys_business_unit' }, + columns: ['name', 'code', 'kind', 'parent_business_unit_id', 'manager_user_id', 'active'], sort: [{ field: 'name', order: 'asc' }], pagination: { pageSize: 100 }, }, @@ -105,7 +105,7 @@ export const SysDepartment = ObjectSchema.create({ ), // ── Hierarchy ──────────────────────────────────────────────── - parent_department_id: Field.lookup('sys_department', { + parent_business_unit_id: Field.lookup('sys_business_unit', { label: 'Parent Department', required: false, description: 'Self-reference for the org tree. Null = root of tenant.', @@ -183,7 +183,7 @@ export const SysDepartment = ObjectSchema.create({ indexes: [ { fields: ['organization_id'] }, - { fields: ['parent_department_id'] }, + { fields: ['parent_business_unit_id'] }, { fields: ['code', 'organization_id'], unique: true }, { fields: ['active'] }, ], diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index ff625a9c02..063072ecf9 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -281,8 +281,8 @@ export class ApprovalService implements IApprovalService { * * **Graph semantics:** * - `team` → flat members of `sys_team` (better-auth; no BFS) - * - `department` → recursive BFS of `sys_department.parent_department_id` - * → members of every descendant via `sys_department_member` + * - `department` → recursive BFS of `sys_business_unit.parent_business_unit_id` + * → members of every descendant via `sys_business_unit_member` * - `role` → users with `sys_member.role = value` in tenant * - `manager` → `sys_user.manager_id` of `record[value] ?? record.owner_id` * - `field` → literal user id stored in `record[value]` @@ -299,8 +299,8 @@ export class ApprovalService implements IApprovalService { if (a.type === 'team') { const users = await this.expandTeamUsers(String(a.value)); if (users.length) { for (const u of users) out.push(u); continue; } - } else if (a.type === 'department' || a.type === 'dept') { - const users = await this.expandDepartmentUsers(String(a.value), organizationId); + } else if (a.type === 'business_unit' || a.type === 'bu') { + const users = await this.expandBusinessUnitUsers(String(a.value), organizationId); if (users.length) { for (const u of users) out.push(u); continue; } } else if (a.type === 'role') { const users = await this.expandRoleUsers(String(a.value), organizationId); @@ -333,15 +333,15 @@ export class ApprovalService implements IApprovalService { return Array.from(new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean))); } - /** Recursive department — walks `sys_department.parent_department_id`. */ - private async expandDepartmentUsers(departmentId: string, organizationId?: string | null): Promise { - if (!departmentId) return []; + /** Recursive department — walks `sys_business_unit.parent_business_unit_id`. */ + private async expandBusinessUnitUsers(businessUnitId: string, organizationId?: string | null): Promise { + if (!businessUnitId) return []; // Seed sanity check: skip if dept doesn't exist or is inactive within tenant. try { - const seed = await this.engine.find('sys_department', { + const seed = await this.engine.find('sys_business_unit', { filter: organizationId - ? { id: departmentId, organization_id: organizationId } - : { id: departmentId }, + ? { id: businessUnitId, organization_id: organizationId } + : { id: businessUnitId }, fields: ['id', 'active'], limit: 1, context: SYSTEM_CTX, @@ -350,15 +350,15 @@ export class ApprovalService implements IApprovalService { if (!seedRow || seedRow.active === false) return []; } catch { return []; } - const seen = new Set([departmentId]); - const queue: string[] = [departmentId]; + const seen = new Set([businessUnitId]); + const queue: string[] = [businessUnitId]; while (queue.length) { const parent = queue.shift()!; let kids: any[] = []; try { - const filter: any = { parent_department_id: parent, active: { $ne: false } }; + const filter: any = { parent_business_unit_id: parent, active: { $ne: false } }; if (organizationId) filter.organization_id = organizationId; - kids = await this.engine.find('sys_department', { filter, fields: ['id'], limit: 1000, context: SYSTEM_CTX } as any); + kids = await this.engine.find('sys_business_unit', { filter, fields: ['id'], limit: 1000, context: SYSTEM_CTX } as any); } catch { kids = []; } for (const k of kids ?? []) { const kid = String((k as any).id ?? ''); @@ -367,8 +367,8 @@ export class ApprovalService implements IApprovalService { } let rows: any[] = []; try { - rows = await this.engine.find('sys_department_member', { - filter: { department_id: { $in: Array.from(seen) } }, + rows = await this.engine.find('sys_business_unit_member', { + filter: { business_unit_id: { $in: Array.from(seen) } }, fields: ['user_id'], limit: 10000, context: SYSTEM_CTX, diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-roles.ts b/packages/plugins/plugin-security/src/bootstrap-declared-roles.ts new file mode 100644 index 0000000000..473f1b30b7 --- /dev/null +++ b/packages/plugins/plugin-security/src/bootstrap-declared-roles.ts @@ -0,0 +1,95 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * bootstrapDeclaredRoles — seed stack-declared `roles` into `sys_role` + * (ADR-0057 D6, closes #2077). + * + * Reads the validated `role` metadata (registered from the stack's `roles: []` + * via `metadataService.list('role')`) and idempotently upserts each into + * `sys_role` by `name`, so the runtime role→permission-set resolution + * (`resolveExecutionContext` → `sys_role` → `sys_role_permission_set`) and + * sharing-rule role recipients stop being decorative. Runs on `kernel:ready` + * alongside the platform-admin bootstrap. + * + * Pre-launch posture (ADR-0057): upsert only — no prune. Role visibility + * HIERARCHY is NOT seeded here: per ADR-0057 D5 the role is a capability + * bundle, and "manager sees subordinates" lives on the `sys_business_unit` + * tree, not `sys_role.parent`. + */ + +const SYSTEM_CTX = { isSystem: true }; + +function genId(prefix: string): string { + const rand = Math.random().toString(36).slice(2, 10); + const ts = Date.now().toString(36); + return `${prefix}_${ts}${rand}`; +} + +async function tryFind(ql: any, object: string, where: any, limit = 100): Promise { + try { + const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX }); + return Array.isArray(rows) ? rows : []; + } catch { return []; } +} +async function tryInsert(ql: any, object: string, data: any): Promise { + try { return await ql.insert(object, data, { context: SYSTEM_CTX }); } catch { return null; } +} +async function tryUpdate(ql: any, object: string, data: any): Promise { + try { await ql.update(object, data, { context: SYSTEM_CTX }); return true; } catch { return false; } +} + +interface SeedOptions { + logger?: { info: (m: string, meta?: Record) => void; warn: (m: string, meta?: Record) => void }; +} + +/** + * Read declared metadata items of a type. The engine's SchemaRegistry + * (populated by `manifest.register` from the stack's `roles`/`sharingRules` + * arrays) is the reliable source in every boot path; the metadata-service + * facade only surfaces these once the compiled-artifact loader runs (serve.ts). + */ +function readDeclared(engine: any, type: string): any[] { + try { + const reg = engine?._registry; + if (reg?.listItems) { + return (reg.listItems(type) ?? []).map((i: any) => i?.content ?? i).filter(Boolean); + } + } catch { /* fall through */ } + return []; +} + +export async function bootstrapDeclaredRoles( + ql: any, + metadataService: any, + options: SeedOptions = {}, +): Promise<{ seeded: number; updated: number }> { + if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') { + return { seeded: 0, updated: 0 }; + } + let roles: any[] = readDeclared(ql, 'role'); + if (roles.length === 0) { + try { + const listed = metadataService?.list?.('role'); + roles = typeof (listed as any)?.then === 'function' ? await listed : (listed ?? []); + } catch { roles = []; } + } + if (!Array.isArray(roles) || roles.length === 0) return { seeded: 0, updated: 0 }; + + let seeded = 0; + let updated = 0; + for (const r of roles) { + if (!r?.name) continue; + const fields = { label: r.label ?? r.name, description: r.description ?? null }; + const existing = await tryFind(ql, 'sys_role', { name: r.name }, 1); + if (existing[0]?.id) { + if (await tryUpdate(ql, 'sys_role', { id: existing[0].id, ...fields })) updated += 1; + } else { + const created = await tryInsert(ql, 'sys_role', { + id: genId('role'), name: r.name, ...fields, active: true, is_default: false, + }); + if (created) seeded += 1; + } + } + options.logger?.info?.('[security] declared roles seeded into sys_role', { seeded, updated, total: roles.length }); + return { seeded, updated }; +} diff --git a/packages/plugins/plugin-security/src/manifest.ts b/packages/plugins/plugin-security/src/manifest.ts index 8ce953d96c..fb3bebb8c7 100644 --- a/packages/plugins/plugin-security/src/manifest.ts +++ b/packages/plugins/plugin-security/src/manifest.ts @@ -13,6 +13,7 @@ import { SysRole, SysUserPermissionSet, SysRolePermissionSet, + SysUserRole, defaultPermissionSets, } from './objects/index.js'; @@ -25,6 +26,7 @@ export const securityObjects = [ SysPermissionSet, SysUserPermissionSet, SysRolePermissionSet, + SysUserRole, ]; /** Default platform permission sets (admin / member / viewer). */ diff --git a/packages/plugins/plugin-security/src/objects/index.ts b/packages/plugins/plugin-security/src/objects/index.ts index 59683e83dd..0cc4722d39 100644 --- a/packages/plugins/plugin-security/src/objects/index.ts +++ b/packages/plugins/plugin-security/src/objects/index.ts @@ -13,4 +13,5 @@ export { SysRole } from './sys-role.object.js'; export { SysPermissionSet } from './sys-permission-set.object.js'; export { SysUserPermissionSet } from './sys-user-permission-set.object.js'; export { SysRolePermissionSet } from './sys-role-permission-set.object.js'; +export { SysUserRole } from './sys-user-role.object.js'; export { defaultPermissionSets } from './default-permission-sets.js'; diff --git a/packages/plugins/plugin-security/src/objects/sys-user-role.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-role.object.ts new file mode 100644 index 0000000000..f282a0ebe5 --- /dev/null +++ b/packages/plugins/plugin-security/src/objects/sys-user-role.object.ts @@ -0,0 +1,93 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_user_role — User ↔ Role assignment (ADR-0057 D4). + * + * The platform-owned source of truth for "who holds which RBAC role", + * decoupled from better-auth's `sys_member.role` (which is reframed to + * org-administration: owner/admin/member). At request time the runtime + * resolver (`resolveExecutionContext`) reads assignments from this table + * (∪ `sys_member.role` during the transition window) into + * `ExecutionContext.roles[]`. + * + * `role` stores the role's machine name (matches `sys_role.name`), mirroring + * how `ctx.roles` is keyed everywhere downstream. `organization_id = null` + * means a cross-tenant (global) assignment. + * + * @namespace sys + */ +export const SysUserRole = ObjectSchema.create({ + name: 'sys_user_role', + label: 'User Role', + pluralLabel: 'User Roles', + icon: 'user-cog', + isSystem: true, + managedBy: 'system', + description: 'Assigns an RBAC role (sys_role.name) to a user. Platform-owned (ADR-0057 D4).', + titleFormat: '{user_id} → {role}', + compactLayout: ['user_id', 'role', 'organization_id'], + + fields: { + id: Field.text({ + label: 'Assignment ID', + required: true, + readonly: true, + description: 'UUID of the user-role assignment.', + }), + + user_id: Field.lookup('sys_user', { + label: 'User', + required: true, + description: 'Foreign key to sys_user.', + }), + + role: Field.text({ + label: 'Role', + required: true, + maxLength: 100, + description: 'RBAC role machine name (references sys_role.name).', + }), + + organization_id: Field.lookup('sys_organization', { + label: 'Organization', + required: false, + description: 'Tenant that owns this assignment; null = global (cross-tenant).', + }), + + granted_by: Field.lookup('sys_user', { + label: 'Granted By', + required: false, + description: 'User who granted this role assignment.', + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + }), + + updated_at: Field.datetime({ + label: 'Updated At', + defaultValue: 'NOW()', + readonly: true, + }), + }, + + indexes: [ + { fields: ['user_id', 'role', 'organization_id'], unique: true }, + { fields: ['user_id'] }, + { fields: ['role'] }, + { fields: ['organization_id'] }, + ], + + enable: { + trackHistory: true, + searchable: true, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'update', 'delete'], + trash: true, + mru: false, + }, +}); diff --git a/packages/plugins/plugin-security/src/permission-evaluator.ts b/packages/plugins/plugin-security/src/permission-evaluator.ts index 68ab764290..4c629eafdc 100644 --- a/packages/plugins/plugin-security/src/permission-evaluator.ts +++ b/packages/plugins/plugin-security/src/permission-evaluator.ts @@ -74,6 +74,38 @@ export class PermissionEvaluator { return false; } + + /** + * [ADR-0057 D1] Effective access DEPTH for an operation class on an object, + * merged most-permissively across the permission sets. `view/modifyAll` + * shortcut to 'org'. A granting set with no scope defaults to 'own' (the + * owner-only baseline owner-scoped objects already enforce); the WIDEST wins. + * Returns 'org' when no set grants the op (the caller denies separately, so + * the value is unused). + */ + getEffectiveScope( + opClass: 'read' | 'write', + objectName: string, + permissionSets: PermissionSet[], + ): 'own' | 'own_and_reports' | 'unit' | 'unit_and_below' | 'org' { + const RANK = { own: 0, own_and_reports: 1, unit: 2, unit_and_below: 3, org: 4 } as const; + const ORDER = ['own', 'own_and_reports', 'unit', 'unit_and_below', 'org'] as const; + let widest = -1; + let matched = false; + for (const ps of permissionSets) { + const op: any = ps.objects?.[objectName] ?? ps.objects?.['*']; + if (!op) continue; + matched = true; + if (opClass === 'read' && (op.viewAllRecords || op.modifyAllRecords)) return 'org'; + if (opClass === 'write' && op.modifyAllRecords) return 'org'; + const s = opClass === 'read' ? op.readScope : op.writeScope; + const rank = s ? RANK[s as keyof typeof RANK] : RANK.own; + if (rank > widest) widest = rank; + } + if (!matched) return 'org'; + return ORDER[widest < 0 ? 0 : widest]; + } + /** * Get the merged field permissions for an object. * Returns a map of field names to their effective permissions. diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 7aee1c1369..49fcc088db 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -3,6 +3,7 @@ import { Plugin, PluginContext } from '@objectstack/core'; import type { PermissionSet, RowLevelSecurityPolicy } from '@objectstack/spec/security'; import { PermissionEvaluator } from './permission-evaluator.js'; +import { bootstrapDeclaredRoles } from './bootstrap-declared-roles.js'; import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js'; import { FieldMasker } from './field-masker.js'; import { PermissionDeniedError } from './errors.js'; @@ -366,6 +367,19 @@ export class SecurityPlugin implements Plugin { } } + // 2.6. [ADR-0057 D1] Stash the grant's access DEPTH for this object so the + // sharing service can widen the owner-match (owner_id IN unit-set) + // while still OR-ing in shares. Owner-set expansion needs the BU graph + // (plugin-sharing), so we pass the scope STRING, not the resolved set. + if (permissionSets.length > 0) { + const sc: any = opCtx.context; + if (['find', 'findOne', 'count', 'aggregate'].includes(opCtx.operation)) { + sc.__readScope = this.permissionEvaluator.getEffectiveScope('read', opCtx.object, permissionSets); + } else if (opCtx.operation === 'update' || opCtx.operation === 'delete') { + sc.__writeScope = this.permissionEvaluator.getEffectiveScope('write', opCtx.object, permissionSets); + } + } + // 2.7. Row-level WRITE authorization (pre-image check). // // RLS is injected as a `where` filter on the read path (step 3, via @@ -575,6 +589,13 @@ export class SecurityPlugin implements Plugin { const report = await bootstrapPlatformAdmin(ql, this.bootstrapPermissionSets, { logger: ctx.logger, }); + // [ADR-0057 D6 / #2077] Seed stack-declared roles into sys_role so they + // stop being decorative (role→permission-set resolution + recipients). + try { + await bootstrapDeclaredRoles(ql, this.metadata, { logger: ctx.logger }); + } catch (e) { + ctx.logger.warn('[security] declared-role seeding failed', { error: (e as Error).message }); + } bootstrapRanOnce = true; ctx.logger.info('[security] platform bootstrap complete', report); return report; diff --git a/packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts b/packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts new file mode 100644 index 0000000000..b2f273cec7 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts @@ -0,0 +1,139 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * bootstrapDeclaredSharingRules — seed stack-declared `sharingRules` into + * `sys_sharing_rule` (ADR-0057 D6, closes #2077; reconciles #1887). + * + * The spec authoring shape (`SharingRuleSchema`: CEL `condition`, `ownedBy`, + * `sharedWith{type,value}`) diverges from the enforced runtime shape + * (`criteria_json` JSON filter + `recipient_type`/`recipient_id`). ADR-0057 D6 + * makes the RUNTIME shape canonical and translates only the directly-mappable + * fields. Parts the runtime cannot enforce statically are SKIPPED (logged + * `[experimental]`) rather than seeded as a match-all rule — silently + * over-sharing would be worse than not enforcing (ADR-0049): + * - `owner`-type rules (`ownedBy`): role membership is dynamic, no static + * `criteria_json` equivalent. + * - CEL `condition` the mini-translator can't reduce to a field equality. + * - `sharedWith.type` of `group`/`guest`: no runtime recipient mapping. + * + * Seeding upserts via `SharingRuleService.defineRule` (idempotent by name) and + * MUST run before `listRules()`/`bindRuleHooks` so the lifecycle hooks bind to + * a populated table. + */ + +import type { SharingRuleService } from './sharing-rule-service.js'; +import type { SharingRuleRecipientType, ShareAccessLevel } from '@objectstack/spec/contracts'; + +const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; + +type Logger = { info?: (m: string, meta?: any) => void; warn?: (m: string, meta?: any) => void }; + +/** Map the spec `sharedWith.type` onto a runtime recipient_type, or null. */ +function mapRecipientType(t: unknown): SharingRuleRecipientType | null { + switch (t) { + case 'user': return 'user'; + case 'role': return 'role'; + case 'role_and_subordinates': return 'role_and_subordinates'; + // ADR-0057 D5: business-unit subtree recipient. + case 'business_unit': return 'business_unit' as SharingRuleRecipientType; + case 'unit_and_subordinates': return 'unit_and_subordinates' as SharingRuleRecipientType; + default: return null; // group / guest — no runtime mapping yet + } +} + +/** + * Reduce a simple CEL predicate to a JSON FilterCondition. Handles the common + * `record.field == ` shape (string/number/bool). Anything else (AND/OR, + * comparisons, `current_user.*`, functions) returns null → caller skips. + */ +export function celToFilter(cel: unknown): Record | null { + // The spec coerces `condition` to an ExpressionInput object + // ({ dialect: 'cel', source: '...' }); accept that or a raw string. + let src: string | null = null; + if (typeof cel === 'string') src = cel; + else if (cel && typeof cel === 'object' && typeof (cel as any).source === 'string') src = (cel as any).source; + if (!src) return null; + const m = src.trim().match(/^record\.([A-Za-z_][A-Za-z0-9_]*)\s*==\s*(.+)$/); + if (!m) return null; + const field = m[1]; + const raw = m[2].trim(); + let val: unknown; + if ((raw.startsWith("'") && raw.endsWith("'")) || (raw.startsWith('"') && raw.endsWith('"'))) { + val = raw.slice(1, -1); + } else if (raw === 'true') val = true; + else if (raw === 'false') val = false; + else if (/^-?\d+(\.\d+)?$/.test(raw)) val = Number(raw); + else return null; // references / non-literal → not statically translatable + return { [field]: val }; +} + +function readDeclared(engine: any, type: string): any[] { + try { + const reg = engine?._registry; + if (reg?.listItems) { + return (reg.listItems(type) ?? []).map((i: any) => i?.content ?? i).filter(Boolean); + } + } catch { /* fall through */ } + return []; +} + +export async function bootstrapDeclaredSharingRules( + ruleService: SharingRuleService, + metadataService: any, + engine: any, + logger?: Logger, +): Promise<{ seeded: number; skipped: number }> { + let rules: any[] = readDeclared(engine, 'sharing_rule'); + if (rules.length === 0) { + try { + const listed = metadataService?.list?.('sharing_rule'); + rules = typeof (listed as any)?.then === 'function' ? await listed : (listed ?? []); + } catch { rules = []; } + } + if (!Array.isArray(rules) || rules.length === 0) return { seeded: 0, skipped: 0 }; + + let seeded = 0; + let skipped = 0; + for (const r of rules) { + if (!r?.name || !r?.object) { skipped += 1; continue; } + const recipientType = mapRecipientType(r.sharedWith?.type); + if (!recipientType || !r.sharedWith?.value) { + logger?.warn?.('[sharing-rule] skipped (unmappable recipient) [experimental]', { rule: r.name, sharedWith: r.sharedWith?.type }); + skipped += 1; continue; + } + // owner-type rules have no static criteria_json equivalent. + if (r.type === 'owner') { + logger?.warn?.('[sharing-rule] skipped owner-based rule (no static criteria) [experimental]', { rule: r.name }); + skipped += 1; continue; + } + // criteria rules: translate CEL → filter. Empty condition = match-all (intentional). + let criteria: Record | undefined; + if (r.condition != null && String(r.condition).trim() !== '') { + const f = celToFilter(r.condition); + if (!f) { + logger?.warn?.('[sharing-rule] skipped (untranslatable CEL condition) [experimental]', { rule: r.name, condition: r.condition }); + skipped += 1; continue; + } + criteria = f; + } + try { + await ruleService.defineRule({ + name: r.name, + label: r.label ?? r.name, + description: r.description ?? undefined, + object: r.object, + criteria, + recipientType, + recipientId: String(r.sharedWith.value), + accessLevel: (r.accessLevel ?? 'read') as ShareAccessLevel, + active: r.active !== false, + } as any, SYSTEM_CTX as any); + seeded += 1; + } catch (err: any) { + logger?.warn?.('[sharing-rule] seed failed', { rule: r.name, error: err?.message }); + skipped += 1; + } + } + logger?.info?.('[sharing-rule] declared rules seeded into sys_sharing_rule', { seeded, skipped, total: rules.length }); + return { seeded, skipped }; +} diff --git a/packages/plugins/plugin-sharing/src/department-graph.ts b/packages/plugins/plugin-sharing/src/business-unit-graph.ts similarity index 67% rename from packages/plugins/plugin-sharing/src/department-graph.ts rename to packages/plugins/plugin-sharing/src/business-unit-graph.ts index 9b72405eae..21f1c0a47b 100644 --- a/packages/plugins/plugin-sharing/src/department-graph.ts +++ b/packages/plugins/plugin-sharing/src/business-unit-graph.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { IDepartmentGraphService } from '@objectstack/spec/contracts'; +import type { IBusinessUnitGraphService } from '@objectstack/spec/contracts'; import type { SharingEngine } from './sharing-service.js'; import { TeamGraphService } from './team-graph.js'; @@ -12,7 +12,7 @@ type DeptCache = { head?: Map; }; -export interface DepartmentGraphOptions { +export interface BusinessUnitGraphOptions { engine: SharingEngine; /** Optional tenant scope; null means cross-tenant lookups. */ organizationId?: string | null; @@ -27,10 +27,10 @@ export interface DepartmentGraphOptions { } /** - * Default {@link IDepartmentGraphService} implementation. + * Default {@link IBusinessUnitGraphService} implementation. * - * Walks `sys_department.parent_department_id` for hierarchy and - * `sys_department_member` for member expansion. Treats the optional + * Walks `sys_business_unit.parent_business_unit_id` for hierarchy and + * `sys_business_unit_member` for member expansion. Treats the optional * `active` flag as a hard filter (inactive departments contribute no * members and stop BFS descent into their subtrees). * @@ -38,13 +38,13 @@ export interface DepartmentGraphOptions { * lookup so callers can use this single service in approval / sharing * pipelines. */ -export class DepartmentGraphService implements IDepartmentGraphService { +export class BusinessUnitGraphService implements IBusinessUnitGraphService { private readonly engine: SharingEngine; private readonly organizationId: string | null; private readonly cache: DeptCache; private readonly teamGraph?: TeamGraphService; - constructor(opts: DepartmentGraphOptions) { + constructor(opts: BusinessUnitGraphOptions) { this.engine = opts.engine; this.organizationId = opts.organizationId ?? null; this.cache = opts.cache ?? {}; @@ -54,16 +54,16 @@ export class DepartmentGraphService implements IDepartmentGraphService { this.teamGraph = opts.teamGraph; } - async descendants(departmentId: string): Promise { - if (!departmentId) return []; - const cached = this.cache.descendants!.get(departmentId); + async descendants(businessUnitId: string): Promise { + if (!businessUnitId) return []; + const cached = this.cache.descendants!.get(businessUnitId); if (cached) return cached; // Verify seed itself is active + within tenant scope. let seedActive = true; try { - const seedRows = await this.engine.find('sys_department', { - filter: this.orgScope({ id: departmentId }), + const seedRows = await this.engine.find('sys_business_unit', { + where: this.orgScope({ id: businessUnitId }), fields: ['id', 'active'], limit: 1, context: SYSTEM_CTX, @@ -75,18 +75,18 @@ export class DepartmentGraphService implements IDepartmentGraphService { seedActive = false; } if (!seedActive) { - this.cache.descendants!.set(departmentId, []); + this.cache.descendants!.set(businessUnitId, []); return []; } - const seen = new Set([departmentId]); - const queue: string[] = [departmentId]; + const seen = new Set([businessUnitId]); + const queue: string[] = [businessUnitId]; while (queue.length) { const parent = queue.shift()!; let children: any[] = []; try { - children = await this.engine.find('sys_department', { - filter: this.orgScope({ parent_department_id: parent, active: { $ne: false } }), + children = await this.engine.find('sys_business_unit', { + where: this.orgScope({ parent_business_unit_id: parent, active: { $ne: false } }), fields: ['id'], limit: 1000, context: SYSTEM_CTX, @@ -103,22 +103,22 @@ export class DepartmentGraphService implements IDepartmentGraphService { } } const out = Array.from(seen); - this.cache.descendants!.set(departmentId, out); + this.cache.descendants!.set(businessUnitId, out); return out; } - async expandUsers(departmentId: string): Promise { - if (!departmentId) return []; - const cached = this.cache.expandUsers!.get(departmentId); + async expandUsers(businessUnitId: string): Promise { + if (!businessUnitId) return []; + const cached = this.cache.expandUsers!.get(businessUnitId); if (cached) return cached; - const depts = await this.descendants(departmentId); - if (depts.length === 0) return []; + const units = await this.descendants(businessUnitId); + if (units.length === 0) return []; let rows: any[] = []; try { - rows = await this.engine.find('sys_department_member', { - filter: { department_id: { $in: depts } }, + rows = await this.engine.find('sys_business_unit_member', { + where: { business_unit_id: { $in: units } }, fields: ['user_id'], limit: 10000, context: SYSTEM_CTX, @@ -129,17 +129,17 @@ export class DepartmentGraphService implements IDepartmentGraphService { const users = Array.from( new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean)), ); - this.cache.expandUsers!.set(departmentId, users); + this.cache.expandUsers!.set(businessUnitId, users); return users; } - async headOf(departmentId: string): Promise { - if (!departmentId) return null; - if (this.cache.head!.has(departmentId)) return this.cache.head!.get(departmentId) ?? null; + async headOf(businessUnitId: string): Promise { + if (!businessUnitId) return null; + if (this.cache.head!.has(businessUnitId)) return this.cache.head!.get(businessUnitId) ?? null; let row: any = null; try { - const rows = await this.engine.find('sys_department', { - filter: { id: departmentId }, + const rows = await this.engine.find('sys_business_unit', { + where: { id: businessUnitId }, fields: ['id', 'manager_user_id'], limit: 1, context: SYSTEM_CTX, @@ -149,7 +149,7 @@ export class DepartmentGraphService implements IDepartmentGraphService { row = null; } const head = row?.manager_user_id ? String(row.manager_user_id) : null; - this.cache.head!.set(departmentId, head); + this.cache.head!.set(businessUnitId, head); return head; } @@ -159,7 +159,7 @@ export class DepartmentGraphService implements IDepartmentGraphService { if (!userId) return null; try { const rows = await this.engine.find('sys_user', { - filter: { id: userId }, + where: { id: userId }, fields: ['id', 'manager_id'], limit: 1, context: SYSTEM_CTX, diff --git a/packages/plugins/plugin-sharing/src/index.ts b/packages/plugins/plugin-sharing/src/index.ts index e3a818d470..fa6d559dee 100644 --- a/packages/plugins/plugin-sharing/src/index.ts +++ b/packages/plugins/plugin-sharing/src/index.ts @@ -10,7 +10,7 @@ */ export { SysRecordShare, SysSharingRule, SysShareLink } from './objects/index.js'; -export { SysDepartment, SysDepartmentMember } from '@objectstack/platform-objects/identity'; +export { SysBusinessUnit, SysBusinessUnitMember } from '@objectstack/platform-objects/identity'; export { SharingService, type SharingEngine, @@ -29,7 +29,7 @@ export { type ShareLinkRoutesOptions, } from './share-link-routes.js'; export { TeamGraphService, expandPrincipal, type TeamGraphOptions } from './team-graph.js'; -export { DepartmentGraphService, type DepartmentGraphOptions } from './department-graph.js'; +export { BusinessUnitGraphService, type BusinessUnitGraphOptions } from './business-unit-graph.js'; export { bindRuleHooks, unbindAllRuleHooks, SHARING_RULE_HOOK_PACKAGE } from './rule-hooks.js'; export { SharingServicePlugin, @@ -40,7 +40,7 @@ export type { ISharingService, ISharingRuleService, ITeamGraphService, - IDepartmentGraphService, + IBusinessUnitGraphService, RecordShare, GrantShareInput, SharingExecutionContext, diff --git a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts index 81e8d4a788..efe86be085 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts @@ -131,12 +131,12 @@ export const SysSharingRule = ObjectSchema.create({ }), recipient_type: Field.select( - ['user', 'team', 'department', 'role', 'role_and_subordinates', 'queue'], + ['user', 'team', 'business_unit', 'role', 'role_and_subordinates', 'queue'], { label: 'Recipient Type', required: true, - defaultValue: 'department', - description: 'Kind of principal that receives access — expanded to user grants at evaluation time. `department` walks the parent_department_id tree; `team` is flat (better-auth); `role` is the role\'s direct members; `role_and_subordinates` walks the sys_role.parent hierarchy to also include every subordinate role (ADR-0056 D6).', + defaultValue: 'business_unit', + description: 'Kind of principal that receives access — expanded to user grants at evaluation time. `department` walks the parent_business_unit_id tree; `team` is flat (better-auth); `role` is the role\'s direct members; `role_and_subordinates` walks the sys_role.parent hierarchy to also include every subordinate role (ADR-0056 D6).', group: 'Recipient', }, ), diff --git a/packages/plugins/plugin-sharing/src/sharing-plugin.ts b/packages/plugins/plugin-sharing/src/sharing-plugin.ts index cd0d403170..435b4d6c9c 100644 --- a/packages/plugins/plugin-sharing/src/sharing-plugin.ts +++ b/packages/plugins/plugin-sharing/src/sharing-plugin.ts @@ -4,12 +4,13 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import type { EngineMiddleware, OperationContext } from '@objectstack/objectql'; import type { IHttpServer } from '@objectstack/spec/contracts'; import { SysRecordShare, SysSharingRule, SysShareLink } from './objects/index.js'; -import { SysDepartment, SysDepartmentMember } from '@objectstack/platform-objects/identity'; +import { SysBusinessUnit, SysBusinessUnitMember } from '@objectstack/platform-objects/identity'; import { SharingService, type SharingEngine } from './sharing-service.js'; import { SharingRuleService } from './sharing-rule-service.js'; import { ShareLinkService } from './share-link-service.js'; import { registerShareLinkRoutes } from './share-link-routes.js'; import { bindRuleHooks, unbindAllRuleHooks } from './rule-hooks.js'; +import { bootstrapDeclaredSharingRules } from './bootstrap-declared-sharing-rules.js'; export interface SharingPluginOptions { /** Extra object names that bypass sharing entirely. */ @@ -88,7 +89,7 @@ export class SharingServicePlugin implements Plugin { scope: 'system', defaultDatasource: 'cloud', namespace: 'sys', - objects: [SysRecordShare, SysSharingRule, SysDepartment, SysDepartmentMember, SysShareLink], + objects: [SysRecordShare, SysSharingRule, SysBusinessUnit, SysBusinessUnitMember, SysShareLink], // ADR-0029 D7 — contribute the sharing entries into the Setup app's // `group_access_control` slot (priority 200 so they sit after plugin- // security's Roles / Permission Sets). This plugin owns these objects (K2). @@ -136,6 +137,12 @@ export class SharingServicePlugin implements Plugin { this.service = new SharingService({ engine: engine as SharingEngine, bypassObjects: this.options.bypassObjects, + // [ADR-0057] Late-bound lookup of the enterprise hierarchy resolver. + // Open edition: not registered → hierarchy scopes fail closed to own. + hierarchyResolver: () => { + try { return ctx.getService('hierarchy-scope-resolver'); } + catch { return null; } + }, }); ctx.registerService('sharing', this.service); @@ -164,6 +171,19 @@ export class SharingServicePlugin implements Plugin { }); ctx.registerService('sharingRules', this.ruleService); + // [ADR-0057 D6 / #2077] Seed stack-declared sharingRules into + // sys_sharing_rule BEFORE listRules so the lifecycle hooks bind to a + // populated table (previously rules were decorative — ruleCount: 0). + try { + let metadataService: any = null; + try { metadataService = ctx.getService('metadata'); } catch { /* optional */ } + if (metadataService) { + await bootstrapDeclaredSharingRules(this.ruleService, metadataService, engine, ctx.logger as any); + } + } catch (err: any) { + ctx.logger.warn('SharingServicePlugin: sharing-rule seeding failed', { error: err?.message }); + } + if (typeof engine.registerHook === 'function' && typeof engine.unregisterHooksByPackage === 'function') { const rules = await this.ruleService.listRules({ activeOnly: true }, { isSystem: true } as any); unbindAllRuleHooks(engine); diff --git a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts index 26ee7b680f..e39f88c305 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts @@ -13,7 +13,7 @@ import type { SharingEngine } from './sharing-service.js'; import type { SharingService } from './sharing-service.js'; import { TeamGraphService } from './team-graph.js'; import { RoleGraphService } from './role-graph.js'; -import { DepartmentGraphService } from './department-graph.js'; +import { BusinessUnitGraphService } from './business-unit-graph.js'; const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; @@ -258,8 +258,8 @@ export class SharingRuleService implements ISharingRuleService { }); if (rule.recipient_type === 'user') return [rule.recipient_id]; if (rule.recipient_type === 'team') return team.expandUsers(rule.recipient_id); - if (rule.recipient_type === 'department') { - const dept = new DepartmentGraphService({ + if (rule.recipient_type === 'business_unit') { + const dept = new BusinessUnitGraphService({ engine: this.engine, organizationId: rule.organization_id ?? null, teamGraph: team, diff --git a/packages/plugins/plugin-sharing/src/sharing-rule.test.ts b/packages/plugins/plugin-sharing/src/sharing-rule.test.ts index 9ed9a2b8cb..af0b7d8aff 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule.test.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { SharingService } from './sharing-service.js'; import { SharingRuleService } from './sharing-rule-service.js'; import { TeamGraphService, expandPrincipal } from './team-graph.js'; -import { DepartmentGraphService } from './department-graph.js'; +import { BusinessUnitGraphService } from './business-unit-graph.js'; interface Row { [k: string]: any } @@ -108,64 +108,64 @@ describe('TeamGraphService (flat — better-auth sys_team)', () => { expect((await expandPrincipal({ type: 'role', value: 'sales_manager' }, { team: t, organizationId: 'org1' })).sort()).toEqual(['alice']); expect(await expandPrincipal({ type: 'manager', value: 'owner_id', record: { owner_id: 'alice' } }, { team: t, organizationId: 'org1' })).toEqual(['bob']); expect(await expandPrincipal({ type: 'queue', value: 'q1' }, { team: t, organizationId: 'org1' })).toEqual(['queue:q1']); - // department without a dept graph instance falls back to literal - expect(await expandPrincipal({ type: 'department', value: 'emea' }, { team: t, organizationId: 'org1' })).toEqual(['department:emea']); + // business_unit without a graph instance falls back to literal + expect(await expandPrincipal({ type: 'business_unit', value: 'emea' }, { team: t, organizationId: 'org1' })).toEqual(['business_unit:emea']); }); }); -describe('DepartmentGraphService (recursive sys_department)', () => { +describe('BusinessUnitGraphService (recursive sys_business_unit)', () => { let engine: ReturnType; beforeEach(() => { engine = makeEngine(); // Hierarchy: emea → emea_sales → emea_sales_uk ; emea_marketing - engine._tables.sys_department = [ - { id: 'emea', name: 'EMEA', parent_department_id: null, organization_id: 'org1', active: true }, - { id: 'emea_sales', name: 'EMEA Sales', parent_department_id: 'emea', organization_id: 'org1', active: true }, - { id: 'emea_sales_uk', name: 'EMEA Sales UK', parent_department_id: 'emea_sales', organization_id: 'org1', active: true }, - { id: 'emea_marketing', name: 'EMEA Marketing', parent_department_id: 'emea', organization_id: 'org1', active: true }, + engine._tables.sys_business_unit = [ + { id: 'emea', name: 'EMEA', parent_business_unit_id: null, organization_id: 'org1', active: true }, + { id: 'emea_sales', name: 'EMEA Sales', parent_business_unit_id: 'emea', organization_id: 'org1', active: true }, + { id: 'emea_sales_uk', name: 'EMEA Sales UK', parent_business_unit_id: 'emea_sales', organization_id: 'org1', active: true }, + { id: 'emea_marketing', name: 'EMEA Marketing', parent_business_unit_id: 'emea', organization_id: 'org1', active: true }, // Inactive subtree — must not contribute - { id: 'emea_legacy', name: 'EMEA Legacy', parent_department_id: 'emea', organization_id: 'org1', active: false }, + { id: 'emea_legacy', name: 'EMEA Legacy', parent_business_unit_id: 'emea', organization_id: 'org1', active: false }, // Foreign tenant — must not leak - { id: 'foreign', name: 'Foreign', parent_department_id: 'emea', organization_id: 'org2', active: true }, + { id: 'foreign', name: 'Foreign', parent_business_unit_id: 'emea', organization_id: 'org2', active: true }, ]; - engine._tables.sys_department_member = [ - { id: 'dm1', department_id: 'emea_sales_uk', user_id: 'alice' }, - { id: 'dm2', department_id: 'emea_sales', user_id: 'bob' }, - { id: 'dm3', department_id: 'emea_marketing', user_id: 'carol' }, - { id: 'dm4', department_id: 'emea_legacy', user_id: 'ghost' }, + engine._tables.sys_business_unit_member = [ + { id: 'dm1', business_unit_id: 'emea_sales_uk', user_id: 'alice' }, + { id: 'dm2', business_unit_id: 'emea_sales', user_id: 'bob' }, + { id: 'dm3', business_unit_id: 'emea_marketing', user_id: 'carol' }, + { id: 'dm4', business_unit_id: 'emea_legacy', user_id: 'ghost' }, ]; }); it('descendants walks the active hierarchy', async () => { - const d = new DepartmentGraphService({ engine: engine as any, organizationId: 'org1' }); + const d = new BusinessUnitGraphService({ engine: engine as any, organizationId: 'org1' }); expect((await d.descendants('emea')).sort()).toEqual(['emea', 'emea_marketing', 'emea_sales', 'emea_sales_uk']); }); - it('expandUsers returns members of all descendant departments', async () => { - const d = new DepartmentGraphService({ engine: engine as any, organizationId: 'org1' }); + it('expandUsers returns members of all descendant business units', async () => { + const d = new BusinessUnitGraphService({ engine: engine as any, organizationId: 'org1' }); expect((await d.expandUsers('emea')).sort()).toEqual(['alice', 'bob', 'carol']); }); it('expandUsers of leaf returns just leaf members', async () => { - const d = new DepartmentGraphService({ engine: engine as any, organizationId: 'org1' }); + const d = new BusinessUnitGraphService({ engine: engine as any, organizationId: 'org1' }); expect(await d.expandUsers('emea_sales_uk')).toEqual(['alice']); }); it('inactive subtree contributes no members', async () => { - const d = new DepartmentGraphService({ engine: engine as any, organizationId: 'org1' }); + const d = new BusinessUnitGraphService({ engine: engine as any, organizationId: 'org1' }); expect(await d.expandUsers('emea_legacy')).toEqual([]); }); it('cross-tenant lookup is blocked by org scope', async () => { - const d = new DepartmentGraphService({ engine: engine as any, organizationId: 'org1' }); + const d = new BusinessUnitGraphService({ engine: engine as any, organizationId: 'org1' }); // 'foreign' is in org2 — descendants from emea should not include it const desc = await d.descendants('emea'); expect(desc).not.toContain('foreign'); }); it('headOf returns manager_user_id (when set)', async () => { - engine._tables.sys_department[1].manager_user_id = 'alice'; - const d = new DepartmentGraphService({ engine: engine as any, organizationId: 'org1' }); + engine._tables.sys_business_unit[1].manager_user_id = 'alice'; + const d = new BusinessUnitGraphService({ engine: engine as any, organizationId: 'org1' }); expect(await d.headOf('emea_sales')).toEqual('alice'); expect(await d.headOf('emea_marketing')).toBeNull(); }); @@ -193,13 +193,13 @@ describe('SharingRuleService', () => { { id: 'tm2', team_id: 'sales', user_id: 'bob' }, ]; // Department hierarchy: emea_sales (Alice) → emea_sales_uk (Bob) - engine._tables.sys_department = [ - { id: 'emea_sales', name: 'EMEA Sales', parent_department_id: null, organization_id: 'org1', active: true }, - { id: 'emea_sales_uk', name: 'EMEA Sales UK', parent_department_id: 'emea_sales', organization_id: 'org1', active: true }, + engine._tables.sys_business_unit = [ + { id: 'emea_sales', name: 'EMEA Sales', parent_business_unit_id: null, organization_id: 'org1', active: true }, + { id: 'emea_sales_uk', name: 'EMEA Sales UK', parent_business_unit_id: 'emea_sales', organization_id: 'org1', active: true }, ]; - engine._tables.sys_department_member = [ - { id: 'dm1', department_id: 'emea_sales', user_id: 'alice' }, - { id: 'dm2', department_id: 'emea_sales_uk', user_id: 'bob' }, + engine._tables.sys_business_unit_member = [ + { id: 'dm1', business_unit_id: 'emea_sales', user_id: 'alice' }, + { id: 'dm2', business_unit_id: 'emea_sales_uk', user_id: 'bob' }, ]; sharing = new SharingService({ engine: engine as any }); rules = new SharingRuleService({ engine: engine as any, sharing }); @@ -332,11 +332,11 @@ describe('SharingRuleService', () => { expect(active.map(r => r.name)).toEqual(['a']); }); - it('recipientType=department expands via the dept graph (BFS)', async () => { + it('recipientType=business_unit expands via the BU graph (BFS)', async () => { const r = await rules.defineRule({ name: 'dept_rule', label: 'Dept Rule', object: 'opportunity', criteria: { amount: { $gte: 100000 } }, - recipientType: 'department', recipientId: 'emea_sales', accessLevel: 'read', + recipientType: 'business_unit', recipientId: 'emea_sales', accessLevel: 'read', }, SYS); const res = await rules.evaluateRule(r.id, SYS); expect(res.matchedRecords).toBe(2); // opp1, opp2 diff --git a/packages/plugins/plugin-sharing/src/sharing-service.ts b/packages/plugins/plugin-sharing/src/sharing-service.ts index dec4714bc7..dfe8d1e2ec 100644 --- a/packages/plugins/plugin-sharing/src/sharing-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-service.ts @@ -2,6 +2,7 @@ import type { ISharingService, + IHierarchyScopeResolver, RecordShare, GrantShareInput, SharingExecutionContext, @@ -71,6 +72,11 @@ export interface SharingServiceOptions { engine: SharingEngine; /** Object names that bypass sharing — typically platform internals. */ bypassObjects?: string[]; + /** + * [ADR-0057] Late-bound lookup for the enterprise hierarchy-scope resolver + * (`hierarchy-scope-resolver` service). Returns null in the open edition. + */ + hierarchyResolver?: () => IHierarchyScopeResolver | null | undefined; } /** @@ -84,9 +90,11 @@ export interface SharingServiceOptions { export class SharingService implements ISharingService { private readonly engine: SharingEngine; private readonly bypassObjects: Set; + private readonly hierarchyResolver?: () => IHierarchyScopeResolver | null | undefined; constructor(options: SharingServiceOptions) { this.engine = options.engine; + this.hierarchyResolver = options.hierarchyResolver; this.bypassObjects = new Set([ 'sys_record_share', 'sys_user', @@ -121,8 +129,18 @@ export class SharingService implements ISharingService { return { id: '__deny_all__' }; } + // [ADR-0057 D1] Access DEPTH widens the owner-match for this grant: + // own → [me], unit → my BU members, unit_and_below → my BU subtree, org → + // no owner filter. Sharing grants are still OR-ed in on top (additive). + const readScope = (context as any).__readScope as ('own' | 'own_and_reports' | 'unit' | 'unit_and_below' | 'org' | undefined); + if (readScope === 'org') return null; + const ownerIds = await this.resolveOwnerScopeIds(context, readScope); + const ownerMatch: Record = ownerIds.length === 1 + ? { [OWNER_FIELD]: ownerIds[0] } + : { [OWNER_FIELD]: { $in: ownerIds } }; + const grants = await this.engine.find('sys_record_share', { - filter: { + where: { object_name: object, recipient_type: 'user', recipient_id: context.userId, @@ -137,12 +155,12 @@ export class SharingService implements ISharingService { : []; if (grantedIds.length === 0) { - return { [OWNER_FIELD]: context.userId }; + return ownerMatch; } return { $or: [ - { [OWNER_FIELD]: context.userId }, + ownerMatch, { id: { $in: grantedIds } }, ], }; @@ -167,19 +185,24 @@ export class SharingService implements ISharingService { if (!hasOwnerField(schema)) return true; if (!context.userId) return false; - // 1) Ownership — fast path. + // 1) Ownership (write DEPTH widens the owner-set) — fast path. const own = await this.engine.find(object, { - filter: { id: recordId }, + where: { id: recordId }, fields: ['id', OWNER_FIELD], limit: 1, context: SYSTEM_CTX, }); const owner = Array.isArray(own) && own[0] ? (own[0] as any)[OWNER_FIELD] : undefined; - if (owner && String(owner) === String(context.userId)) return true; + if (owner != null) { + const writeScope = (context as any).__writeScope as ('own' | 'own_and_reports' | 'unit' | 'unit_and_below' | 'org' | undefined); + if (writeScope === 'org') return true; + const owners = await this.resolveOwnerScopeIds(context, writeScope); + if (owners.includes(String(owner))) return true; + } // 2) Explicit edit / full share. const editGrants = await this.engine.find('sys_record_share', { - filter: { + where: { object_name: object, record_id: recordId, recipient_type: 'user', @@ -212,7 +235,7 @@ export class SharingService implements ISharingService { // Upsert: if a row with same (object, record, recipient) exists, // update its access level / reason; otherwise insert a new one. const existing = await this.engine.find('sys_record_share', { - filter: { + where: { object_name: input.object, record_id: input.recordId, recipient_type: recipientType, @@ -271,7 +294,7 @@ export class SharingService implements ISharingService { _context: SharingExecutionContext, ): Promise { const rows = await this.engine.find('sys_record_share', { - filter: { object_name: object, record_id: recordId }, + where: { object_name: object, record_id: recordId }, orderBy: [{ field: 'created_at', order: 'desc' }], limit: 500, context: SYSTEM_CTX, @@ -281,6 +304,38 @@ export class SharingService implements ISharingService { // ── helpers ────────────────────────────────────────────────────── + /** + * [ADR-0057] Resolve the owner-id set for a DEPTH scope. `own`/unset/`org` + * resolve locally to the caller. HIERARCHY scopes (`unit` / `unit_and_below` + * / `own_and_reports`) are an ENTERPRISE capability resolved by a pluggable + * {@link IHierarchyScopeResolver} (`hierarchy-scope-resolver` service, shipped + * only by `@objectstack/security-enterprise`). The open edition has none, so + * this fails CLOSED to owner-only — a hierarchy scope NEVER widens without the + * enterprise resolver (the spec gate also refuses to compile such a grant). + */ + private async resolveOwnerScopeIds( + context: SharingExecutionContext, + scope: 'own' | 'own_and_reports' | 'unit' | 'unit_and_below' | 'org' | undefined, + ): Promise { + const me = String((context as any).userId); + if (!scope || scope === 'own' || scope === 'org') return [me]; + const resolver = this.hierarchyResolver?.(); + if (!resolver) return [me]; + try { + const ids = await resolver.resolveOwnerIds( + { + userId: me, + organizationId: (context as any).organizationId ?? null, + tenantId: (context as any).tenantId ?? null, + }, + scope, + ); + return Array.isArray(ids) && ids.length > 0 ? ids : [me]; + } catch { + return [me]; + } + } + private shouldBypass(object: string, context: SharingExecutionContext): boolean { if (context?.isSystem) return true; if (this.bypassObjects.has(object)) return true; diff --git a/packages/plugins/plugin-sharing/src/team-graph.ts b/packages/plugins/plugin-sharing/src/team-graph.ts index 56877c7e7a..8552c264b5 100644 --- a/packages/plugins/plugin-sharing/src/team-graph.ts +++ b/packages/plugins/plugin-sharing/src/team-graph.ts @@ -25,8 +25,8 @@ export interface TeamGraphOptions { * grouping) plus `sys_member.role` for tenant role expansion. * * **This service does NOT walk a hierarchy.** Teams here are flat — - * the enterprise org chart lives in `sys_department` and is served by - * {@link DepartmentGraphService}. + * the enterprise org chart lives in `sys_business_unit` and is served by + * {@link BusinessUnitGraphService}. * * All queries elevate to {@link SYSTEM_CTX} since the graph is platform * metadata; callers (sharing rule evaluator, approval engine) own their @@ -120,7 +120,7 @@ export class TeamGraphService implements ITeamGraphService { * * `team` → flat team members (this service). * `department` → recursive department members (delegated; requires a - * {@link IDepartmentGraphService} instance passed in `opts.dept`). + * {@link IBusinessUnitGraphService} instance passed in `opts.dept`). * `role` → tenant role members. * `manager` → submitter's manager via `record[value] ?? record.owner_id`. * `field` → literal user id stored in `record[value]`. @@ -137,7 +137,7 @@ export async function expandPrincipal( if (!v) return []; if (t === 'user') return [v]; if (t === 'team') return ctx.team.expandUsers(v); - if (t === 'department' || t === 'dept') { + if (t === 'business_unit' || t === 'bu') { if (ctx.dept) return ctx.dept.expandUsers(v); return [`${t}:${v}`]; } diff --git a/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts index dfab4c2a66..b733efe49b 100644 --- a/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts @@ -131,7 +131,7 @@ export const enObjects: NonNullable = { }, recipient_type: { label: "Recipient Type", - help: "Kind of principal that receives access — expanded to user grants at evaluation time. `department` walks the parent_department_id tree; `team` is flat (better-auth).", + help: "Kind of principal that receives access — expanded to user grants at evaluation time. `department` walks the parent_business_unit_id tree; `team` is flat (better-auth).", options: { user: "user", team: "team", diff --git a/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts index 3c63418ea0..6806f98771 100644 --- a/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts @@ -131,7 +131,7 @@ export const esESObjects: NonNullable = { }, recipient_type: { label: "Tipo de destinatario", - help: "Tipo de principal que recibe acceso; se expande a concesiones de usuario durante la evaluación. `department` recorre el árbol parent_department_id; `team` es plano (better-auth).", + help: "Tipo de principal que recibe acceso; se expande a concesiones de usuario durante la evaluación. `department` recorre el árbol parent_business_unit_id; `team` es plano (better-auth).", options: { user: "Usuario", team: "Equipo", diff --git a/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts index 6320ccc615..9df0ee3c76 100644 --- a/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts @@ -131,7 +131,7 @@ export const jaJPObjects: NonNullable = { }, recipient_type: { label: "受信者タイプ", - help: "アクセスを受け取るプリンシパルの種別 — 評価時にユーザー付与に展開されます。`department` は parent_department_id ツリーをたどります。`team` はフラット(better-auth)。", + help: "アクセスを受け取るプリンシパルの種別 — 評価時にユーザー付与に展開されます。`department` は parent_business_unit_id ツリーをたどります。`team` はフラット(better-auth)。", options: { user: "ユーザー", team: "チーム", diff --git a/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts index 5b26583346..e4ae853514 100644 --- a/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts @@ -131,7 +131,7 @@ export const zhCNObjects: NonNullable = { }, recipient_type: { label: "接收方类型", - help: "接收访问权限的主体类型——求值时会展开为用户授权。`department` 会沿 parent_department_id 树展开;`team` 为扁平结构(better-auth)。", + help: "接收访问权限的主体类型——求值时会展开为用户授权。`department` 会沿 parent_business_unit_id 树展开;`team` 为扁平结构(better-auth)。", options: { user: "用户", team: "团队", diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index d39a0808bf..34820e1bd8 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -240,6 +240,47 @@ export class AppPlugin implements Plugin { }); } + // [ADR-0057 / #2077] Surface stack-declared SECURITY metadata (roles, + // permission sets, sharing rules, policies) in the metadata registry so + // the boot seeders (plugin-security / plugin-sharing) and runtime + // resolvers can read them via `list('role'|'permission'|'sharing_rule')`. + // Without this, bootStack's metadata service holds only objects (the + // artifact loader that registers these runs only in compiled serve.ts), + // leaving the declarations decorative. + try { + const metadata = ctx.getService('metadata') as + | { registerInMemory?: (t: string, n: string, d: unknown) => void } + | undefined; + if (typeof metadata?.registerInMemory === 'function') { + const securityBundle: any = this.bundle.manifest + ? { ...this.bundle.manifest, ...this.bundle } + : this.bundle; + const SECURITY_FIELDS: Array<[string, string]> = [ + ['roles', 'role'], + ['permissions', 'permission'], + ['sharingRules', 'sharing_rule'], + ['policies', 'policy'], + ]; + let count = 0; + for (const [field, type] of SECURITY_FIELDS) { + const arr = securityBundle?.[field]; + if (!Array.isArray(arr)) continue; + for (const item of arr) { + if (!item?.name) continue; + metadata.registerInMemory(type, item.name, item); + count += 1; + } + } + if (count > 0) { + ctx.logger.info('Registered stack-declared security metadata', { appId, count }); + } + } + } catch (err) { + ctx.logger.warn('[AppPlugin] failed to register security metadata', { + error: (err as Error)?.message ?? String(err), + }); + } + // Resolve the runtime hook owner. Modules that declare both a // `default` (defineStack(...)) export and a named `onEnable` export // hide the named export from `bundle.default`, so we fall back to the diff --git a/packages/runtime/src/security/resolve-execution-context.ts b/packages/runtime/src/security/resolve-execution-context.ts index c046a6825b..e75d18d071 100644 --- a/packages/runtime/src/security/resolve-execution-context.ts +++ b/packages/runtime/src/security/resolve-execution-context.ts @@ -233,6 +233,19 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise { name: 'employee', fields: { name: { type: 'text' }, - department_id: { type: 'lookup', reference: 'department' }, + business_unit_id: { type: 'lookup', reference: 'department' }, }, }, }); @@ -211,7 +211,7 @@ describe('SeedLoaderService', () => { name: 'employee', fields: { name: { type: 'text' }, - department_id: { type: 'lookup', reference: 'department' }, + business_unit_id: { type: 'lookup', reference: 'department' }, }, }, }); @@ -568,7 +568,7 @@ describe('SeedLoaderService', () => { name: 'employee', fields: { name: { type: 'text' }, - department_id: { type: 'lookup', reference: 'department' }, + business_unit_id: { type: 'lookup', reference: 'department' }, }, }, }); @@ -578,7 +578,7 @@ describe('SeedLoaderService', () => { const result = await loader.load({ seeds: [ { object: 'department', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'Engineering', head_id: 'Alice' }] }, - { object: 'employee', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'Alice', department_id: 'Engineering' }] }, + { object: 'employee', externalId: 'name', mode: 'upsert', env: ['prod', 'dev', 'test'], records: [{ name: 'Alice', business_unit_id: 'Engineering' }] }, ], config: { dryRun: false, haltOnError: false, multiPass: true, diff --git a/packages/spec/liveness/permission.json b/packages/spec/liveness/permission.json index cea0b10dfe..427cf9e415 100644 --- a/packages/spec/liveness/permission.json +++ b/packages/spec/liveness/permission.json @@ -46,6 +46,16 @@ "modifyAllRecords": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:60" + }, + "readScope": { + "status": "live", + "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts (getEffectiveScope) + packages/plugins/plugin-sharing/src/sharing-service.ts (owner-match widened by readScope; hierarchy values delegated to IHierarchyScopeResolver)", + "note": "ADR-0057 D1 — read access DEPTH (own/own_and_reports/unit/unit_and_below/org). own/org enforced in open edition; hierarchy values via the enterprise hierarchy-scope-resolver (fail-closed to owner-only when absent; defineStack requires 'hierarchy-security'). Proven: packages/dogfood/test/showcase-scope-depth.dogfood.test.ts." + }, + "writeScope": { + "status": "live", + "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts (getEffectiveScope) + packages/plugins/plugin-sharing/src/sharing-service.ts (canEdit owner-set widened by writeScope; hierarchy values delegated)", + "note": "ADR-0057 D1 — write access DEPTH (same enum as readScope)." } } }, diff --git a/packages/spec/src/automation/approval.zod.ts b/packages/spec/src/automation/approval.zod.ts index 61923a60ae..c2a48ce408 100644 --- a/packages/spec/src/automation/approval.zod.ts +++ b/packages/spec/src/automation/approval.zod.ts @@ -10,7 +10,7 @@ export const ApproverType = z.enum([ 'user', // Specific user(s) 'role', // Users with specific role (sys_member.role) 'team', // Members of a flat collaboration team (sys_team) - 'department', // Members of a department + all descendant departments (sys_department) + 'department', // Members of a department + all descendant departments (sys_business_unit) 'manager', // Submitter's manager (sys_user.manager_id) 'field', // User ID defined in a record field 'queue' // Data ownership queue diff --git a/packages/spec/src/contracts/sharing-service.ts b/packages/spec/src/contracts/sharing-service.ts index c2b10587eb..792876051c 100644 --- a/packages/spec/src/contracts/sharing-service.ts +++ b/packages/spec/src/contracts/sharing-service.ts @@ -128,12 +128,12 @@ export interface ISharingService { * * - `user` — a specific user id (no expansion) * - `team` — a flat collaboration team (`sys_team` + `sys_team_member`) - * - `department` — an org-skeleton node (`sys_department` + descendants via - * `parent_department_id` + members from `sys_department_member`) + * - `department` — an org-skeleton node (`sys_business_unit` + descendants via + * `parent_business_unit_id` + members from `sys_business_unit_member`) * - `role` — tenant role on `sys_member.role` * - `queue` — opaque queue identifier (resolution left to caller / app) */ -export type SharingRuleRecipientType = 'user' | 'team' | 'department' | 'role' | 'role_and_subordinates' | 'queue'; +export type SharingRuleRecipientType = 'user' | 'team' | 'business_unit' | 'role' | 'role_and_subordinates' | 'queue'; /** * Stored shape of a sharing rule. Maps 1-to-1 to `sys_sharing_rule` @@ -219,7 +219,7 @@ export interface ISharingRuleService { * Flat collaboration team graph (better-auth's `sys_team` semantics). * * Teams in this model are *not* hierarchical — they are ad-hoc groupings. - * For the enterprise org-chart hierarchy, see {@link IDepartmentGraphService}. + * For the enterprise org-chart hierarchy, see {@link IBusinessUnitGraphService}. */ export interface ITeamGraphService { /** Return all user ids that are members of `teamId`. */ @@ -231,21 +231,50 @@ export interface ITeamGraphService { } /** - * Hierarchical department graph (`sys_department` org skeleton). + * Hierarchical department graph (`sys_business_unit` org skeleton). * - * Walks `parent_department_id` to expand a department into the union of + * Walks `parent_business_unit_id` to expand a department into the union of * its members and all descendant members. Drives: - * - `recipient_type='department'` sharing rules - * - `dept:` approver prefix in the approval engine + * - `recipient_type='business_unit'` sharing rules + * - `bu:` approver prefix in the approval engine * - report rollups, manager chains, and similar org-aware logic */ -export interface IDepartmentGraphService { - /** Return all descendant department ids (BFS, includes the seed). */ - descendants(departmentId: string): Promise; - /** Return all user ids in `departmentId` or any descendant department. */ - expandUsers(departmentId: string): Promise; +export interface IBusinessUnitGraphService { + /** Return all descendant business unit ids (BFS, includes the seed). */ + descendants(businessUnitId: string): Promise; + /** Return all user ids in `businessUnitId` or any descendant business unit. */ + expandUsers(businessUnitId: string): Promise; /** Return the department head (manager_user_id) — best-effort, null when none. */ - headOf(departmentId: string): Promise; + headOf(businessUnitId: string): Promise; /** Return the manager id for a user — proxy to {@link ITeamGraphService.managerOf}. */ managerOf(userId: string, organizationId?: string): Promise; } + + +/** + * [ADR-0057] HIERARCHY access scopes (the Dataverse-style "see records by where + * you sit in the org"). `own`/`org` are resolved by the open sharing layer + * itself; these three require a pluggable resolver. + */ +export type HierarchyScope = 'unit' | 'unit_and_below' | 'own_and_reports'; + +export interface HierarchyScopeContext { + userId: string; + organizationId?: string | null; + tenantId?: string | null; +} + +/** + * Pluggable resolver for {@link HierarchyScope}s. The OPEN edition ships no + * implementation; the ENTERPRISE package `@objectstack/security-enterprise` + * registers one under the `hierarchy-scope-resolver` kernel service. When + * absent, the sharing layer fails CLOSED to owner-only (a hierarchy scope never + * widens without the resolver). + */ +export interface IHierarchyScopeResolver { + /** + * Owner ids whose records the caller may see under `scope` (must include the + * caller). Empty/throw → caller falls back to owner-only. + */ + resolveOwnerIds(context: HierarchyScopeContext, scope: HierarchyScope): Promise; +} diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index 657cb8846f..fbd6137fbd 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -14,6 +14,13 @@ import { RowLevelSecurityPolicySchema } from './rls.zod'; * - Purge (Hard delete / Compliance) */ import { lazySchema } from '../shared/lazy-schema'; +/** + * [ADR-0057 D1] Object access DEPTH — the Dataverse "access level" axis, + * layered on top of OWD. Widens the owner-match for owner-scoped objects. + */ +export const ObjectAccessScopeSchema = z.enum(['own', 'own_and_reports', 'unit', 'unit_and_below', 'org']); +export type ObjectAccessScope = z.infer; + export const ObjectPermissionSchema = lazySchema(() => z.object({ /** C: Create */ allowCreate: z.boolean().default(false).describe('Create permission'), @@ -51,6 +58,18 @@ export const ObjectPermissionSchema = lazySchema(() => z.object({ * Equivalent to Microsoft Dataverse "Organization" level write access. */ modifyAllRecords: z.boolean().default(false).describe('Modify All Data (Bypass Sharing)'), + + /** + * [ADR-0057 D1] Read access DEPTH (Dataverse-style access level), layered on + * top of OWD. For owner-scoped (`private`) objects it widens the owner-match: + * `own` (owner only) | `own_and_reports` (me + my sys_user.manager_id + * report chain) | `unit` (my business unit) | `unit_and_below` (my BU + + * descendants) | `org` (whole tenant). Unset = `own` baseline. Resolved into + * an `owner_id IN (…)` set at request time; sharing rules still widen on top. + */ + readScope: ObjectAccessScopeSchema.optional().describe('[ADR-0057 D1] Read depth: own|unit|unit_and_below|org'), + /** [ADR-0057 D1] Write (edit/delete) access DEPTH — same enum as readScope. */ + writeScope: ObjectAccessScopeSchema.optional().describe('[ADR-0057 D1] Write depth: own|unit|unit_and_below|org'), })); /** diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 0ec445e099..cdfc8f2e4e 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -792,6 +792,40 @@ function mergeActionsIntoObjects(config: ObjectStackDefinition): ObjectStackDefi * const stack = defineStack({ manifest: { ... }, objects: [...], views: [...] }, { strict: true }); * ``` */ +/** + * [ADR-0057] HIERARCHY access scopes (`unit` / `unit_and_below` / + * `own_and_reports`) are an ENTERPRISE capability — their enforcement ships in + * `@objectstack/security-enterprise`, not the open edition. A stack that uses + * one MUST declare `requires: ['hierarchy-security']`; otherwise the open + * runtime would silently fail closed to owner-only (the metadata would lie, + * ADR-0049). This makes that an authoring-time error instead. + */ +function validateHierarchyScopeCapability(data: unknown): string[] { + const errors: string[] = []; + const d = data as { requires?: unknown; permissions?: unknown }; + const requires = Array.isArray(d?.requires) ? (d.requires as string[]) : []; + if (requires.includes('hierarchy-security')) return errors; + const HIER = new Set(['unit', 'unit_and_below', 'own_and_reports']); + const perms = Array.isArray(d?.permissions) ? (d.permissions as any[]) : []; + for (const ps of perms) { + const objs = ps?.objects && typeof ps.objects === 'object' ? ps.objects : {}; + for (const [objName, grant] of Object.entries(objs)) { + const g = grant as Record; + for (const key of ['readScope', 'writeScope']) { + const v = g?.[key]; + if (typeof v === 'string' && HIER.has(v)) { + errors.push( + `permission set '${ps?.name ?? '?'}' grant on '${objName}' uses ${key}='${v}', a HIERARCHY scope. ` + + `Declare \`requires: ['hierarchy-security']\` (provided by @objectstack/security-enterprise) — ` + + `the open edition cannot enforce it and would fail closed to owner-only.`, + ); + } + } + } + } + return errors; +} + export function defineStack( config: ObjectStackDefinitionInput, options?: DefineStackOptions, @@ -838,6 +872,13 @@ export function defineStack( throw new Error(`${header}\n\n${lines.join('\n')}`); } + const hierErrors = validateHierarchyScopeCapability(result.data); + if (hierErrors.length > 0) { + const header = `defineStack hierarchy-scope capability validation failed (${hierErrors.length} issue${hierErrors.length === 1 ? '' : 's'}):`; + const lines = hierErrors.map((e) => ` ✗ ${e}`); + throw new Error(`${header}\n\n${lines.join('\n')}`); + } + return mergeActionsIntoObjects(result.data); }