From 3f1e578cc13e2c8f879dea504ec7c9a016636817 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 05:59:25 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(security)!:=20ADR-0090=20P3=20?= =?UTF-8?q?=E2=80=94=20security=20publish=20linter=20(D7),=20delegated=20a?= =?UTF-8?q?dministration=20(D12),=20BU=20assignment=20anchor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D7 — validateSecurityPosture in @objectstack/lint, gating os compile and reported by os lint. Seven rules, each with a failing fixture: unset OWD on custom objects (the objectui#2348 leave_request shape), retired OWD aliases (fix-it), external dial wider than internal (D11), wildcard VAMA outside the platform admin set (ADR-0066), high-privilege everyone-suggested sets, the reserved word 'role' in security identifiers/labels (D3), and advisory private-object-read-without-depth. The linter immediately caught real gaps: app-crm shipped six objects with no OWD, app-showcase two, plus a 'role' field on showcase_project_membership and the CLI golden corpus — all fixed in this commit (grandfather stamps + engagement rename), proving the rule set against its own repo. D12 — DelegatedAdminGate in plugin-security. PermissionSetSchema.adminScope (persisted as sys_permission_set.admin_scope) declares WHERE (BU subtree), WHAT (manageAssignments / manageBindings / authorEnvironmentSets) and WHICH sets a delegate may hand out (allowlist). Writes to sys_user_position, sys_position_permission_set, sys_user_permission_set and sys_permission_set are now governed: tenant admins (superuser wildcard) pass to ordinary CRUD/RLS; delegates need a covering scope — subtree-anchored assignments, allowlisted sets only (to others AND themselves), single-row writes, granted_by audit stamping; plain CRUD holders with no scope are denied. Granting/authoring a set that carries an adminScope requires strict containment. everyone/guest anchors stay tenant-level only, and stored assignments to an anchor are rejected for every caller. ADR-0090 Addendum — sys_user_position.business_unit_id lands with its three consumers: D12 delegation boundary (enforced), audit fact, and the depth-anchor contract documented on IHierarchyScopeResolver for enterprise resolvers. D9 tightening — describeHighPrivilegeBits moved to @objectstack/spec/security (shared by lint + runtime, re-exported from plugin-security) plus new describeAnchorForbiddenBits: guest bindings now also reject edit bits. D3 sweep — SysRole→SysPosition, SysUserRole→SysUserPosition, SysRolePermissionSet→SysPositionPermissionSet (one-step, no aliases); sys_position actions/list views/labels de-roled; sys_business_unit_member.role_in_business_unit→function_in_business_unit. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H --- .../adr-0090-p3-linter-delegated-admin.md | 21 + docs/design/permission-model.md | 26 +- .../app-crm/src/objects/account.object.ts | 5 + .../app-crm/src/objects/activity.object.ts | 5 + .../app-crm/src/objects/contact.object.ts | 5 + examples/app-crm/src/objects/lead.object.ts | 5 + .../objects/opportunity-line-item.object.ts | 3 + .../app-crm/src/objects/opportunity.object.ts | 5 + .../src/data/objects/semantic-zoo.object.ts | 4 + .../src/data/objects/team.object.ts | 10 +- examples/app-showcase/src/data/seed/index.ts | 6 +- .../src/system/translations/index.ts | 4 +- packages/cli/src/commands/compile.ts | 34 + packages/cli/src/commands/lint.ts | 19 +- packages/cli/src/lint/corpus.ts | 11 + packages/cli/test/metadata-eval.test.ts | 6 +- packages/cli/test/score.test.ts | 10 +- packages/lint/src/index.ts | 12 + .../src/validate-security-posture.test.ts | 207 ++++++ .../lint/src/validate-security-posture.ts | 339 +++++++++ .../apps/translations/en.objects.generated.ts | 4 +- .../translations/es-ES.objects.generated.ts | 4 +- .../translations/ja-JP.objects.generated.ts | 4 +- .../translations/zh-CN.objects.generated.ts | 4 +- .../sys-business-unit-member.object.ts | 6 +- .../scripts/i18n-extract.config.ts | 4 +- .../src/audience-anchors.test.ts | 24 + .../src/bootstrap-declared-permissions.ts | 3 + .../src/bootstrap-platform-admin.ts | 2 + .../src/delegated-admin-gate.test.ts | 320 +++++++++ .../src/delegated-admin-gate.ts | 654 ++++++++++++++++++ packages/plugins/plugin-security/src/index.ts | 2 + .../plugins/plugin-security/src/manifest.ts | 14 +- .../plugin-security/src/objects/index.ts | 6 +- .../src/objects/rbac-objects.test.ts | 14 +- .../src/objects/sys-permission-set.object.ts | 15 +- .../sys-position-permission-set.object.ts | 15 +- .../src/objects/sys-position.object.ts | 73 +- .../src/objects/sys-user-position.object.ts | 39 +- .../plugin-security/src/security-plugin.ts | 72 +- packages/spec/api-surface.json | 5 + packages/spec/liveness/permission.json | 8 +- .../spec/src/contracts/sharing-service.ts | 10 + packages/spec/src/security/high-privilege.ts | 76 ++ packages/spec/src/security/index.ts | 1 + packages/spec/src/security/permission.test.ts | 34 + packages/spec/src/security/permission.zod.ts | 53 +- 47 files changed, 2073 insertions(+), 130 deletions(-) create mode 100644 .changeset/adr-0090-p3-linter-delegated-admin.md create mode 100644 packages/lint/src/validate-security-posture.test.ts create mode 100644 packages/lint/src/validate-security-posture.ts create mode 100644 packages/plugins/plugin-security/src/delegated-admin-gate.test.ts create mode 100644 packages/plugins/plugin-security/src/delegated-admin-gate.ts create mode 100644 packages/spec/src/security/high-privilege.ts diff --git a/.changeset/adr-0090-p3-linter-delegated-admin.md b/.changeset/adr-0090-p3-linter-delegated-admin.md new file mode 100644 index 0000000000..e7ad012e4f --- /dev/null +++ b/.changeset/adr-0090-p3-linter-delegated-admin.md @@ -0,0 +1,21 @@ +--- +"@objectstack/spec": minor +"@objectstack/lint": minor +"@objectstack/cli": minor +"@objectstack/plugin-security": major +"@objectstack/platform-objects": major +--- + +ADR-0090 P3 — security-domain publish linter (D7) and delegated administration (D12). + +**D7 — `validateSecurityPosture` (@objectstack/lint), wired into `os compile` (errors gate the build) and `os lint`.** Rules, each with a failing fixture: `security-owd-unset` (custom object with no `sharingModel` — the objectui#2348 leave_request shape), `security-owd-alias` (retired D4 alias values, with fix-it), `security-external-wider-than-internal` (D11 `external ≤ internal`), `security-wildcard-vama` (`'*'` + View/Modify All outside the platform admin set, ADR-0066), `security-anchor-high-privilege` (an `isDefault`/everyone-suggested set carrying anchor-forbidden bits), `security-role-word` (D3 vocabulary freeze in security identifiers/labels; ARIA/page roles exempt), and advisory `security-private-no-readscope`. + +**D12 — delegated administration (@objectstack/plugin-security `DelegatedAdminGate`).** `PermissionSetSchema.adminScope` (new in spec, persisted as `sys_permission_set.admin_scope`) declares WHERE (a `sys_business_unit` subtree), WHAT (`manageAssignments` / `manageBindings` / `authorEnvironmentSets`), and WHICH sets a delegate may hand out (`assignablePermissionSets` allowlist). Writes to `sys_user_position`, `sys_position_permission_set`, `sys_user_permission_set`, and `sys_permission_set` are now governed: tenant-level admins (ADR-0066 superuser wildcard) pass through; delegates need a covering scope — inside their subtree, allowlisted sets only (to others AND themselves), single-row writes, `granted_by` audit-stamped; everyone else (including holders of plain CRUD on RBAC tables) is denied. Granting or authoring a set that itself carries an `adminScope` requires a held scope that STRICTLY contains it. The `everyone`/`guest` anchors stay tenant-level only, and direct position assignments to an anchor are rejected for every caller. + +**ADR-0090 Addendum — assignment-level BU anchor.** `sys_user_position.business_unit_id` lands with its three consumers scoped: D12 delegation boundary (enforced here), audit fact, and the depth-anchor contract for enterprise `hierarchy-scope-resolver` implementations (documented on `IHierarchyScopeResolver`). + +**D9 tier tightening.** `describeHighPrivilegeBits` moved to `@objectstack/spec/security` (re-exported from plugin-security) alongside new `describeAnchorForbiddenBits`: `guest` bindings now additionally reject edit bits (read-only by default; create stays the case-by-case exception). + +**BREAKING (@objectstack/plugin-security):** exports renamed to the ADR-0090 D3 vocabulary — `SysRole`→`SysPosition`, `SysUserRole`→`SysUserPosition`, `SysRolePermissionSet`→`SysPositionPermissionSet` (no aliases, pre-launch one-step rename). `sys_position` row actions/list views renamed (`activate_position`, …), labels relabeled Role→Position. Non-tenant-admin writes to the RBAC link tables without an `adminScope` are now denied (previously any CRUD grant on those tables sufficed). + +**BREAKING (@objectstack/platform-objects):** `sys_business_unit_member.role_in_business_unit` → `function_in_business_unit` (D3 reserved-word sweep; values member/lead/deputy unchanged). diff --git a/docs/design/permission-model.md b/docs/design/permission-model.md index 5e066350ce..9dec57245b 100644 --- a/docs/design/permission-model.md +++ b/docs/design/permission-model.md @@ -208,8 +208,10 @@ being **structured data**: 1. **A small, closed vocabulary** — 5 concepts, 4 OWD values, no aliases, banned words: the error space is shrunk before any checker runs. Strict authoring (rejects, never lenient-parses). -2. **Publish linter** (security domain): unset OWD, everyone+high-privilege, non-admin superuser - wildcards, forbidden vocabulary — each rule traceable to an observed failure class. +2. **Publish linter** (security domain, landed in P3 as `validateSecurityPosture` in + `@objectstack/lint`, gating `os compile`): unset OWD, retired OWD aliases, external dial wider + than internal, non-admin superuser wildcards, high-privilege everyone-suggested sets, forbidden + vocabulary — each rule traceable to an observed failure class and mirrored by a runtime gate. 3. **Access-matrix snapshot**: publishes evaluate representative positions × objects and diff against the committed matrix; an unchanged matrix auto-passes, a changed one raises a human gate showing the *semantic* impact ("grants `sales_rep` (~1,200 users) org-wide read on @@ -285,6 +287,26 @@ anything outside the allowlist — **including to themselves** — and can never things (the `everyone`/`guest` anchors, security publishes). Headquarters keeps one dashboard: the same explain engine answers "who *could have* granted this", not just "who did". +**How it is authored (landed in P3).** An admin scope is a field on an ordinary permission set +(`adminScope: { businessUnit, includeSubtree, manageAssignments, manageBindings, +authorEnvironmentSets, assignablePermissionSets[] }`), so it is distributed via positions and +audited like every other grant. The same set should also carry plain CRUD on the RBAC link +tables (`sys_user_position`, `sys_position_permission_set`, `sys_user_permission_set`) — the +scope authorizes *what* may be administered, the CRUD bits let the requests through at all. +Runtime rules enforced by the `DelegatedAdminGate` (plugin-security): + +- assignments a delegate creates must be **anchored** (`sys_user_position.business_unit_id`) + inside their subtree, and are `granted_by`-stamped automatically; +- every set reached by the write — bound to the assigned position, or granted directly — must be + in the allowlist; re-composing a position (bindings) requires every current holder to sit + inside the subtree; +- granting or authoring a set that itself carries an `adminScope` requires a held scope that + **strictly contains** it (handing your own exact scope to a peer is refused — no lateral + propagation); +- delegates write **single rows by id** only (a broad filter-write cannot be boundary-checked); +- holders of plain CRUD on the RBAC tables with **no** scope are refused: administration is a + scoped capability now, not a side effect of table access. + Planned next (tracked as follow-up ADRs, not yet in the model): **expiring grants** (contractor access that ends on a date, stand-in approvers during vacations, break-glass access that auto-revokes), **separation-of-duties rules** ("the person who creates vendors must not also diff --git a/examples/app-crm/src/objects/account.object.ts b/examples/app-crm/src/objects/account.object.ts index 2bd3fc00b6..9825a7a20f 100644 --- a/examples/app-crm/src/objects/account.object.ts +++ b/examples/app-crm/src/objects/account.object.ts @@ -4,6 +4,11 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; export const Account = ObjectSchema.create({ name: 'crm_account', + // [ADR-0090 D1] Explicit grandfather stamp: record isolation for this demo + // object is intentionally org-shared; without this the new secure default + // (unset OWD => private) would owner-filter it, and the D7 publish linter + // (security-owd-unset) fails the build on an undeclared baseline. + sharingModel: 'public_read_write', label: 'Account', pluralLabel: 'Accounts', icon: 'building', diff --git a/examples/app-crm/src/objects/activity.object.ts b/examples/app-crm/src/objects/activity.object.ts index 94a166aa91..a2f940fb73 100644 --- a/examples/app-crm/src/objects/activity.object.ts +++ b/examples/app-crm/src/objects/activity.object.ts @@ -4,6 +4,11 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; export const Activity = ObjectSchema.create({ name: 'crm_activity', + // [ADR-0090 D1] Explicit grandfather stamp: record isolation for this demo + // object is intentionally org-shared; without this the new secure default + // (unset OWD => private) would owner-filter it, and the D7 publish linter + // (security-owd-unset) fails the build on an undeclared baseline. + sharingModel: 'public_read_write', label: 'Activity', pluralLabel: 'Activities', icon: 'calendar-check', diff --git a/examples/app-crm/src/objects/contact.object.ts b/examples/app-crm/src/objects/contact.object.ts index 0aeb6207b7..e1e8bdfadd 100644 --- a/examples/app-crm/src/objects/contact.object.ts +++ b/examples/app-crm/src/objects/contact.object.ts @@ -5,6 +5,11 @@ import { cel } from '@objectstack/spec'; export const Contact = ObjectSchema.create({ name: 'crm_contact', + // [ADR-0090 D1] Explicit grandfather stamp: record isolation for this demo + // object is intentionally org-shared; without this the new secure default + // (unset OWD => private) would owner-filter it, and the D7 publish linter + // (security-owd-unset) fails the build on an undeclared baseline. + sharingModel: 'public_read_write', label: 'Contact', pluralLabel: 'Contacts', icon: 'user', diff --git a/examples/app-crm/src/objects/lead.object.ts b/examples/app-crm/src/objects/lead.object.ts index 58d23e2e76..b5cae4f850 100644 --- a/examples/app-crm/src/objects/lead.object.ts +++ b/examples/app-crm/src/objects/lead.object.ts @@ -5,6 +5,11 @@ import { cel, P } from '@objectstack/spec'; export const Lead = ObjectSchema.create({ name: 'crm_lead', + // [ADR-0090 D1] Explicit grandfather stamp: record isolation for this demo + // object is intentionally org-shared; without this the new secure default + // (unset OWD => private) would owner-filter it, and the D7 publish linter + // (security-owd-unset) fails the build on an undeclared baseline. + sharingModel: 'public_read_write', label: 'Lead', pluralLabel: 'Leads', icon: 'funnel', diff --git a/examples/app-crm/src/objects/opportunity-line-item.object.ts b/examples/app-crm/src/objects/opportunity-line-item.object.ts index c268f5bd82..8d3256c9d4 100644 --- a/examples/app-crm/src/objects/opportunity-line-item.object.ts +++ b/examples/app-crm/src/objects/opportunity-line-item.object.ts @@ -16,6 +16,9 @@ import { cel } from '@objectstack/spec'; */ export const OpportunityLineItem = ObjectSchema.create({ name: 'crm_opportunity_line_item', + // [ADR-0090 D1/D4] Master-detail child: record access follows the parent + // opportunity (the D7 publish linter requires the baseline to be declared). + sharingModel: 'controlled_by_parent', label: 'Line Item', pluralLabel: 'Line Items', icon: 'list', diff --git a/examples/app-crm/src/objects/opportunity.object.ts b/examples/app-crm/src/objects/opportunity.object.ts index 6271837233..4281f0413f 100644 --- a/examples/app-crm/src/objects/opportunity.object.ts +++ b/examples/app-crm/src/objects/opportunity.object.ts @@ -5,6 +5,11 @@ import { cel, P } from '@objectstack/spec'; export const Opportunity = ObjectSchema.create({ name: 'crm_opportunity', + // [ADR-0090 D1] Explicit grandfather stamp: record isolation for this demo + // object is intentionally org-shared; without this the new secure default + // (unset OWD => private) would owner-filter it, and the D7 publish linter + // (security-owd-unset) fails the build on an undeclared baseline. + sharingModel: 'public_read_write', label: 'Opportunity', pluralLabel: 'Opportunities', icon: 'trending-up', diff --git a/examples/app-showcase/src/data/objects/semantic-zoo.object.ts b/examples/app-showcase/src/data/objects/semantic-zoo.object.ts index cd769160cb..8c7a475b97 100644 --- a/examples/app-showcase/src/data/objects/semantic-zoo.object.ts +++ b/examples/app-showcase/src/data/objects/semantic-zoo.object.ts @@ -56,6 +56,10 @@ export const SemanticZoo = ObjectSchema.create({ export const SemanticZooLegacy = ObjectSchema.create({ name: 'showcase_semantic_zoo_legacy', + // [ADR-0090 D1] Explicit grandfather stamp: record isolation for this demo + // object is RLS-owned / intentionally public; without this the new secure + // default (unset OWD => private) would owner-filter it. + sharingModel: 'public_read_write', label: 'Semantic Zoo (Legacy)', pluralLabel: 'Semantic Zoo Legacies', icon: 'flask-round', diff --git a/examples/app-showcase/src/data/objects/team.object.ts b/examples/app-showcase/src/data/objects/team.object.ts index 1d2f7614a0..621c425a90 100644 --- a/examples/app-showcase/src/data/objects/team.object.ts +++ b/examples/app-showcase/src/data/objects/team.object.ts @@ -28,6 +28,10 @@ export const Team = ObjectSchema.create({ /** Junction row joining Team ↔ Project (many-to-many). */ export const ProjectMembership = ObjectSchema.create({ name: 'showcase_project_membership', + // [ADR-0090 D1] Explicit grandfather stamp: record isolation for this demo + // object is RLS-owned / intentionally public; without this the new secure + // default (unset OWD => private) would owner-filter it. + sharingModel: 'public_read_write', label: 'Project Membership', pluralLabel: 'Project Memberships', icon: 'link', @@ -36,8 +40,10 @@ export const ProjectMembership = ObjectSchema.create({ fields: { team: Field.masterDetail('showcase_team', { label: 'Team', required: true }), project: Field.masterDetail('showcase_project', { label: 'Project', required: true }), - role: Field.select({ - label: 'Role', + // [ADR-0090 D3] Formerly `role` — reserved word; this is the team's + // engagement on the project, not a capability container. + engagement: Field.select({ + label: 'Engagement', options: [ { label: 'Owner', value: 'owner', default: true }, { label: 'Contributor', value: 'contributor' }, diff --git a/examples/app-showcase/src/data/seed/index.ts b/examples/app-showcase/src/data/seed/index.ts index ab8a029eb5..6309a8a0d5 100644 --- a/examples/app-showcase/src/data/seed/index.ts +++ b/examples/app-showcase/src/data/seed/index.ts @@ -155,9 +155,9 @@ const products = defineSeed(Product, { const memberships = defineSeed(ProjectMembership, { mode: 'insert', records: [ - { team: 'Experience', project: 'Website Relaunch', role: 'owner', allocation_percent: 80 }, - { team: 'Platform', project: 'Data Platform', role: 'owner', allocation_percent: 100 }, - { team: 'Platform', project: 'Website Relaunch', role: 'contributor', allocation_percent: 20 }, + { team: 'Experience', project: 'Website Relaunch', engagement: 'owner', allocation_percent: 80 }, + { team: 'Platform', project: 'Data Platform', engagement: 'owner', allocation_percent: 100 }, + { team: 'Platform', project: 'Website Relaunch', engagement: 'contributor', allocation_percent: 20 }, ], }); diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index d81060081a..d33d9d01f5 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -115,7 +115,7 @@ export const ShowcaseTranslationBundle = { }, showcase_project_membership: { label: 'Membership', pluralLabel: 'Memberships', - fields: { team: { label: 'Team' }, project: { label: 'Project' }, role: { label: 'Role' }, allocation_percent: { label: 'Allocation %' } }, + fields: { team: { label: 'Team' }, project: { label: 'Project' }, engagement: { label: 'Engagement' }, allocation_percent: { label: 'Allocation %' } }, }, showcase_category: { label: 'Category', pluralLabel: 'Categories', @@ -232,7 +232,7 @@ export const ShowcaseTranslationBundle = { }, showcase_project_membership: { label: '成员', pluralLabel: '成员', - fields: { team: { label: '团队' }, project: { label: '项目' }, role: { label: '角色' }, allocation_percent: { label: '分配比例' } }, + fields: { team: { label: '团队' }, project: { label: '项目' }, engagement: { label: '协作职能' }, allocation_percent: { label: '分配比例' } }, }, showcase_category: { label: '分类', pluralLabel: '分类', diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 00fda324c8..ee121c430b 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -11,6 +11,7 @@ import { lowerCallables } from '../utils/lower-callables.js'; import { validateStackExpressions } from '@objectstack/lint'; import { validateWidgetBindings } from '@objectstack/lint'; import { validateResponsiveStyles } from '@objectstack/lint'; +import { validateSecurityPosture } from '@objectstack/lint'; import { lintFlowPatterns } from '../utils/lint-flow-patterns.js'; import { lintAutonumberFormats } from '../utils/lint-autonumber-formats.js'; import { lintLivenessProperties } from '../utils/lint-liveness-properties.js'; @@ -323,6 +324,39 @@ export default class Compile extends Command { } } + // 3e. [ADR-0090 D7] Security-domain publish linter. Every error rule + // mirrors a runtime enforcement point (fail-closed OWD default, + // canonical enum, anchor binding gate, vocabulary freeze) — the lint + // moves the failure from a runtime deny to an author-time fix-it. + // Errors GATE the build (per ADR-0049 this is not advisory + // security); `info` findings are printed dimmed and never fatal. + if (!flags.json) printStep('Checking security posture (ADR-0090 D7)...'); + const securityFindings = validateSecurityPosture(result.data as Record); + const securityErrors = securityFindings.filter((f) => f.severity === 'error'); + const securityAdvisories = securityFindings.filter((f) => f.severity !== 'error'); + if (securityErrors.length > 0) { + if (flags.json) { + console.log(JSON.stringify({ success: false, error: 'security posture validation failed', issues: securityErrors })); + this.exit(1); + } + console.log(''); + printError(`Security posture check failed (${securityErrors.length} issue${securityErrors.length > 1 ? 's' : ''})`); + for (const f of securityErrors.slice(0, 50)) { + console.log(` • ${f.where}: ${f.message}`); + console.log(chalk.dim(` ${f.hint}`)); + console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); + } + this.exit(1); + } + if (securityAdvisories.length > 0 && !flags.json) { + console.log(''); + for (const f of securityAdvisories) { + printWarning(`${f.where}: ${f.message}`); + console.log(chalk.dim(` ${f.hint}`)); + console.log(chalk.dim(` rule: ${f.rule}`)); + } + } + // 3d. Package docs (ADR-0046): compile flat `src/docs/*.md` into // `docs: DocSchema[]` and lint the combined set (flatness, // namespace-prefixed names, MDX/image ban, same-package link diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index a7569f4be4..e7bc0ff60e 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -8,7 +8,7 @@ import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js'; import { computeI18nCoverage } from '../utils/i18n-coverage.js'; import { lintDataModel } from '../lint/data-model-rules.js'; import { validateWidgetBindings } from '@objectstack/lint'; -import { validateRecordTitle, validateSemanticRoles, validateCapabilityReferences } from '@objectstack/lint'; +import { validateRecordTitle, validateSemanticRoles, validateCapabilityReferences, validateSecurityPosture } from '@objectstack/lint'; import { collectAndLintDocs } from '../utils/collect-docs.js'; import { scoreMetadata } from '../lint/score.js'; import { runMetadataEval } from '../lint/metadata-eval.js'; @@ -352,6 +352,23 @@ export function lintConfig(config: any): LintIssue[] { }); } + // ── Security posture (ADR-0090 D7) ── + // The security-domain publish linter: unset/alias OWD, external dial wider + // than internal, wildcard VAMA, high-privilege everyone-suggested sets, the + // reserved word "role", and private-object read grants with no depth. Runs + // on the NORMALIZED (pre-zod) input here, so alias values that the schema + // gate would reject in `os compile` get a located fix-it instead of a Zod + // enum error. `error` findings gate `os compile`; `info` maps to suggestion. + for (const t of validateSecurityPosture(config)) { + issues.push({ + severity: t.severity === 'info' ? 'suggestion' : t.severity, + rule: t.rule, + message: `${t.where}: ${t.message}`, + path: t.path, + fix: t.hint, + }); + } + return issues; } diff --git a/packages/cli/src/lint/corpus.ts b/packages/cli/src/lint/corpus.ts index 382bb45d1c..150457a3fa 100644 --- a/packages/cli/src/lint/corpus.ts +++ b/packages/cli/src/lint/corpus.ts @@ -37,6 +37,7 @@ export const DEFAULT_METADATA_EVAL_CORPUS: MetadataEvalCase[] = [ { name: 'invoice', label: 'Invoice', + sharingModel: 'private', fields: { name: { type: 'text', label: 'Invoice Number', required: true }, account: { type: 'lookup', label: 'Account', reference: 'account' }, @@ -59,6 +60,7 @@ export const DEFAULT_METADATA_EVAL_CORPUS: MetadataEvalCase[] = [ { name: 'invoice_line', label: 'Invoice Line', + sharingModel: 'controlled_by_parent', fields: { invoice: { type: 'master_detail', @@ -78,6 +80,7 @@ export const DEFAULT_METADATA_EVAL_CORPUS: MetadataEvalCase[] = [ { name: 'account', label: 'Account', + sharingModel: 'private', fields: { name: { type: 'text', label: 'Account Name', required: true } }, }, ], @@ -95,6 +98,7 @@ export const DEFAULT_METADATA_EVAL_CORPUS: MetadataEvalCase[] = [ { name: 'project', label: 'Project', + sharingModel: 'private', fields: { name: { type: 'text', label: 'Project Name', required: true }, status: { @@ -121,6 +125,7 @@ export const DEFAULT_METADATA_EVAL_CORPUS: MetadataEvalCase[] = [ { name: 'task', label: 'Task', + sharingModel: 'controlled_by_parent', fields: { title: { type: 'text', label: 'Title', required: true }, project: { @@ -158,6 +163,7 @@ export const DEFAULT_METADATA_EVAL_CORPUS: MetadataEvalCase[] = [ { name: 'post', label: 'Post', + sharingModel: 'private', fields: { title: { type: 'text', label: 'Title', required: true }, body: { type: 'textarea', label: 'Body' }, @@ -166,6 +172,7 @@ export const DEFAULT_METADATA_EVAL_CORPUS: MetadataEvalCase[] = [ { name: 'post_comment', label: 'Comment', + sharingModel: 'controlled_by_parent', fields: { // Association: owned by the post (cascade) but NOT inlineEdit — // surfaced as a related list on the post's detail page. @@ -195,6 +202,7 @@ export const DEFAULT_METADATA_EVAL_CORPUS: MetadataEvalCase[] = [ { name: 'expense_report', label: 'Expense Report', + sharingModel: 'private', fields: { name: { type: 'text', label: 'Title', required: true }, submitter: { type: 'text', label: 'Submitter', required: true }, @@ -208,6 +216,7 @@ export const DEFAULT_METADATA_EVAL_CORPUS: MetadataEvalCase[] = [ { name: 'expense_line', label: 'Expense Line', + sharingModel: 'controlled_by_parent', fields: { expense_report: { type: 'master_detail', @@ -246,6 +255,7 @@ export const DEFAULT_METADATA_EVAL_CORPUS: MetadataEvalCase[] = [ { name: 'account', label: 'Account', + sharingModel: 'private', fields: { name: { type: 'text', label: 'Account Name', required: true }, industry: { @@ -261,6 +271,7 @@ export const DEFAULT_METADATA_EVAL_CORPUS: MetadataEvalCase[] = [ { name: 'contact', label: 'Contact', + sharingModel: 'private', fields: { full_name: { type: 'text', label: 'Full Name', required: true }, email: { type: 'email', label: 'Email' }, diff --git a/packages/cli/test/metadata-eval.test.ts b/packages/cli/test/metadata-eval.test.ts index cf4c331b49..f9e28b5920 100644 --- a/packages/cli/test/metadata-eval.test.ts +++ b/packages/cli/test/metadata-eval.test.ts @@ -51,8 +51,10 @@ describe('runMetadataEval — live seam', () => { it('a generator that produces a clean stack passes', async () => { const goodGen = () => ({ objects: [ - { name: 'invoice', label: 'Invoice', fields: { name: { type: 'text', label: 'Name', required: true } } }, - { name: 'invoice_line', label: 'Line', fields: { invoice: { type: 'master_detail', label: 'Invoice', reference: 'invoice', required: true, deleteBehavior: 'cascade', inlineEdit: true }, amount: { type: 'currency', label: 'Amount', required: true } } }, + // A "clean" stack declares OWD — the D7 security linter (ADR-0090) + // errors on custom objects with an unset sharingModel. + { name: 'invoice', label: 'Invoice', sharingModel: 'private', fields: { name: { type: 'text', label: 'Name', required: true } } }, + { name: 'invoice_line', label: 'Line', sharingModel: 'controlled_by_parent', fields: { invoice: { type: 'master_detail', label: 'Invoice', reference: 'invoice', required: true, deleteBehavior: 'cascade', inlineEdit: true }, amount: { type: 'currency', label: 'Amount', required: true } } }, ], }); const report = await runMetadataEval(oneCase, { generate: goodGen }); diff --git a/packages/cli/test/score.test.ts b/packages/cli/test/score.test.ts index 491dee0c1c..aed92c1a8c 100644 --- a/packages/cli/test/score.test.ts +++ b/packages/cli/test/score.test.ts @@ -8,6 +8,7 @@ const GOOD_STACK = { { name: 'invoice', label: 'Invoice', + sharingModel: 'private', // exemplary: OWD is an authored decision (ADR-0090 D7) fields: { name: { type: 'text', label: 'Invoice Number', required: true }, status: { type: 'select', label: 'Status', options: [{ label: 'Draft', value: 'draft' }, { label: 'Sent', value: 'sent' }] }, @@ -17,6 +18,7 @@ const GOOD_STACK = { { name: 'invoice_line', label: 'Invoice Line', + sharingModel: 'controlled_by_parent', fields: { invoice: { type: 'master_detail', label: 'Invoice', reference: 'invoice', required: true, deleteBehavior: 'cascade', inlineEdit: true }, product: { type: 'text', label: 'Product', required: true }, @@ -80,15 +82,15 @@ describe('scoreMetadata', () => { // Only suggestions: a master_detail without explicit deleteBehavior (suggestion). const onlySuggestions = scoreMetadata({ objects: [ - { name: 'invoice', label: 'Invoice', fields: { name: { type: 'text', label: 'Name', required: true } } }, - { name: 'invoice_line', label: 'Line', fields: { invoice: { type: 'master_detail', label: 'Invoice', reference: 'invoice', required: true, inlineEdit: true } } }, + { name: 'invoice', label: 'Invoice', sharingModel: 'private', fields: { name: { type: 'text', label: 'Name', required: true } } }, + { name: 'invoice_line', label: 'Line', sharingModel: 'controlled_by_parent', fields: { invoice: { type: 'master_detail', label: 'Invoice', reference: 'invoice', required: true, inlineEdit: true } } }, ], }); // A warning: master_detail not required. const withWarning = scoreMetadata({ objects: [ - { name: 'invoice', label: 'Invoice', fields: { name: { type: 'text', label: 'Name', required: true } } }, - { name: 'invoice_line', label: 'Line', fields: { invoice: { type: 'master_detail', label: 'Invoice', reference: 'invoice', deleteBehavior: 'cascade' } } }, + { name: 'invoice', label: 'Invoice', sharingModel: 'private', fields: { name: { type: 'text', label: 'Name', required: true } } }, + { name: 'invoice_line', label: 'Line', sharingModel: 'controlled_by_parent', fields: { invoice: { type: 'master_detail', label: 'Invoice', reference: 'invoice', deleteBehavior: 'cascade' } } }, ], }); expect(onlySuggestions.score).toBeGreaterThan(withWarning.score); diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index bfcdf8192d..fd2b644dbd 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -74,3 +74,15 @@ export { CAPABILITY_REFERENCE_UNKNOWN, } from './validate-capability-references.js'; export type { CapabilityRefFinding, CapabilityRefSeverity } from './validate-capability-references.js'; + +export { + validateSecurityPosture, + SECURITY_OWD_UNSET, + SECURITY_OWD_ALIAS, + SECURITY_EXTERNAL_WIDER, + SECURITY_WILDCARD_VAMA, + SECURITY_ANCHOR_HIGH_PRIVILEGE, + SECURITY_ROLE_WORD, + SECURITY_PRIVATE_NO_READSCOPE, +} from './validate-security-posture.js'; +export type { SecurityFinding, SecuritySeverity } from './validate-security-posture.js'; diff --git a/packages/lint/src/validate-security-posture.test.ts b/packages/lint/src/validate-security-posture.test.ts new file mode 100644 index 0000000000..240bed8429 --- /dev/null +++ b/packages/lint/src/validate-security-posture.test.ts @@ -0,0 +1,207 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0090 D7] Security-posture linter — one failing fixture per rule (the + * ADR's own acceptance bar: "each lint rule has a fixture that fails without + * it"), plus the clean-stack fixture that must stay silent. + */ + +import { describe, it, expect } from 'vitest'; +import { + validateSecurityPosture, + SECURITY_OWD_UNSET, + SECURITY_OWD_ALIAS, + SECURITY_EXTERNAL_WIDER, + SECURITY_WILDCARD_VAMA, + SECURITY_ANCHOR_HIGH_PRIVILEGE, + SECURITY_ROLE_WORD, + SECURITY_PRIVATE_NO_READSCOPE, +} from './validate-security-posture.js'; + +const rulesOf = (stack: Record) => + validateSecurityPosture(stack).map((f) => f.rule); + +describe('validateSecurityPosture (ADR-0090 D7)', () => { + it('clean stack produces no findings', () => { + const findings = validateSecurityPosture({ + objects: [ + { name: 'leave_request', label: 'Leave Request', sharingModel: 'private', fields: { title: { name: 'title', label: 'Title' } } }, + { name: 'leave_item', label: 'Leave Item', sharingModel: 'controlled_by_parent' }, + { name: 'sys_internal', label: 'Internal' }, // system prefix — exempt from OWD rules + ], + permissions: [ + { + name: 'hr_user', + label: 'HR User', + objects: { leave_request: { allowRead: true, allowCreate: true, readScope: 'unit' } }, + }, + ], + positions: [{ name: 'hr_specialist', label: 'HR Specialist' }], + }); + expect(findings).toEqual([]); + }); + + // ── Rule: security-owd-unset (origin: objectui#2348 incident) ──────── + it('errors on a custom object with no sharingModel — the leave_request shape', () => { + const findings = validateSecurityPosture({ + objects: [{ name: 'leave_request', label: 'Leave Request' }], + }); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'error', + rule: SECURITY_OWD_UNSET, + where: 'object "leave_request"', + }); + expect(findings[0].hint).toContain("'private'"); + }); + + it('does not flag system objects for unset OWD', () => { + expect(rulesOf({ objects: [{ name: 'sys_thing' }, { name: 'custom', isSystem: true }] })).toEqual([]); + }); + + it('honors sharingModel nested under security.*', () => { + expect( + rulesOf({ objects: [{ name: 'ok_obj', security: { sharingModel: 'private' } }] }), + ).toEqual([]); + }); + + // ── Rule: security-owd-alias (ADR-0090 D4) ─────────────────────────── + it('errors with a fix-it on retired alias values', () => { + const findings = validateSecurityPosture({ + objects: [{ name: 'a', sharingModel: 'read' }, { name: 'b', sharingModel: 'read_write' }], + }); + expect(findings.map((f) => f.rule)).toEqual([SECURITY_OWD_ALIAS, SECURITY_OWD_ALIAS]); + expect(findings[0].hint).toContain("'public_read'"); + expect(findings[1].hint).toContain("'public_read_write'"); + }); + + it('errors on unknown OWD values (runtime fails closed to private)', () => { + const findings = validateSecurityPosture({ objects: [{ name: 'a', sharingModel: 'everyone' }] }); + expect(findings[0].rule).toBe(SECURITY_OWD_ALIAS); + expect(findings[0].message).toContain("fails CLOSED to 'private'"); + }); + + // ── Rule: security-external-wider-than-internal (ADR-0090 D11) ────── + it('errors when the external dial is wider than the internal one', () => { + const findings = validateSecurityPosture({ + objects: [{ name: 'portal_case', sharingModel: 'private', externalSharingModel: 'public_read' }], + }); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(SECURITY_EXTERNAL_WIDER); + }); + + it('accepts external ≤ internal', () => { + expect( + rulesOf({ + objects: [ + { name: 'a', sharingModel: 'public_read_write', externalSharingModel: 'public_read' }, + { name: 'b', sharingModel: 'public_read', externalSharingModel: 'public_read' }, + ], + }), + ).toEqual([]); + }); + + // ── Rule: security-wildcard-vama (ADR-0066) ───────────────────────── + it("errors on a '*' wildcard carrying viewAll/modifyAll in an authored set", () => { + const findings = validateSecurityPosture({ + permissions: [ + { name: 'sneaky_admin', objects: { '*': { allowRead: true, viewAllRecords: true } } }, + ], + }); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(SECURITY_WILDCARD_VAMA); + }); + + it("tolerates a plain '*' read wildcard without VAMA", () => { + expect( + rulesOf({ permissions: [{ name: 'reader', objects: { '*': { allowRead: true } } }] }), + ).toEqual([]); + }); + + // ── Rule: security-anchor-high-privilege (ADR-0090 D5/D9) ─────────── + it('errors when an isDefault (everyone-suggested) set carries high-privilege bits', () => { + const findings = validateSecurityPosture({ + permissions: [ + { + name: 'app_default', + isDefault: true, + objects: { invoice: { allowRead: true, allowDelete: true } }, + }, + ], + }); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(SECURITY_ANCHOR_HIGH_PRIVILEGE); + expect(findings[0].message).toContain('everyone'); + }); + + it('accepts a low-privilege isDefault set', () => { + expect( + rulesOf({ + permissions: [ + { name: 'app_default', isDefault: true, objects: { invoice: { allowRead: true, allowCreate: true, allowEdit: true } } }, + ], + }), + ).toEqual([]); + }); + + // ── Rule: security-role-word (ADR-0090 D3) ────────────────────────── + it('errors on "role" in identifiers and labels across kinds', () => { + const findings = validateSecurityPosture({ + objects: [ + { + name: 'user_role', // identifier token + sharingModel: 'private', + fields: { role_name: { name: 'role_name', label: 'Role Name' } }, + }, + ], + permissions: [{ name: 'role_manager', label: 'Role Manager' }], + positions: [{ name: 'sales_rep', label: 'Sales Role' }], // label word + }); + const roleFindings = findings.filter((f) => f.rule === SECURITY_ROLE_WORD); + // object name, field name, permission set name, position label + expect(roleFindings).toHaveLength(4); + expect(roleFindings.every((f) => f.severity === 'error')).toBe(true); + }); + + it('does not flag words merely containing the letters (payroll, controlled)', () => { + expect( + rulesOf({ + objects: [ + { name: 'payroll_run', label: 'Payroll — Controlled Rollout', sharingModel: 'private' }, + ], + }), + ).toEqual([]); + }); + + it('skips system objects (better-auth sys_member.role is the documented exception)', () => { + expect( + rulesOf({ objects: [{ name: 'sys_member', fields: { role: { name: 'role', label: 'Role' } } }] }), + ).toEqual([]); + }); + + // ── Rule: security-private-no-readscope (info) ────────────────────── + it('emits info when a set grants plain read on a private object without depth', () => { + const findings = validateSecurityPosture({ + objects: [{ name: 'expense', sharingModel: 'private' }], + permissions: [{ name: 'finance_user', objects: { expense: { allowRead: true } } }], + }); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ severity: 'info', rule: SECURITY_PRIVATE_NO_READSCOPE }); + }); + + it('stays silent when depth or VAMA is declared, or the object is public', () => { + expect( + rulesOf({ + objects: [ + { name: 'expense', sharingModel: 'private' }, + { name: 'notice', sharingModel: 'public_read' }, + ], + permissions: [ + { name: 'a', objects: { expense: { allowRead: true, readScope: 'unit' } } }, + { name: 'b', objects: { expense: { allowRead: true, viewAllRecords: true } } }, + { name: 'c', objects: { notice: { allowRead: true } } }, + ], + }).filter((r) => r === SECURITY_PRIVATE_NO_READSCOPE), + ).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-security-posture.ts b/packages/lint/src/validate-security-posture.ts new file mode 100644 index 0000000000..27172f89de --- /dev/null +++ b/packages/lint/src/validate-security-posture.ts @@ -0,0 +1,339 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0090 D7] Security-domain publish linter. + * + * Every rule here is traceable to an observed failure class (the taxonomy + * grows by incident, per the ADR): + * + * | Rule | Origin | + * |-----------------------------------------|---------------------------------| + * | security-owd-unset (error) | objectui#2348 leave_request 事故 | + * | security-owd-alias (error) | ADR-0090 D4 canonical enum | + * | security-external-wider (error) | ADR-0090 D11 external ≤ internal| + * | security-wildcard-vama (error) | ADR-0066 superuser wildcard | + * | security-anchor-high-privilege(error) | ADR-0090 D5/D9 anchors | + * | security-role-word (error) | ADR-0090 D3 vocabulary freeze | + * | security-private-no-readscope (info) | admin-intent mismatch class | + * + * Per ADR-0049 discipline these are NOT advisory security: every `error` rule + * mirrors a runtime enforcement point (D1 fail-closed OWD default, D4 zod + * enum + fail-closed evaluator, D5/D9 anchor binding gate, D3 rename wave) — + * the lint moves the failure from runtime-deny to author-time fix-it. + * + * Pure `(stack) => Finding[]`; accepts the NORMALIZED stack input (works both + * pre- and post-zod-parse, so `os lint` catches what the zod gate would + * reject in `os compile` — with a better message). + */ + +import { describeAnchorForbiddenBits } from '@objectstack/spec/security'; + +export const SECURITY_OWD_UNSET = 'security-owd-unset'; +export const SECURITY_OWD_ALIAS = 'security-owd-alias'; +export const SECURITY_EXTERNAL_WIDER = 'security-external-wider-than-internal'; +export const SECURITY_WILDCARD_VAMA = 'security-wildcard-vama'; +export const SECURITY_ANCHOR_HIGH_PRIVILEGE = 'security-anchor-high-privilege'; +export const SECURITY_ROLE_WORD = 'security-role-word'; +export const SECURITY_PRIVATE_NO_READSCOPE = 'security-private-no-readscope'; + +export type SecuritySeverity = 'error' | 'warning' | 'info'; + +export interface SecurityFinding { + severity: SecuritySeverity; + /** Diagnostic rule id (`security-*`). */ + rule: string; + /** Human-readable location, e.g. `object "leave_request"`. */ + where: string; + /** Config path, e.g. `objects[3].sharingModel`. */ + path: string; + /** What is wrong. */ + message: string; + /** How to fix it. */ + hint: string; +} + +type AnyRec = Record; + +const CANONICAL_OWD = ['private', 'public_read', 'public_read_write', 'controlled_by_parent'] as const; +/** [ADR-0090 D4] Legacy alias → canonical fix-it mapping. */ +const OWD_ALIAS_FIX: Record = { + read: 'public_read', + read_write: 'public_read_write', + full: 'public_read_write', + public: 'public_read_write', +}; +/** D11 ordering for external ≤ internal (controlled_by_parent excluded). */ +const OWD_WIDTH: Record = { + private: 0, + public_read: 1, + public_read_write: 2, +}; + +/** Coerce a collection (array or name-keyed map) to an array of records. */ +function asArray(v: unknown): AnyRec[] { + if (Array.isArray(v)) return v as AnyRec[]; + if (v && typeof v === 'object') { + return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) })); + } + return []; +} + +function owdOf(obj: AnyRec): unknown { + return obj.sharingModel ?? (obj.security as AnyRec | undefined)?.sharingModel; +} + +function isSystemObject(obj: AnyRec): boolean { + return obj.isSystem === true || String(obj.name ?? '').startsWith('sys_'); +} + +/** snake_case identifier contains the reserved token `role`/`roles`. */ +function identifierHasRoleToken(name: unknown): boolean { + if (typeof name !== 'string') return false; + return name + .toLowerCase() + .split(/[^a-z0-9]+/) + .some((tok) => tok === 'role' || tok === 'roles'); +} + +/** Free-text label contains the whole word `role(s)` (case-insensitive). */ +function labelHasRoleWord(label: unknown): boolean { + if (typeof label !== 'string') return false; + return /\brole(s)?\b/i.test(label); +} + +/** + * Validate the security posture of a stack. Returns findings (empty = clean). + * `error` findings gate the build in `os compile`; `info` is advisory. + */ +export function validateSecurityPosture(stack: AnyRec): SecurityFinding[] { + const findings: SecurityFinding[] = []; + if (!stack || typeof stack !== 'object') return findings; + + const objects = asArray(stack.objects); + const permissionSets = asArray(stack.permissions); + + // ── D1/D4/D11: per-object OWD posture ──────────────────────────────── + for (let i = 0; i < objects.length; i++) { + const obj = objects[i]; + if (!obj || typeof obj !== 'object') continue; + const objName = typeof obj.name === 'string' ? obj.name : `(object ${i})`; + const objPath = `objects[${i}]`; + const owd = owdOf(obj); + const external = obj.externalSharingModel; + + if (!isSystemObject(obj)) { + if (owd == null) { + findings.push({ + severity: 'error', + rule: SECURITY_OWD_UNSET, + where: `object "${objName}"`, + path: `${objPath}.sharingModel`, + message: + `custom object "${objName}" declares no sharingModel (OWD). The runtime fails ` + + `CLOSED to 'private' (ADR-0090 D1), but the baseline must be an authored decision, ` + + `not an accident — this is the exact shape of the leave_request incident (objectui#2348).`, + hint: + `Declare sharingModel explicitly: 'private' (owner + shares; recommended default), ` + + `'public_read', 'public_read_write', or 'controlled_by_parent' (master-detail children).`, + }); + } else if (typeof owd === 'string' && OWD_ALIAS_FIX[owd]) { + findings.push({ + severity: 'error', + rule: SECURITY_OWD_ALIAS, + where: `object "${objName}"`, + path: `${objPath}.sharingModel`, + message: + `sharingModel '${owd}' is a retired alias (ADR-0090 D4). The runtime fails CLOSED ` + + `to 'private' on unknown values, so this object is NOT ${owd === 'read' ? 'readable' : 'writable'} org-wide.`, + hint: `Replace with the canonical value: sharingModel: '${OWD_ALIAS_FIX[owd]}'.`, + }); + } else if (typeof owd === 'string' && !(CANONICAL_OWD as readonly string[]).includes(owd)) { + findings.push({ + severity: 'error', + rule: SECURITY_OWD_ALIAS, + where: `object "${objName}"`, + path: `${objPath}.sharingModel`, + message: + `sharingModel '${owd}' is not a canonical OWD value; the runtime fails CLOSED to 'private'.`, + hint: `Use one of: ${CANONICAL_OWD.join(', ')}.`, + }); + } + } + + // D11: external dial present on any object (system included) must obey + // external ≤ internal. controlled_by_parent inherits the master's pair. + if (typeof external === 'string') { + if (OWD_ALIAS_FIX[external]) { + findings.push({ + severity: 'error', + rule: SECURITY_OWD_ALIAS, + where: `object "${objName}"`, + path: `${objPath}.externalSharingModel`, + message: `externalSharingModel '${external}' is a retired alias (ADR-0090 D4).`, + hint: `Replace with the canonical value: externalSharingModel: '${OWD_ALIAS_FIX[external]}'.`, + }); + } else if ( + typeof owd === 'string' && + external in OWD_WIDTH && + owd in OWD_WIDTH && + OWD_WIDTH[external] > OWD_WIDTH[owd] + ) { + findings.push({ + severity: 'error', + rule: SECURITY_EXTERNAL_WIDER, + where: `object "${objName}"`, + path: `${objPath}.externalSharingModel`, + message: + `externalSharingModel '${external}' is WIDER than the internal sharingModel '${owd}' — ` + + `the external baseline must never exceed the internal one (ADR-0090 D11).`, + hint: `Narrow externalSharingModel to '${owd}' or below (ordering: private < public_read < public_read_write).`, + }); + } + } + } + + // ── ADR-0066 / D5/D9: permission-set posture ───────────────────────── + for (let i = 0; i < permissionSets.length; i++) { + const ps = permissionSets[i]; + if (!ps || typeof ps !== 'object') continue; + const psName = typeof ps.name === 'string' ? ps.name : `(permission set ${i})`; + const psPath = `permissions[${i}]`; + const objectsMap = (ps.objects && typeof ps.objects === 'object' ? ps.objects : {}) as AnyRec; + + const wildcard = objectsMap['*'] as AnyRec | undefined; + if (wildcard && (wildcard.viewAllRecords === true || wildcard.modifyAllRecords === true)) { + findings.push({ + severity: 'error', + rule: SECURITY_WILDCARD_VAMA, + where: `permission set "${psName}"`, + path: `${psPath}.objects.*`, + message: + `'*' wildcard carrying View All / Modify All Data — a package-authored superuser. ` + + `Only the platform's own admin set may combine the wildcard with VAMA (ADR-0066).`, + hint: + `Enumerate the objects this set really needs, or drop viewAllRecords/modifyAllRecords ` + + `from the wildcard entry. App-level admins belong in an ordinary set the customer binds ` + + `to a position of their choosing (ADR-0090 D9).`, + }); + } + + // D5: an isDefault set is a SUGGESTED binding to the `everyone` anchor — + // hold it to the anchor tier at author time (the runtime gate enforces the + // same predicate at bind time; this moves the failure to the author). + if (ps.isDefault === true) { + const offending = describeAnchorForbiddenBits(ps, 'everyone'); + if (offending) { + findings.push({ + severity: 'error', + rule: SECURITY_ANCHOR_HIGH_PRIVILEGE, + where: `permission set "${psName}"`, + path: `${psPath}.isDefault`, + message: + `isDefault:true suggests binding this set to the 'everyone' audience anchor, but it ` + + `carries ${offending} — the runtime will refuse the binding (ADR-0090 D5/D9).`, + hint: + `Split the powerful bits into a separate set granted through ordinary positions, and ` + + `keep the everyone-suggested set low-privilege.`, + }); + } + } + } + + // ── D3: the word "role" is reserved-forbidden ──────────────────────── + // Scope: security-relevant identifiers/labels (objects, fields, actions, + // permission sets, positions, apps). Pages/views/components are NOT + // scanned — `role` there is HTML/ARIA semantics, not permission vocabulary. + // The sole platform exception (better-auth `sys_member.role`) is a system + // object, which app stacks never author. + const flagRole = (kind: string, name: unknown, label: unknown, where: string, path: string) => { + if (identifierHasRoleToken(name)) { + findings.push({ + severity: 'error', + rule: SECURITY_ROLE_WORD, + where, + path, + message: + `${kind} name "${String(name)}" uses the reserved word "role" — the platform vocabulary ` + + `is permission_set (capability), position (distribution), business_unit (hierarchy) (ADR-0090 D3).`, + hint: `Rename using 'position' for distribution groups or a domain word (e.g. 'function', 'duty').`, + }); + } else if (labelHasRoleWord(label)) { + findings.push({ + severity: 'error', + rule: SECURITY_ROLE_WORD, + where, + path: `${path.replace(/\.name$/, '')}.label`, + message: `${kind} label "${String(label)}" uses the reserved word "role" (ADR-0090 D3).`, + hint: `Relabel with 'Position' (distribution) or a domain word — admins must meet ONE vocabulary.`, + }); + } + }; + + for (let i = 0; i < objects.length; i++) { + const obj = objects[i]; + if (!obj || typeof obj !== 'object' || isSystemObject(obj)) continue; + const objName = typeof obj.name === 'string' ? obj.name : `(object ${i})`; + flagRole('object', obj.name, obj.label, `object "${objName}"`, `objects[${i}].name`); + for (const f of asArray(obj.fields)) { + flagRole('field', f.name, f.label, `field "${objName}.${String(f.name ?? '?')}"`, `objects[${i}].fields.${String(f.name ?? '?')}.name`); + } + for (const [ai, action] of asArray(obj.actions).entries()) { + flagRole('action', action.name, action.label, `action "${objName}.${String(action.name ?? '?')}"`, `objects[${i}].actions[${ai}].name`); + } + } + for (let i = 0; i < permissionSets.length; i++) { + const ps = permissionSets[i]; + if (!ps || typeof ps !== 'object') continue; + flagRole('permission set', ps.name, ps.label, `permission set "${String(ps.name ?? i)}"`, `permissions[${i}].name`); + } + for (const [i, pos] of asArray(stack.positions).entries()) { + flagRole('position', pos.name, pos.label, `position "${String(pos.name ?? i)}"`, `positions[${i}].name`); + } + for (const [i, app] of asArray(stack.apps).entries()) { + flagRole('app', app.name, app.label, `app "${String(app.name ?? i)}"`, `apps[${i}].name`); + } + + // ── Admin-intent mismatch: private object, plain read, no depth ────── + // An object whose baseline is private (explicit or D1-defaulted) where a set + // grants allowRead with neither readScope nor viewAllRecords: every reader + // sees ONLY their own records. Legitimate (personal to-dos) often enough + // that this stays `info` — but it is the #1 "why can't 李四 see the data" + // support class, so say it out loud at author time. + const privateObjects = new Set( + objects + .filter((o) => o && typeof o === 'object' && !isSystemObject(o)) + .filter((o) => { + const owd = owdOf(o); + return owd == null || owd === 'private'; + }) + .map((o) => String(o.name ?? '')), + ); + if (privateObjects.size > 0) { + for (let i = 0; i < permissionSets.length; i++) { + const ps = permissionSets[i]; + if (!ps || typeof ps !== 'object') continue; + const psName = typeof ps.name === 'string' ? ps.name : `(permission set ${i})`; + const objectsMap = (ps.objects && typeof ps.objects === 'object' ? ps.objects : {}) as AnyRec; + for (const [objName, rawPerm] of Object.entries(objectsMap)) { + if (!privateObjects.has(objName)) continue; + const p = (rawPerm ?? {}) as AnyRec; + if (p.allowRead === true && p.readScope == null && p.viewAllRecords !== true) { + findings.push({ + severity: 'info', + rule: SECURITY_PRIVATE_NO_READSCOPE, + where: `permission set "${psName}"`, + path: `permissions[${i}].objects.${objName}.readScope`, + message: + `"${objName}" is private (OWD) and this set grants allowRead without a readScope — ` + + `holders see ONLY records they own (plus explicit shares).`, + hint: + `If that is intended (personal data), ignore this. Otherwise add readScope: ` + + `'own_and_reports' | 'unit' | 'unit_and_below' | 'org', or widen the object's sharingModel.`, + }); + } + } + } + } + + return findings; +} 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 41cfea702c..d92df0763c 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -673,8 +673,8 @@ export const enObjects: NonNullable = { user_id: { label: "User" }, - role_in_business_unit: { - label: "Role in Business Unit", + function_in_business_unit: { + label: "Function 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 1db91e352b..949dc63ae8 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 @@ -673,8 +673,8 @@ export const esESObjects: NonNullable = { user_id: { label: "Usuario" }, - role_in_business_unit: { - label: "Rol en el departamento", + function_in_business_unit: { + label: "Función en la unidad de negocio", help: "`lead` es el responsable del día a día; `deputy` puede sustituir al responsable en el enrutamiento de aprobaciones.", options: { member: "Miembro", 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 eec7b774dd..ae704c8013 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 @@ -673,8 +673,8 @@ export const jaJPObjects: NonNullable = { user_id: { label: "ユーザー" }, - role_in_business_unit: { - label: "ビジネスユニット内ロール", + function_in_business_unit: { + label: "ビジネスユニット内の職能", help: "`lead` は日常の責任者、`deputy` は承認ルーティングでリードの代理を務める場合があります。", options: { member: "メンバー", 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 ad7fcb943f..39cb85a361 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 @@ -673,8 +673,8 @@ export const zhCNObjects: NonNullable = { user_id: { label: "用户" }, - role_in_business_unit: { - label: "业务单元内角色", + function_in_business_unit: { + label: "业务单元内职能", help: "`lead` 表示日常负责人;`deputy` 可在审批路由中代替负责人。", options: { member: "成员", diff --git a/packages/platform-objects/src/identity/sys-business-unit-member.object.ts b/packages/platform-objects/src/identity/sys-business-unit-member.object.ts index 3337ea158d..8e0066eae8 100644 --- a/packages/platform-objects/src/identity/sys-business-unit-member.object.ts +++ b/packages/platform-objects/src/identity/sys-business-unit-member.object.ts @@ -23,7 +23,7 @@ export const SysBusinessUnitMember = ObjectSchema.create({ managedBy: 'platform', description: 'User assignment to a business unit (matrix-org friendly, effective-dated).', titleFormat: '{user_id} in {business_unit_id}', - highlightFields: ['user_id', 'business_unit_id', 'role_in_business_unit', 'is_primary'], + highlightFields: ['user_id', 'business_unit_id', 'function_in_business_unit', 'is_primary'], fields: { id: Field.text({ @@ -45,10 +45,10 @@ export const SysBusinessUnitMember = ObjectSchema.create({ group: 'Assignment', }), - role_in_business_unit: Field.select( + function_in_business_unit: Field.select( ['member', 'lead', 'deputy'], { - label: 'Role in Business Unit', + label: 'Function 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.', diff --git a/packages/plugins/plugin-security/scripts/i18n-extract.config.ts b/packages/plugins/plugin-security/scripts/i18n-extract.config.ts index ba77d7d737..fea4fc1835 100644 --- a/packages/plugins/plugin-security/scripts/i18n-extract.config.ts +++ b/packages/plugins/plugin-security/scripts/i18n-extract.config.ts @@ -13,7 +13,7 @@ */ import { defineStack } from '@objectstack/spec'; -import { SysRole, SysPermissionSet, SysUserPermissionSet, SysRolePermissionSet } from '../src/objects/index.js'; +import { SysPosition, SysPermissionSet, SysUserPermissionSet, SysPositionPermissionSet } from '../src/objects/index.js'; import { enObjects } from '../src/translations/en.objects.generated.js'; import { zhCNObjects } from '../src/translations/zh-CN.objects.generated.js'; import { jaJPObjects } from '../src/translations/ja-JP.objects.generated.js'; @@ -21,7 +21,7 @@ import { esESObjects } from '../src/translations/es-ES.objects.generated.js'; export default defineStack({ name: 'plugin-security-i18n-extract', - objects: [SysRole, SysPermissionSet, SysUserPermissionSet, SysRolePermissionSet] as any, + objects: [SysPosition, SysPermissionSet, SysUserPermissionSet, SysPositionPermissionSet] as any, translations: [ { en: { objects: enObjects } }, { 'zh-CN': { objects: zhCNObjects } }, diff --git a/packages/plugins/plugin-security/src/audience-anchors.test.ts b/packages/plugins/plugin-security/src/audience-anchors.test.ts index a3adae4e78..8cc9d6a994 100644 --- a/packages/plugins/plugin-security/src/audience-anchors.test.ts +++ b/packages/plugins/plugin-security/src/audience-anchors.test.ts @@ -3,6 +3,7 @@ // high-privilege binding gate. import { describe, it, expect } from 'vitest'; +import { describeAnchorForbiddenBits } from '@objectstack/spec/security'; import { bootstrapBuiltinRoles } from './bootstrap-builtin-positions'; import { describeHighPrivilegeBits } from './security-plugin'; @@ -76,3 +77,26 @@ describe('describeHighPrivilegeBits (anchor-binding predicate)', () => { expect(describeHighPrivilegeBits({ objects: JSON.stringify({ a: { allowRead: true } }) })).toBeNull(); }); }); + +describe('describeAnchorForbiddenBits (ADR-0090 D9 anchor tiers)', () => { + it('guest faces the strictest tier: edit bits are refused on top of the high-privilege set', () => { + const editSet = { objects: { helpdesk_ticket: { allowRead: true, allowEdit: true } } }; + expect(describeAnchorForbiddenBits(editSet, 'everyone')).toBeNull(); // everyone: edit OK + expect(describeAnchorForbiddenBits(editSet, 'guest')).toMatch(/read-only/); // guest: refused + }); + + it('guest allows read + case-by-case create (public form intake shape)', () => { + expect( + describeAnchorForbiddenBits( + { objects: { form_submission: { allowRead: true, allowCreate: true } } }, + 'guest', + ), + ).toBeNull(); + }); + + it('high-privilege bits stay refused for BOTH anchors', () => { + const vama = { objects: { a: { viewAllRecords: true } } }; + expect(describeAnchorForbiddenBits(vama, 'everyone')).toMatch(/View\/Modify All/); + expect(describeAnchorForbiddenBits(vama, 'guest')).toMatch(/View\/Modify All/); + }); +}); diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts b/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts index 2425fdb477..30b85236db 100644 --- a/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts +++ b/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts @@ -82,6 +82,9 @@ function toRowFields(ps: any): Record { system_permissions: JSON.stringify(ps.systemPermissions ?? []), row_level_security: JSON.stringify(ps.rowLevelSecurity ?? []), tab_permissions: JSON.stringify(ps.tabPermissions ?? {}), + // [ADR-0090 D12] Delegated-admin scope travels with the set row so the + // delegated-admin gate can resolve a DB-loaded delegate's authority. + admin_scope: ps.adminScope ? JSON.stringify(ps.adminScope) : null, }; } diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts index 3dd0e1a355..5fc2b8d389 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts @@ -106,6 +106,8 @@ export async function bootstrapPlatformAdmin( system_permissions: JSON.stringify(ps.systemPermissions ?? []), row_level_security: JSON.stringify(ps.rowLevelSecurity ?? []), tab_permissions: JSON.stringify(ps.tabPermissions ?? {}), + // [ADR-0090 D12] Delegated-admin scope travels with the set row. + admin_scope: (ps as any).adminScope ? JSON.stringify((ps as any).adminScope) : null, active: true, }); if (created?.id) seeded[ps.name] = created.id; diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts new file mode 100644 index 0000000000..63a38432d3 --- /dev/null +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts @@ -0,0 +1,320 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// ADR-0090 D12 — delegated administration: scoped admin, no self-escalation. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { DelegatedAdminGate } from './delegated-admin-gate'; + +/** + * Fixture topology + * + * hq (bu_hq) + * ├── east (bu_east) ← delegate's subtree root + * │ └── east_sales (bu_es) + * └── west (bu_west) + * + * Permission sets: sales_user (allowlisted, plain), finance_admin (NOT + * allowlisted), sub_admin (carries the delegate's adminScope), admin_full + * (tenant superuser wildcard). + * Positions: sales_rep → [sales_user]; mixed_pos → [sales_user, finance_admin]; + * everyone (anchor). + */ + +const EAST_SCOPE = { + businessUnit: 'east', + includeSubtree: true, + manageAssignments: true, + manageBindings: true, + authorEnvironmentSets: true, + assignablePermissionSets: ['sales_user', 'sub_admin'], +}; + +function makeHarness() { + const tables: Record = { + sys_business_unit: [ + { id: 'bu_hq', name: 'hq', parent_business_unit_id: null }, + { id: 'bu_east', name: 'east', parent_business_unit_id: 'bu_hq' }, + { id: 'bu_es', name: 'east_sales', parent_business_unit_id: 'bu_east' }, + { id: 'bu_west', name: 'west', parent_business_unit_id: 'bu_hq' }, + ], + sys_position: [ + { id: 'pos_sales', name: 'sales_rep' }, + { id: 'pos_mixed', name: 'mixed_pos' }, + { id: 'pos_everyone', name: 'everyone' }, + ], + sys_permission_set: [ + { id: 'ps_sales', name: 'sales_user' }, + { id: 'ps_fin', name: 'finance_admin' }, + { id: 'ps_sub', name: 'sub_admin', admin_scope: JSON.stringify(EAST_SCOPE) }, + ], + sys_position_permission_set: [ + { id: 'b1', position_id: 'pos_sales', permission_set_id: 'ps_sales' }, + { id: 'b2', position_id: 'pos_mixed', permission_set_id: 'ps_sales' }, + { id: 'b3', position_id: 'pos_mixed', permission_set_id: 'ps_fin' }, + ], + sys_user_position: [ + { id: 'a_prev', user_id: 'u_east_1', position: 'sales_rep', business_unit_id: 'bu_es' }, + ], + sys_business_unit_member: [ + { id: 'm1', business_unit_id: 'bu_es', user_id: 'u_east_1' }, + { id: 'm2', business_unit_id: 'bu_west', user_id: 'u_west_1' }, + ], + sys_user: [ + { id: 'u_delegate' }, { id: 'u_east_1' }, { id: 'u_west_1' }, + ], + }; + + const matches = (row: any, where: any): boolean => + Object.entries(where ?? {}).every(([k, v]) => { + if (v && typeof v === 'object' && Array.isArray((v as any).$in)) { + return (v as any).$in.includes(row[k]); + } + return row[k] === v; + }); + + const ql = { + tables, + async find(object: string, opts: any) { + const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); + return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; + }, + async findOne(object: string, opts: any) { + const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); + return rows[0] ?? null; + }, + } as any; + + // Resolved permission sets per principal — mirrors what + // resolvePermissionSetsForContext would return for each context. + const SETS: Record = { + tenant_admin: [{ name: 'admin_full', objects: { '*': { allowRead: true, modifyAllRecords: true } } }], + delegate: [ + { name: 'member_default', objects: {} }, + { name: 'sub_admin', objects: { sys_user_position: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, adminScope: EAST_SCOPE }, + ], + crud_only: [{ name: 'rbac_crud', objects: { sys_user_position: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } } }], + }; + + const gate = new DelegatedAdminGate({ + ql, + resolveSets: async (context: any) => SETS[context?.principal ?? ''] ?? [], + }); + + const ctxOf = (principal: string, userId = `u_${principal}`) => ({ principal, userId, positions: [principal] }); + return { gate, ql, tables, ctxOf }; +} + +let h: ReturnType; +beforeEach(() => { h = makeHarness(); }); + +const insertAssignment = (ctx: any, row: any) => h.gate.assert({ + object: 'sys_user_position', operation: 'insert', data: row, context: ctx, +}); + +describe('DelegatedAdminGate — tenant admins and outsiders', () => { + it('tenant-level admin (superuser wildcard) passes untouched', async () => { + await expect(insertAssignment(h.ctxOf('tenant_admin'), { + user_id: 'u_west_1', position: 'mixed_pos', business_unit_id: 'bu_west', + })).resolves.toBeUndefined(); + }); + + it('plain CRUD on RBAC tables no longer makes a permission administrator', async () => { + await expect(insertAssignment(h.ctxOf('crud_only'), { + user_id: 'u_east_1', position: 'sales_rep', business_unit_id: 'bu_es', + })).rejects.toThrow(/delegated adminScope/); + }); + + it('principal-less non-system writes to RBAC tables fail closed', async () => { + await expect(insertAssignment({}, { + user_id: 'u_east_1', position: 'sales_rep', business_unit_id: 'bu_es', + })).rejects.toThrow(/authenticated administrator/); + }); + + it('reads and non-governed objects are untouched', async () => { + await expect(h.gate.assert({ object: 'sys_user_position', operation: 'find', context: {} })) + .resolves.toBeUndefined(); + await expect(h.gate.assert({ object: 'task', operation: 'insert', data: {}, context: {} })) + .resolves.toBeUndefined(); + }); +}); + +describe('DelegatedAdminGate — assignments (sys_user_position)', () => { + it('delegate assigns an allowlisted position inside the subtree; granted_by is stamped', async () => { + const row: any = { user_id: 'u_east_1', position: 'sales_rep', business_unit_id: 'bu_es' }; + await expect(insertAssignment(h.ctxOf('delegate'), row)).resolves.toBeUndefined(); + expect(row.granted_by).toBe('u_delegate'); // audit stamp + }); + + it('denies when the position distributes a set outside the allowlist', async () => { + await expect(insertAssignment(h.ctxOf('delegate'), { + user_id: 'u_east_1', position: 'mixed_pos', business_unit_id: 'bu_es', + })).rejects.toThrow(/finance_admin.*not in the scope's allowlist/); + }); + + it('denies assignments anchored outside the subtree', async () => { + await expect(insertAssignment(h.ctxOf('delegate'), { + user_id: 'u_west_1', position: 'sales_rep', business_unit_id: 'bu_west', + })).rejects.toThrow(/outside the delegated subtree/); + }); + + it('denies unanchored assignments (no business_unit_id)', async () => { + await expect(insertAssignment(h.ctxOf('delegate'), { + user_id: 'u_east_1', position: 'sales_rep', + })).rejects.toThrow(/no business_unit_id anchor/); + }); + + it('no self-carve-out: delegate self-assigning a non-allowlisted position is denied', async () => { + await expect(insertAssignment(h.ctxOf('delegate'), { + user_id: 'u_delegate', position: 'mixed_pos', business_unit_id: 'bu_es', + })).rejects.toThrow(/allowlist/); + }); + + it('anchors are never assignable — for delegates AND tenant admins', async () => { + for (const principal of ['delegate', 'tenant_admin']) { + await expect(insertAssignment(h.ctxOf(principal), { + user_id: 'u_east_1', position: 'everyone', business_unit_id: 'bu_es', + })).rejects.toThrow(/audience anchor is implicit/); + } + }); + + it('update cannot move an assignment out of the subtree', async () => { + await expect(h.gate.assert({ + object: 'sys_user_position', operation: 'update', + data: { id: 'a_prev', business_unit_id: 'bu_west' }, + context: h.ctxOf('delegate'), + })).rejects.toThrow(/outside the delegated subtree/); + }); + + it('delete of an in-subtree assignment is allowed; filter writes are not', async () => { + await expect(h.gate.assert({ + object: 'sys_user_position', operation: 'delete', + options: { where: { id: 'a_prev' } }, + context: h.ctxOf('delegate'), + })).resolves.toBeUndefined(); + + await expect(h.gate.assert({ + object: 'sys_user_position', operation: 'delete', + options: { where: { position: 'sales_rep' } }, + context: h.ctxOf('delegate'), + })).rejects.toThrow(/single rows by id/); + }); +}); + +describe('DelegatedAdminGate — bindings (sys_position_permission_set)', () => { + it('delegate binds an allowlisted set to a position held only inside the subtree', async () => { + await expect(h.gate.assert({ + object: 'sys_position_permission_set', operation: 'insert', + data: { position_id: 'pos_sales', permission_set_id: 'ps_sales' }, + context: h.ctxOf('delegate'), + })).resolves.toBeUndefined(); + }); + + it('denies binding a non-allowlisted set', async () => { + await expect(h.gate.assert({ + object: 'sys_position_permission_set', operation: 'insert', + data: { position_id: 'pos_sales', permission_set_id: 'ps_fin' }, + context: h.ctxOf('delegate'), + })).rejects.toThrow(/not in the scope's allowlist/); + }); + + it('denies re-composing a position held outside the subtree (blast radius)', async () => { + h.tables.sys_user_position.push({ id: 'a_w', user_id: 'u_west_1', position: 'sales_rep', business_unit_id: 'bu_west' }); + await expect(h.gate.assert({ + object: 'sys_position_permission_set', operation: 'insert', + data: { position_id: 'pos_sales', permission_set_id: 'ps_sales' }, + context: h.ctxOf('delegate'), + })).rejects.toThrow(/outside the delegated subtree/); + }); + + it('audience-anchor bindings are tenant-level only for delegates', async () => { + await expect(h.gate.assert({ + object: 'sys_position_permission_set', operation: 'insert', + data: { position_id: 'pos_everyone', permission_set_id: 'ps_sales' }, + context: h.ctxOf('delegate'), + })).rejects.toThrow(/tenant-level only/); + }); +}); + +describe('DelegatedAdminGate — direct grants (sys_user_permission_set)', () => { + it('delegate grants an allowlisted set to a user inside the subtree; granted_by stamped', async () => { + const row: any = { user_id: 'u_east_1', permission_set_id: 'ps_sales' }; + await expect(h.gate.assert({ + object: 'sys_user_permission_set', operation: 'insert', data: row, context: h.ctxOf('delegate'), + })).resolves.toBeUndefined(); + expect(row.granted_by).toBe('u_delegate'); + }); + + it('denies grants to users outside the subtree', async () => { + await expect(h.gate.assert({ + object: 'sys_user_permission_set', operation: 'insert', + data: { user_id: 'u_west_1', permission_set_id: 'ps_sales' }, + context: h.ctxOf('delegate'), + })).rejects.toThrow(/outside the delegated subtree/); + }); + + it('denies non-allowlisted sets — including to the delegate themselves', async () => { + await expect(h.gate.assert({ + object: 'sys_user_permission_set', operation: 'insert', + data: { user_id: 'u_delegate', permission_set_id: 'ps_fin' }, + context: h.ctxOf('delegate'), + })).rejects.toThrow(/not in the scope's allowlist/); + }); + + it('granting a set that carries an adminScope requires STRICT containment (equal scope refused)', async () => { + // sub_admin carries the delegate's own EXACT scope — lateral propagation banned. + await expect(h.gate.assert({ + object: 'sys_user_permission_set', operation: 'insert', + data: { user_id: 'u_east_1', permission_set_id: 'ps_sub' }, + context: h.ctxOf('delegate'), + })).rejects.toThrow(/not strictly contained/); + }); +}); + +describe('DelegatedAdminGate — env-set authoring (sys_permission_set)', () => { + it('delegate with authorEnvironmentSets may insert an inert env set', async () => { + await expect(h.gate.assert({ + object: 'sys_permission_set', operation: 'insert', + data: { name: 'east_helper', object_permissions: '{}' }, + context: h.ctxOf('delegate'), + })).resolves.toBeUndefined(); + }); + + it('authoring a set that mints a NARROWER adminScope is allowed (strict containment)', async () => { + await expect(h.gate.assert({ + object: 'sys_permission_set', operation: 'insert', + data: { + name: 'east_sales_admin', + admin_scope: JSON.stringify({ + businessUnit: 'east_sales', + manageAssignments: true, + assignablePermissionSets: ['sales_user'], + }), + }, + context: h.ctxOf('delegate'), + })).resolves.toBeUndefined(); + }); + + it('authoring a scope equal to or broader than your own is denied', async () => { + await expect(h.gate.assert({ + object: 'sys_permission_set', operation: 'insert', + data: { name: 'clone_of_mine', admin_scope: JSON.stringify(EAST_SCOPE) }, + context: h.ctxOf('delegate'), + })).rejects.toThrow(/strictly contains it/); + + await expect(h.gate.assert({ + object: 'sys_permission_set', operation: 'insert', + data: { + name: 'hq_takeover', + admin_scope: JSON.stringify({ ...EAST_SCOPE, businessUnit: 'hq' }), + }, + context: h.ctxOf('delegate'), + })).rejects.toThrow(/strictly contains it/); + }); + + it('delegates cannot set the tenant-wide isDefault suggestion', async () => { + await expect(h.gate.assert({ + object: 'sys_permission_set', operation: 'insert', + data: { name: 'east_default', isDefault: true }, + context: h.ctxOf('delegate'), + })).rejects.toThrow(/tenant-level/); + }); +}); diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.ts new file mode 100644 index 0000000000..38be63291d --- /dev/null +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.ts @@ -0,0 +1,654 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0090 D12] Delegated-administration write gate. + * + * "Administration itself becomes a scoped capability." Today whoever can + * write the RBAC link tables can manage ALL permissions; this gate turns + * those writes into a governed operation: + * + * - TENANT-LEVEL ADMINS (a resolved set carries the ADR-0066 superuser + * wildcard `objects['*'].modifyAllRecords`) keep the status quo — the gate + * passes and the ordinary CRUD/RLS checks decide (note the built-in + * `organization_admin` also passes here but is still denied downstream by + * its explicit per-table `allowEdit:false` overrides — unchanged). + * - DELEGATES (non-tenant-admins holding ≥1 `adminScope`) may write ONLY + * what one of their scopes fully approves: the right kind of action + * (manageAssignments / manageBindings / authorEnvironmentSets), inside + * their BU subtree, handing out only allowlisted sets, with STRICT + * containment whenever the touched set itself carries an adminScope. + * - EVERYONE ELSE is denied — holding plain CRUD on `sys_user_position` + * no longer makes you a permission administrator (fail closed, D12). + * + * The `everyone` / `guest` audience anchors stay tenant-level only: no + * delegated scope can touch their bindings, and DIRECT position assignments + * to an anchor are rejected for every caller (anchors are implicit — a + * stored assignment row is a modeling error). + * + * System/boot writes carry `isSystem` and short-circuit the security + * middleware before this gate (seeders, publish materializer, better-auth + * reconciliation are unaffected). + */ + +import type { AdminScope, PermissionSet } from '@objectstack/spec/security'; +import { PermissionDeniedError } from './errors.js'; + +const SYSTEM_CTX = { isSystem: true } as const; +/** Max BU-tree depth walked when expanding a scope subtree (safety bound). */ +const MAX_TREE_DEPTH = 32; +/** Max existing assignments examined for a binding blast-radius check. */ +const BLAST_RADIUS_CAP = 500; + +const GOVERNED_OBJECTS = new Set([ + 'sys_user_position', + 'sys_position_permission_set', + 'sys_user_permission_set', + 'sys_permission_set', +]); +const GOVERNED_OPERATIONS = new Set(['insert', 'update', 'delete', 'transfer', 'restore', 'purge']); +const ANCHOR_POSITIONS = new Set(['everyone', 'guest']); + +export interface DelegatedAdminGateDeps { + /** ObjectQL engine handle (system-context reads for pre-images/lookups). */ + ql: any; + /** Shared permission-set resolution (same path as the CRUD middleware). */ + resolveSets: (context: any) => Promise; + logger?: { warn?: (msg: string, meta?: any) => void }; +} + +interface HeldScope { + /** The set that carries the scope (for error messages). */ + setName: string; + scope: AdminScope & { assignablePermissionSets: string[] }; + /** Resolved BU ids covered (root + descendants when includeSubtree). Empty = misconfigured → approves nothing. */ + subtree: Set; +} + +function rowsOf(opCtx: any): any[] { + const d = opCtx?.data; + if (Array.isArray(d)) return d; + if (d && typeof d === 'object') return [d]; + return []; +} + +function parseMaybeJson(v: unknown): any { + if (typeof v !== 'string') return v; + try { return JSON.parse(v); } catch { return undefined; } +} + +/** ADR-0066 tenant-level admin: a resolved set whose '*' entry carries modifyAllRecords. */ +function isTenantAdmin(sets: PermissionSet[]): boolean { + for (const ps of sets) { + const objects: any = parseMaybeJson((ps as any).objects) ?? {}; + const wildcard = objects?.['*']; + if (wildcard && wildcard.modifyAllRecords === true) return true; + } + return false; +} + +/** Single scalar id from an update/delete opCtx (mirrors the engine's single-id rule). */ +function extractSingleId(opCtx: any): string | number | null { + const isScalar = (v: unknown): v is string | number => + v !== null && (typeof v === 'string' || typeof v === 'number'); + const data = opCtx?.data; + if (data && typeof data === 'object' && !Array.isArray(data) && isScalar(data.id)) return data.id; + const whereId = opCtx?.options?.where?.id ?? opCtx?.where?.id ?? opCtx?.id; + return isScalar(whereId) ? whereId : null; +} + +export class DelegatedAdminGate { + constructor(private readonly deps: DelegatedAdminGateDeps) {} + + /** Per-call caches (BU subtrees, position bindings) live on the instance + * only for the duration of one assert — recreated each call for freshness. */ + + async assert(opCtx: any): Promise { + if (!GOVERNED_OBJECTS.has(opCtx?.object)) return; + if (!GOVERNED_OPERATIONS.has(opCtx?.operation)) return; + + const ctx = opCtx.context ?? {}; + + // ── Unconditional invariant: no stored assignments to audience anchors — + // they are implicit for whole principal classes (ADR-0090 D5/D9), so a + // row is at best inert and at worst a privilege-mask. Applies to every + // caller, tenant admins included (boot/system short-circuited earlier). + if (opCtx.object === 'sys_user_position' && ['insert', 'update'].includes(opCtx.operation)) { + for (const row of rowsOf(opCtx)) { + const pos = String(row?.position ?? ''); + if (ANCHOR_POSITIONS.has(pos)) { + throw new PermissionDeniedError( + `[Security] Access denied: the '${pos}' audience anchor is implicit — it cannot be ` + + `assigned via sys_user_position (ADR-0090 D9). Authenticated principals hold ` + + `'everyone' and anonymous principals hold 'guest' automatically.`, + { operation: opCtx.operation, object: opCtx.object, position: pos }, + ); + } + } + } + + // ── Principal-less non-system writes to RBAC tables: fail CLOSED. ── + if (!ctx.userId) { + throw new PermissionDeniedError( + `[Security] Access denied: '${opCtx.operation}' on '${opCtx.object}' requires an ` + + `authenticated administrator (ADR-0090 D12 — administration is a scoped capability).`, + { operation: opCtx.operation, object: opCtx.object }, + ); + } + + let sets: PermissionSet[] = []; + try { + sets = await this.deps.resolveSets(ctx); + } catch { + sets = []; // resolution failure → treated as no authority (fail closed) + } + + if (isTenantAdmin(sets)) return; // status quo — downstream CRUD/RLS decide + + const held = await this.resolveHeldScopes(sets); + if (held.length === 0) { + throw new PermissionDeniedError( + `[Security] Access denied: '${opCtx.operation}' on '${opCtx.object}' requires tenant-level ` + + `administration or a delegated adminScope (ADR-0090 D12) — plain CRUD grants on RBAC ` + + `tables do not make a permission administrator.`, + { operation: opCtx.operation, object: opCtx.object, userId: ctx.userId }, + ); + } + + // Delegates may not run filter-writes — a mutation the gate cannot + // attribute to ONE pre-imaged row cannot be boundary-checked (a broad + // `where` with a patch payload would slip the subtree check otherwise). + // Supported delegate shapes: insert with payload rows, or single-row + // update/delete by scalar id. + const isMutationWithoutId = ['update', 'delete', 'transfer', 'restore', 'purge'].includes(opCtx.operation) + && extractSingleId(opCtx) == null; + if (isMutationWithoutId) { + throw new PermissionDeniedError( + `[Security] Access denied: delegated administrators must target single rows by id on ` + + `'${opCtx.object}' — filter writes cannot be checked against a delegation boundary.`, + { operation: opCtx.operation, object: opCtx.object }, + ); + } + + switch (opCtx.object) { + case 'sys_user_position': + return this.assertAssignmentWrite(opCtx, ctx, held); + case 'sys_user_permission_set': + return this.assertDirectGrantWrite(opCtx, ctx, held); + case 'sys_position_permission_set': + return this.assertBindingWrite(opCtx, held); + case 'sys_permission_set': + return this.assertSetAuthoring(opCtx, held); + } + } + + // ── sys_user_position: user ↔ position assignments ────────────────── + + private async assertAssignmentWrite(opCtx: any, ctx: any, held: HeldScope[]): Promise { + const targets = await this.materializeTargets(opCtx, 'sys_user_position'); + for (const t of targets) { + const buId = t.next?.business_unit_id ?? null; + const positionName = String(t.next?.position ?? t.prev?.position ?? ''); + const boundSets = positionName ? await this.setsBoundToPosition(positionName) : []; + + const failure = this.firstApprovalFailure(held, (s) => { + if (!s.scope.manageAssignments) return 'the scope does not grant manageAssignments'; + // New/updated rows must be anchored inside the delegate's subtree. + if (t.next) { + if (!buId) return 'the assignment has no business_unit_id anchor — delegated assignments must target your subtree (ADR-0090 Addendum)'; + if (!s.subtree.has(String(buId))) return `business unit '${buId}' is outside the delegated subtree`; + } + // Pre-image (update/delete) must also lie inside the subtree — a + // delegate can neither capture nor evict assignments beyond it. + if (t.prev) { + const prevBu = t.prev.business_unit_id ?? null; + if (!prevBu) return 'the existing assignment is unanchored (no business_unit_id) — only a tenant admin may modify it'; + if (!s.subtree.has(String(prevBu))) return `the existing assignment's business unit '${prevBu}' is outside the delegated subtree`; + } + // Every set the position distributes must be allowlisted. + for (const bound of boundSets) { + if (!s.scope.assignablePermissionSets.includes(bound.name)) { + return `position '${positionName}' distributes permission set '${bound.name}', which is not in the scope's allowlist`; + } + const contained = this.assertScopeGrantContainment(bound, held, /*dryRun*/ true); + if (contained) return contained; + } + return null; + }); + if (failure) { + throw new PermissionDeniedError( + `[Security] Access denied: delegated '${opCtx.operation}' on sys_user_position rejected — ${failure}.`, + { operation: opCtx.operation, object: opCtx.object, position: positionName }, + ); + } + // Audit stamp: who granted this (insert only; never overwrite an explicit value). + if (opCtx.operation === 'insert' && t.next && t.next.granted_by == null && ctx.userId) { + t.next.granted_by = ctx.userId; + } + } + } + + // ── sys_user_permission_set: direct user ↔ set grants ─────────────── + + private async assertDirectGrantWrite(opCtx: any, ctx: any, held: HeldScope[]): Promise { + const targets = await this.materializeTargets(opCtx, 'sys_user_permission_set'); + for (const t of targets) { + const row = t.next ?? t.prev ?? {}; + const setRow = await this.loadSetRowById(row.permission_set_id); + const setName = String(setRow?.name ?? row.permission_set_id ?? ''); + const targetUserId = row.user_id ? String(row.user_id) : null; + const userBUs = targetUserId ? await this.businessUnitsOfUser(targetUserId) : new Set(); + + const failure = this.firstApprovalFailure(held, (s) => { + if (!s.scope.manageAssignments) return 'the scope does not grant manageAssignments'; + if (!s.scope.assignablePermissionSets.includes(setName)) { + return `permission set '${setName}' is not in the scope's allowlist`; + } + if (!targetUserId) return 'the grant names no target user'; + if (userBUs.size === 0) return `target user '${targetUserId}' has no business-unit membership — only a tenant admin may grant outside the tree`; + let inSubtree = false; + for (const bu of userBUs) if (s.subtree.has(bu)) { inSubtree = true; break; } + if (!inSubtree) return `target user '${targetUserId}' is outside the delegated subtree`; + if (setRow) { + const contained = this.assertScopeGrantContainment(setRow, held, true); + if (contained) return contained; + } + return null; + }); + if (failure) { + throw new PermissionDeniedError( + `[Security] Access denied: delegated '${opCtx.operation}' on sys_user_permission_set rejected — ${failure}.`, + { operation: opCtx.operation, object: opCtx.object, permissionSet: setName }, + ); + } + if (opCtx.operation === 'insert' && t.next && t.next.granted_by == null && ctx.userId) { + t.next.granted_by = ctx.userId; + } + } + } + + // ── sys_position_permission_set: position ↔ set bindings ──────────── + + private async assertBindingWrite(opCtx: any, held: HeldScope[]): Promise { + const targets = await this.materializeTargets(opCtx, 'sys_position_permission_set'); + for (const t of targets) { + const row = t.next ?? t.prev ?? {}; + const positionName = await this.positionNameById(row.position_id); + if (ANCHOR_POSITIONS.has(positionName)) { + throw new PermissionDeniedError( + `[Security] Access denied: bindings of the '${positionName}' audience anchor are ` + + `tenant-level only — no delegated scope can touch them (ADR-0090 D12).`, + { operation: opCtx.operation, object: opCtx.object, position: positionName }, + ); + } + const setRow = await this.loadSetRowById(row.permission_set_id); + const setName = String(setRow?.name ?? row.permission_set_id ?? ''); + const radius = positionName ? await this.assignmentAnchorsOfPosition(positionName) : { anchors: new Set(), overCap: false, unanchored: 0 }; + + const failure = this.firstApprovalFailure(held, (s) => { + if (!s.scope.manageBindings) return 'the scope does not grant manageBindings'; + if (!s.scope.assignablePermissionSets.includes(setName)) { + return `permission set '${setName}' is not in the scope's allowlist`; + } + // Blast radius: re-composing a position re-composes EVERY holder's + // capability. A delegate may only do that when all current holders + // sit inside their subtree (fail closed on unanchored/over-cap). + if (radius.overCap) return `position '${positionName}' has more than ${BLAST_RADIUS_CAP} assignments — only a tenant admin may re-compose it`; + if (radius.unanchored > 0) return `position '${positionName}' has ${radius.unanchored} unanchored assignment(s) (no business_unit_id) — only a tenant admin may re-compose it`; + for (const bu of radius.anchors) { + if (!s.subtree.has(bu)) return `position '${positionName}' is held in business unit '${bu}', outside the delegated subtree`; + } + if (setRow) { + const contained = this.assertScopeGrantContainment(setRow, held, true); + if (contained) return contained; + } + return null; + }); + if (failure) { + throw new PermissionDeniedError( + `[Security] Access denied: delegated '${opCtx.operation}' on sys_position_permission_set rejected — ${failure}.`, + { operation: opCtx.operation, object: opCtx.object, position: positionName, permissionSet: setName }, + ); + } + } + } + + // ── sys_permission_set: environment-set authoring ──────────────────── + + private async assertSetAuthoring(opCtx: any, held: HeldScope[]): Promise { + // Package-managed rows were already rejected by the two-doors gate; what + // reaches here is environment-owned authoring. + const targets = await this.materializeTargets(opCtx, 'sys_permission_set'); + for (const t of targets) { + const payload = t.next ?? {}; + const existing = t.prev ?? null; + const setName = String(payload.name ?? existing?.name ?? ''); + const authoredScope: AdminScope | undefined = + parseMaybeJson(payload.admin_scope ?? payload.adminScope) ?? undefined; + + const failure = this.firstApprovalFailure(held, (s) => { + if (!s.scope.authorEnvironmentSets) return 'the scope does not grant authorEnvironmentSets'; + // Mutating/deleting an existing env set is only in-domain when the + // delegate distributes it (allowlist); fresh inserts are inert until + // distributed, so they pass this check. + if (existing && !s.scope.assignablePermissionSets.includes(setName)) { + return `environment set '${setName}' is outside the scope's allowlist — only its tenant-level owner may change it`; + } + return null; + }); + if (failure) { + throw new PermissionDeniedError( + `[Security] Access denied: delegated '${opCtx.operation}' on sys_permission_set rejected — ${failure}.`, + { operation: opCtx.operation, object: opCtx.object, permissionSet: setName }, + ); + } + + // A delegate may suggest nothing tenant-wide: isDefault is the D5 + // install-suggestion to bind to `everyone` — tenant-level only. + if (t.next && (t.next.isDefault === true || t.next.is_default === true)) { + throw new PermissionDeniedError( + `[Security] Access denied: 'isDefault' (the everyone-binding suggestion) is tenant-level ` + + `only — a delegated administrator cannot set it (ADR-0090 D12).`, + { operation: opCtx.operation, object: opCtx.object, permissionSet: setName }, + ); + } + + // Authoring a set that CARRIES an adminScope = minting administration: + // requires a held scope that STRICTLY contains the minted one. + if (authoredScope) { + const containment = await this.checkStrictContainment(authoredScope, held); + if (containment) { + throw new PermissionDeniedError( + `[Security] Access denied: the authored adminScope is not strictly contained by your ` + + `own — ${containment} (ADR-0090 D12: granting an admin scope requires holding a scope ` + + `that strictly contains it).`, + { operation: opCtx.operation, object: opCtx.object, permissionSet: setName }, + ); + } + } + } + } + + // ── Shared helpers ──────────────────────────────────────────────────── + + /** First scope that fully approves wins; otherwise the FIRST failure reason + * from the LAST scope tried is surfaced (all scopes rejected). */ + private firstApprovalFailure( + held: HeldScope[], + check: (s: HeldScope) => string | null, + ): string | null { + let lastReason: string | null = null; + for (const s of held) { + const reason = check(s); + if (reason == null) return null; + lastReason = `${reason} (scope from '${s.setName}')`; + } + return lastReason ?? 'no delegated scope applies'; + } + + /** + * If `setRowOrDef` itself carries an adminScope, verify strict containment + * against the actor's held scopes. Returns a failure description or null. + * (`dryRun` callers use the string as a per-scope rejection reason.) + */ + private assertScopeGrantContainment(setRowOrDef: any, held: HeldScope[], _dryRun: boolean): string | null { + const carried: AdminScope | undefined = + parseMaybeJson(setRowOrDef.admin_scope ?? setRowOrDef.adminScope) ?? undefined; + if (!carried) return null; + // NOTE: containment needs resolved subtrees — checked synchronously against + // the pre-resolved held scopes; the carried scope's subtree is resolved in + // checkStrictContainment for the authoring path. For grant paths we compare + // conservatively by definition (name + flags + allowlist). + for (const s of held) { + if (this.definitionContainsStrictly(s.scope, carried)) return null; + } + return `granting set '${setRowOrDef.name ?? '?'}' would hand out an adminScope not strictly contained by yours`; + } + + /** Definition-level strict containment (no tree resolution): same-root-or- + * narrower BU (equal root only when outer includes subtree and inner is the + * same or a descendant — without the tree we accept equal root + subtree, + * or require the authoring path's resolved check), rights ⊇, allowlist ⊇, + * and NOT identical. Conservative: unknown ⇒ not contained. */ + private definitionContainsStrictly(outer: HeldScope['scope'], inner: AdminScope): boolean { + const innerFull = { + includeSubtree: inner.includeSubtree !== false, + manageAssignments: inner.manageAssignments === true, + manageBindings: inner.manageBindings === true, + authorEnvironmentSets: inner.authorEnvironmentSets === true, + assignable: (inner.assignablePermissionSets ?? []) as string[], + }; + // BU axis (definition level): identical root, and inner must not cover + // MORE of the tree than outer. + if (inner.businessUnit !== outer.businessUnit) return false; + if (innerFull.includeSubtree && outer.includeSubtree === false) return false; + // Rights axis: inner ⊆ outer. + if (innerFull.manageAssignments && !outer.manageAssignments) return false; + if (innerFull.manageBindings && !outer.manageBindings) return false; + if (innerFull.authorEnvironmentSets && !outer.authorEnvironmentSets) return false; + // Allowlist axis: inner ⊆ outer. + for (const name of innerFull.assignable) { + if (!outer.assignablePermissionSets.includes(name)) return false; + } + // Strictness: some axis must be strictly smaller. + const equalRights = + innerFull.manageAssignments === (outer.manageAssignments === true) && + innerFull.manageBindings === (outer.manageBindings === true) && + innerFull.authorEnvironmentSets === (outer.authorEnvironmentSets === true); + const equalTree = innerFull.includeSubtree === (outer.includeSubtree !== false); + const equalAllow = + innerFull.assignable.length === outer.assignablePermissionSets.length && + innerFull.assignable.every((n) => outer.assignablePermissionSets.includes(n)); + return !(equalRights && equalTree && equalAllow); + } + + /** Authoring-path strict containment with resolved subtrees. Returns a + * failure description, or null when some held scope strictly contains. */ + private async checkStrictContainment(minted: AdminScope, held: HeldScope[]): Promise { + const mintedSubtree = await this.resolveSubtree(minted.businessUnit, minted.includeSubtree !== false); + if (mintedSubtree.size === 0) return `its business unit '${minted.businessUnit}' does not resolve`; + for (const s of held) { + let treeContained = true; + for (const bu of mintedSubtree) if (!s.subtree.has(bu)) { treeContained = false; break; } + if (!treeContained) continue; + if ((minted.manageAssignments === true) && !s.scope.manageAssignments) continue; + if ((minted.manageBindings === true) && !s.scope.manageBindings) continue; + if ((minted.authorEnvironmentSets === true) && !s.scope.authorEnvironmentSets) continue; + const mintedAllow = minted.assignablePermissionSets ?? []; + if (!mintedAllow.every((n) => s.scope.assignablePermissionSets.includes(n))) continue; + // Strictness on the resolved tuple. + const equalTree = mintedSubtree.size === s.subtree.size; + const equalRights = + (minted.manageAssignments === true) === (s.scope.manageAssignments === true) && + (minted.manageBindings === true) === (s.scope.manageBindings === true) && + (minted.authorEnvironmentSets === true) === (s.scope.authorEnvironmentSets === true); + const equalAllow = + mintedAllow.length === s.scope.assignablePermissionSets.length && + mintedAllow.every((n) => s.scope.assignablePermissionSets.includes(n)); + if (equalTree && equalRights && equalAllow) continue; // identical — not STRICT + return null; + } + return 'no held scope covers its subtree, rights and allowlist with room to spare'; + } + + /** Resolve every adminScope carried by the actor's resolved sets. */ + private async resolveHeldScopes(sets: PermissionSet[]): Promise { + const out: HeldScope[] = []; + for (const ps of sets) { + const raw = parseMaybeJson((ps as any).adminScope ?? (ps as any).admin_scope); + if (!raw || typeof raw !== 'object' || typeof raw.businessUnit !== 'string') continue; + const scope = { + businessUnit: raw.businessUnit, + includeSubtree: raw.includeSubtree !== false, + manageAssignments: raw.manageAssignments === true, + manageBindings: raw.manageBindings === true, + authorEnvironmentSets: raw.authorEnvironmentSets === true, + assignablePermissionSets: Array.isArray(raw.assignablePermissionSets) + ? raw.assignablePermissionSets.filter((n: unknown): n is string => typeof n === 'string') + : [], + }; + const subtree = await this.resolveSubtree(scope.businessUnit, scope.includeSubtree); + out.push({ setName: (ps as any).name ?? '?', scope, subtree }); + } + return out; + } + + /** BU name → covered BU-id set (root + descendants when includeSubtree). */ + private async resolveSubtree(businessUnitName: string, includeSubtree: boolean): Promise> { + const ids = new Set(); + const ql = this.deps.ql; + if (!ql?.find) return ids; + let root: any = null; + try { + const roots = await ql.find('sys_business_unit', { where: { name: businessUnitName }, limit: 1, context: SYSTEM_CTX }); + root = Array.isArray(roots) && roots[0] ? roots[0] : null; + } catch { root = null; } + if (!root?.id) return ids; // misconfigured scope → approves nothing (fail closed) + ids.add(String(root.id)); + if (!includeSubtree) return ids; + let frontier: string[] = [String(root.id)]; + for (let depth = 0; depth < MAX_TREE_DEPTH && frontier.length > 0; depth++) { + let children: any[] = []; + try { + children = await ql.find('sys_business_unit', { + where: { parent_business_unit_id: { $in: frontier } }, + limit: 5000, + context: SYSTEM_CTX, + }); + } catch { children = []; } + const next: string[] = []; + for (const c of Array.isArray(children) ? children : []) { + const id = String((c as any)?.id ?? ''); + if (id && !ids.has(id)) { ids.add(id); next.push(id); } + } + frontier = next; + } + return ids; + } + + /** Rows targeted by this write: `{ next, prev }` per row (prev = pre-image on update/delete). */ + private async materializeTargets(opCtx: any, object: string): Promise> { + const op = opCtx.operation; + const payload = rowsOf(opCtx); + if (op === 'insert') return payload.map((r) => ({ next: r, prev: null })); + + const id = extractSingleId(opCtx); + let prev: any = null; + if (id != null && this.deps.ql?.findOne) { + prev = await this.deps.ql + .findOne(object, { where: { id }, context: SYSTEM_CTX }) + .catch(() => null); + } + if (op === 'update') { + const next = payload.length > 0 ? payload[0] : null; + // Merge unchanged pre-image values so boundary checks see the full row + // (an update payload may carry only the changed columns). + const merged = next && prev ? { ...prev, ...next } : next ?? prev; + return [{ next: merged, prev }]; + } + // delete / transfer / restore / purge — judged on the pre-image alone. + return [{ next: null, prev }]; + } + + private async setsBoundToPosition(positionName: string): Promise> { + const ql = this.deps.ql; + if (!ql?.find) return []; + try { + const posRows = await ql.find('sys_position', { where: { name: positionName }, limit: 1, context: SYSTEM_CTX }); + const pos = Array.isArray(posRows) && posRows[0] ? posRows[0] : null; + if (!pos?.id) return []; + const bindings = await ql.find('sys_position_permission_set', { + where: { position_id: pos.id }, + limit: 1000, + context: SYSTEM_CTX, + }); + const setIds = (Array.isArray(bindings) ? bindings : []) + .map((b: any) => b?.permission_set_id) + .filter(Boolean); + if (setIds.length === 0) return []; + const setRows = await ql.find('sys_permission_set', { + where: { id: { $in: setIds } }, + limit: setIds.length, + context: SYSTEM_CTX, + }); + return (Array.isArray(setRows) ? setRows : []) + .map((r: any) => ({ name: String(r?.name ?? ''), admin_scope: r?.admin_scope })) + .filter((r: any) => r.name); + } catch { + return []; + } + } + + private async loadSetRowById(id: unknown): Promise { + if (id == null || !this.deps.ql?.find) return null; + try { + const rows = await this.deps.ql.find('sys_permission_set', { where: { id }, limit: 1, context: SYSTEM_CTX }); + return Array.isArray(rows) && rows[0] ? rows[0] : null; + } catch { + return null; + } + } + + private async positionNameById(id: unknown): Promise { + if (id == null || !this.deps.ql?.find) return ''; + try { + const rows = await this.deps.ql.find('sys_position', { where: { id }, limit: 1, context: SYSTEM_CTX }); + return String((Array.isArray(rows) && rows[0] ? (rows[0] as any).name : '') ?? ''); + } catch { + return ''; + } + } + + /** BU anchors of every current assignment of a position (blast radius). */ + private async assignmentAnchorsOfPosition( + positionName: string, + ): Promise<{ anchors: Set; overCap: boolean; unanchored: number }> { + const anchors = new Set(); + let unanchored = 0; + const ql = this.deps.ql; + if (!ql?.find) return { anchors, overCap: false, unanchored }; + let rows: any[] = []; + try { + rows = await ql.find('sys_user_position', { + where: { position: positionName }, + limit: BLAST_RADIUS_CAP + 1, + context: SYSTEM_CTX, + }); + } catch { + rows = []; + } + const list = Array.isArray(rows) ? rows : []; + if (list.length > BLAST_RADIUS_CAP) return { anchors, overCap: true, unanchored }; + for (const r of list) { + const bu = (r as any)?.business_unit_id; + if (bu == null || bu === '') unanchored += 1; + else anchors.add(String(bu)); + } + return { anchors, overCap: false, unanchored }; + } + + /** Target user's BU memberships (sys_business_unit_member ∪ primary projection). */ + private async businessUnitsOfUser(userId: string): Promise> { + const out = new Set(); + const ql = this.deps.ql; + if (!ql?.find) return out; + try { + const memberships = await ql.find('sys_business_unit_member', { + where: { user_id: userId }, + limit: 1000, + context: SYSTEM_CTX, + }); + for (const m of Array.isArray(memberships) ? memberships : []) { + const bu = (m as any)?.business_unit_id; + if (bu != null && bu !== '') out.add(String(bu)); + } + } catch { /* table may not exist in minimal harnesses */ } + if (out.size === 0) { + try { + const users = await ql.find('sys_user', { where: { id: userId }, limit: 1, context: SYSTEM_CTX }); + const primary = Array.isArray(users) && users[0] ? (users[0] as any).primary_business_unit_id : null; + if (primary != null && primary !== '') out.add(String(primary)); + } catch { /* ignore */ } + } + return out; + } +} diff --git a/packages/plugins/plugin-security/src/index.ts b/packages/plugins/plugin-security/src/index.ts index 15b3bb445e..87cf513651 100644 --- a/packages/plugins/plugin-security/src/index.ts +++ b/packages/plugins/plugin-security/src/index.ts @@ -28,3 +28,5 @@ export { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; export { bootstrapDeclaredPermissions } from './bootstrap-declared-permissions.js'; export { claimSeedOwnership } from './claim-seed-ownership.js'; export { appDefaultPermissionSetName } from './app-default-permission-set.js'; +export { DelegatedAdminGate } from './delegated-admin-gate.js'; +export type { DelegatedAdminGateDeps } from './delegated-admin-gate.js'; diff --git a/packages/plugins/plugin-security/src/manifest.ts b/packages/plugins/plugin-security/src/manifest.ts index 52a4c83a08..a2216ab84e 100644 --- a/packages/plugins/plugin-security/src/manifest.ts +++ b/packages/plugins/plugin-security/src/manifest.ts @@ -10,11 +10,11 @@ import { SysPermissionSet, - SysRole, + SysPosition, SysCapability, SysUserPermissionSet, - SysRolePermissionSet, - SysUserRole, + SysPositionPermissionSet, + SysUserPosition, defaultPermissionSets, } from './objects/index.js'; @@ -23,12 +23,12 @@ export const SECURITY_PLUGIN_VERSION = '1.0.0'; /** Security objects owned by plugin-security. */ export const securityObjects = [ - SysRole, + SysPosition, SysCapability, SysPermissionSet, SysUserPermissionSet, - SysRolePermissionSet, - SysUserRole, + SysPositionPermissionSet, + SysUserPosition, ]; /** Default platform permission sets (admin / member / viewer). */ @@ -43,5 +43,5 @@ export const securityPluginManifestHeader = { scope: 'system' as const, defaultDatasource: 'cloud', name: 'Security Plugin', - description: 'RBAC roles and permission sets for ObjectStack (Role, PermissionSet)', + description: 'RBAC positions and permission sets for ObjectStack (Position, PermissionSet)', }; diff --git a/packages/plugins/plugin-security/src/objects/index.ts b/packages/plugins/plugin-security/src/objects/index.ts index d48ff420ae..39ac2517cc 100644 --- a/packages/plugins/plugin-security/src/objects/index.ts +++ b/packages/plugins/plugin-security/src/objects/index.ts @@ -9,10 +9,10 @@ * live in `@objectstack/plugin-sharing`. */ -export { SysRole } from './sys-position.object.js'; +export { SysPosition } from './sys-position.object.js'; export { SysCapability } from './sys-capability.object.js'; export { SysPermissionSet } from './sys-permission-set.object.js'; export { SysUserPermissionSet } from './sys-user-permission-set.object.js'; -export { SysRolePermissionSet } from './sys-position-permission-set.object.js'; -export { SysUserRole } from './sys-user-position.object.js'; +export { SysPositionPermissionSet } from './sys-position-permission-set.object.js'; +export { SysUserPosition } from './sys-user-position.object.js'; export { defaultPermissionSets } from './default-permission-sets.js'; diff --git a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts index 6b5e23a36f..f2b1fc3d49 100644 --- a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts +++ b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import { SysRole, SysPermissionSet, SysCapability, defaultPermissionSets } from './index.js'; +import { SysPosition, SysPermissionSet, SysCapability, defaultPermissionSets } from './index.js'; /** * RBAC object + default-permission-set assertions. Moved here with the objects @@ -102,16 +102,16 @@ describe('default permission sets', () => { }); describe('RBAC object canonical names + row actions', () => { - it('SysRole / SysPermissionSet use their canonical sys_ short names and are system objects', () => { - expect(SysRole.name).toBe('sys_position'); + it('SysPosition / SysPermissionSet use their canonical sys_ short names and are system objects', () => { + expect(SysPosition.name).toBe('sys_position'); expect(SysPermissionSet.name).toBe('sys_permission_set'); - expect(SysRole.isSystem).toBe(true); + expect(SysPosition.isSystem).toBe(true); expect(SysPermissionSet.isSystem).toBe(true); }); - it('SysRole exposes activate/deactivate/clone/set-default row actions', () => { - const names = (SysRole.actions ?? []).map((a) => a.name).sort(); - expect(names).toEqual(['activate_role', 'clone_role', 'deactivate_role', 'set_default_role']); + it('SysPosition exposes activate/deactivate/clone/set-default row actions (ADR-0090 D3 vocabulary)', () => { + const names = (SysPosition.actions ?? []).map((a) => a.name).sort(); + expect(names).toEqual(['activate_position', 'clone_position', 'deactivate_position', 'set_default_position']); }); it('SysPermissionSet exposes activate/deactivate/clone row actions', () => { diff --git a/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts index e3e244f500..ab01abd1d1 100644 --- a/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts @@ -6,7 +6,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * sys_permission_set — System Permission Set Object * * Named groupings of fine-grained permissions. - * Permission sets can be assigned to roles or directly to users + * Permission sets can be bound to positions or granted directly to users * for granular access control. * * @namespace sys @@ -31,7 +31,7 @@ export const SysPermissionSet = ObjectSchema.create({ titleFormat: '{label}', highlightFields: ['label', 'name', 'active'], - // Custom actions — permission sets are templates assigned to roles or + // Custom actions — permission sets are templates bound to positions or // users (via sys_position_permission_set / sys_user_permission_set). The // sysadmin operations that don't live on the parent-detail tabs are // lifecycle (activate/deactivate without losing assignments) and @@ -183,6 +183,17 @@ export const SysPermissionSet = ObjectSchema.create({ group: 'Permissions', }), + admin_scope: Field.textarea({ + label: 'Delegated Admin Scope', + required: false, + description: + '[ADR-0090 D12] JSON-serialized AdminScope: { businessUnit, includeSubtree, manageAssignments, ' + + 'manageBindings, authorEnvironmentSets, assignablePermissionSets[] }. Holding this set makes the ' + + 'user a SCOPED administrator within the declared business-unit subtree; enforced by the ' + + 'delegated-admin write gate.', + group: 'Permissions', + }), + // ── Status ─────────────────────────────────────────────────── active: Field.boolean({ label: 'Active', diff --git a/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts index df9e6afeb0..31b57707b4 100644 --- a/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts @@ -3,7 +3,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; /** - * sys_position_permission_set — Role ↔ PermissionSet binding. + * sys_position_permission_set — Position ↔ PermissionSet binding. * * Allows administrators to compose a `sys_position` from one or more * `sys_permission_set` rows. At request time, the runtime resolver @@ -11,12 +11,17 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * the user's positions via this table and injects their names into * `ExecutionContext.permissions[]` for downstream RBAC evaluation. * + * Writes are guarded twice (both gates unconditional, ADR-0090): + * the audience-anchor gate (D5/D9 — no high-privilege set on + * `everyone`/`guest`) and the delegated-admin gate (D12 — non-tenant-admin + * writers need an adminScope whose allowlist covers the bound set). + * * @namespace sys */ -export const SysRolePermissionSet = ObjectSchema.create({ +export const SysPositionPermissionSet = ObjectSchema.create({ name: 'sys_position_permission_set', - label: 'Role Permission Set', - pluralLabel: 'Role Permission Sets', + label: 'Position Permission Set', + pluralLabel: 'Position Permission Sets', icon: 'shield-plus', isSystem: true, managedBy: 'system', @@ -33,7 +38,7 @@ export const SysRolePermissionSet = ObjectSchema.create({ }), position_id: Field.lookup('sys_position', { - label: 'Role', + label: 'Position', required: true, description: 'Foreign key to sys_position.', }), diff --git a/packages/plugins/plugin-security/src/objects/sys-position.object.ts b/packages/plugins/plugin-security/src/objects/sys-position.object.ts index 47dc516bba..9abdfb513a 100644 --- a/packages/plugins/plugin-security/src/objects/sys-position.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-position.object.ts @@ -3,48 +3,51 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; /** - * sys_position — System Role Object + * sys_position — Position definitions (ADR-0090 D3). * - * RBAC role definition for the ObjectStack platform. - * Roles group permissions and are assigned to users or members. + * A position (岗位) is the flat capability-DISTRIBUTION group: users hold + * positions (`sys_user_position`), positions bind permission sets + * (`sys_position_permission_set`). Positions carry no capability of their + * own and no hierarchy — the visibility tree lives on `sys_business_unit`. * * @namespace sys */ -export const SysRole = ObjectSchema.create({ +export const SysPosition = ObjectSchema.create({ name: 'sys_position', - label: 'Role', - pluralLabel: 'Roles', + label: 'Position', + pluralLabel: 'Positions', icon: 'shield', isSystem: true, managedBy: 'config', // ADR-0010 §3.7 — RBAC primitive; tenants may add custom rows // (created via UI / API) but the schema itself is locked. - // ADR-0068 D3: role-DEFINITION authority follows the isolation boundary. - // Framework-reserved built-in roles (platform_admin / org_*) are seeded with - // `managed_by = 'system'` and MUST NOT be repurposed by a tenant; ad-hoc role - // definitions in a shared cross-tenant kernel namespace are forbidden. + // ADR-0068 D3: position-DEFINITION authority follows the isolation boundary. + // Framework-reserved built-in identities (platform_admin / org_*) and the + // ADR-0090 D9 audience anchors (everyone / guest) are seeded with + // `managed_by = 'system'` and MUST NOT be repurposed by a tenant; ad-hoc + // position definitions in a shared cross-tenant kernel namespace are forbidden. protection: { lock: 'no-overlay', reason: 'RBAC schema is platform-defined — see ADR-0010.', docsUrl: 'https://docs.objectstack.ai/adr/0010-metadata-protection', }, - description: 'Role definitions for RBAC access control', + description: 'Position definitions for capability distribution (ADR-0090)', displayNameField: 'label', nameField: 'label', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{label}', highlightFields: ['label', 'name', 'active', 'is_default'], - // Custom actions — system roles drive RBAC and are edited rarely but - // require the four high-frequency sysadmin affordances every IdP + // Custom actions — positions drive capability distribution and are edited + // rarely but require the four high-frequency sysadmin affordances every IdP // (Salesforce, ServiceNow, Okta) ships: activate/deactivate (lifecycle // without losing assignments), mark default (auto-assign to new users), - // and clone (template for new roles). All operations hit the generic + // and clone (template for new positions). All operations hit the generic // data CRUD endpoint exposed by `apiEnabled` — no custom server route // required because `managedBy: 'config'` allows direct mutation. actions: [ { - name: 'activate_role', - label: 'Activate Role', + name: 'activate_position', + label: 'Activate Position', icon: 'circle-check', variant: 'secondary', mode: 'custom', @@ -53,12 +56,12 @@ export const SysRole = ObjectSchema.create({ method: 'PATCH', target: '/api/v1/data/sys_position/{id}', bodyExtra: { active: true }, - successMessage: 'Role activated', + successMessage: 'Position activated', refreshAfter: true, }, { - name: 'deactivate_role', - label: 'Deactivate Role', + name: 'deactivate_position', + label: 'Deactivate Position', icon: 'circle-off', variant: 'danger', mode: 'custom', @@ -67,12 +70,12 @@ export const SysRole = ObjectSchema.create({ method: 'PATCH', target: '/api/v1/data/sys_position/{id}', bodyExtra: { active: false }, - confirmText: 'Deactivate this role? Users with the role keep their assignment but the role stops granting permissions until re-activated.', - successMessage: 'Role deactivated', + confirmText: 'Deactivate this position? Users keep their assignment but the position stops granting permissions until re-activated.', + successMessage: 'Position deactivated', refreshAfter: true, }, { - name: 'set_default_role', + name: 'set_default_position', label: 'Set as Default', icon: 'star', variant: 'secondary', @@ -82,8 +85,8 @@ export const SysRole = ObjectSchema.create({ method: 'PATCH', target: '/api/v1/data/sys_position/{id}', bodyExtra: { is_default: true }, - confirmText: 'Make this the default role for new users? Existing users are unaffected.', - successMessage: 'Default role updated', + confirmText: 'Make this the default position for new users? Existing users are unaffected.', + successMessage: 'Default position updated', refreshAfter: true, }, { @@ -91,8 +94,8 @@ export const SysRole = ObjectSchema.create({ // dialog asks only for the new API name / label so the operator // can rename atomically; permissions JSON is copied wholesale via // defaultFromRow. - name: 'clone_role', - label: 'Clone Role', + name: 'clone_position', + label: 'Clone Position', icon: 'copy', variant: 'secondary', mode: 'custom', @@ -101,7 +104,7 @@ export const SysRole = ObjectSchema.create({ method: 'POST', target: '/api/v1/data/sys_position', bodyExtra: { is_default: false, active: true }, - successMessage: 'Role cloned', + successMessage: 'Position cloned', refreshAfter: true, params: [ { name: 'label', label: 'New Display Name', type: 'text', required: true }, @@ -123,9 +126,9 @@ export const SysRole = ObjectSchema.create({ sort: [{ field: 'label', order: 'asc' }], pagination: { pageSize: 50 }, }, - default_roles: { + default_positions: { type: 'grid', - name: 'default_roles', + name: 'default_positions', label: 'Default', data: { provider: 'object', object: 'sys_position' }, columns: ['label', 'name', 'description', 'active'], @@ -143,9 +146,9 @@ export const SysRole = ObjectSchema.create({ sort: [{ field: 'label', order: 'asc' }], pagination: { pageSize: 50 }, }, - all_roles: { + all_positions: { type: 'grid', - name: 'all_roles', + name: 'all_positions', label: 'All', data: { provider: 'object', object: 'sys_position' }, columns: ['label', 'name', 'active', 'is_default', 'updated_at'], @@ -169,7 +172,7 @@ export const SysRole = ObjectSchema.create({ required: true, searchable: true, maxLength: 100, - description: 'Unique machine name for the role (e.g. admin, editor, viewer)', + description: 'Unique machine name for the position (e.g. sales_manager, hr_specialist)', group: 'Identity', }), @@ -195,7 +198,7 @@ export const SysRole = ObjectSchema.create({ }), is_default: Field.boolean({ - label: 'Default Role', + label: 'Default Position', defaultValue: false, description: 'Automatically assigned to new users', group: 'Status', @@ -203,7 +206,7 @@ export const SysRole = ObjectSchema.create({ // ── System ─────────────────────────────────────────────────── // ADR-0068 D2/D3 — provenance of this row. `system` = a framework-reserved - // built-in identity role (seeded by bootstrapBuiltinRoles); `config` = + // built-in identity position (seeded by bootstrapBuiltinPositions); `config` = // stack-declared; null / `user` = tenant-created. Built-in rows are read-only. managed_by: Field.text({ label: 'Managed By', @@ -213,7 +216,7 @@ export const SysRole = ObjectSchema.create({ }), id: Field.text({ - label: 'Role ID', + label: 'Position ID', required: true, readonly: true, group: 'System', diff --git a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts index ca707adf3b..f53a21a50f 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts @@ -3,13 +3,13 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; /** - * sys_user_position — User ↔ Role assignment (ADR-0057 D4). + * sys_user_position — User ↔ Position assignment (ADR-0057 D4). * * The platform-owned source of truth for "who holds which position" * (ADR-0090 D3; formerly sys_user_role), decoupled from better-auth's - * `sys_member.role` (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 + * `sys_member.role` (org-administration tier). At request time the runtime + * resolver (`resolveExecutionContext`) reads assignments from this table + * (∪ `sys_member.role` during the transition window) into * `ExecutionContext.positions[]`. * * `position` stores the position's machine name (matches @@ -17,18 +17,27 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * downstream. `organization_id = null` means a cross-tenant (global) * assignment. * + * `business_unit_id` is the ASSIGNMENT-LEVEL BU anchor (ADR-0090 Addendum; + * reserved by ADR-0057 D4). Positions never bind to a business unit at the + * definition level — that recreates the position-per-department explosion. + * The anchor has exactly three consumers: the depth anchor for this + * assignment's readScope/writeScope (enterprise hierarchy resolver), the + * ADR-0090 D12 delegated-administration boundary ("assignments you create + * must target your subtree" — enforced by the delegated-admin gate), and the + * audit fact ("manager OF WHAT"). Capability bits are never BU-scoped. + * * @namespace sys */ -export const SysUserRole = ObjectSchema.create({ +export const SysUserPosition = ObjectSchema.create({ name: 'sys_user_position', - label: 'User Role', - pluralLabel: 'User Roles', + label: 'User Position', + pluralLabel: 'User Positions', icon: 'user-cog', isSystem: true, managedBy: 'system', description: 'Assigns a position (sys_position.name) to a user. Platform-owned (ADR-0057 D4, ADR-0090 D3).', titleFormat: '{user_id} → {position}', - highlightFields: ['user_id', 'position', 'organization_id'], + highlightFields: ['user_id', 'position', 'business_unit_id', 'organization_id'], fields: { id: Field.text({ @@ -45,12 +54,21 @@ export const SysUserRole = ObjectSchema.create({ }), position: Field.text({ - label: 'Role', + label: 'Position', required: true, maxLength: 100, description: 'Position machine name (references sys_position.name).', }), + business_unit_id: Field.lookup('sys_business_unit', { + label: 'Business Unit', + required: false, + description: + '[ADR-0090 Addendum] Assignment-level BU anchor: where this position assignment applies. ' + + 'Depth anchor for readScope/writeScope, delegated-admin boundary (D12), and audit fact. ' + + 'Null = unanchored (legacy/tenant-wide); delegated admins MUST anchor assignments inside their subtree.', + }), + organization_id: Field.lookup('sys_organization', { label: 'Organization', required: false, @@ -60,7 +78,7 @@ export const SysUserRole = ObjectSchema.create({ granted_by: Field.lookup('sys_user', { label: 'Granted By', required: false, - description: 'User who granted this role assignment.', + description: 'User who granted this position assignment (stamped by the delegated-admin gate for delegate writes).', }), created_at: Field.datetime({ @@ -80,6 +98,7 @@ export const SysUserRole = ObjectSchema.create({ { fields: ['user_id', 'position', 'organization_id'], unique: true }, { fields: ['user_id'] }, { fields: ['position'] }, + { fields: ['business_unit_id'] }, { fields: ['organization_id'] }, ], diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 17c6fc9f9f..638aa7c8fb 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -2,7 +2,9 @@ import { Plugin, PluginContext } from '@objectstack/core'; import type { PermissionSet, RowLevelSecurityPolicy } from '@objectstack/spec/security'; +import { describeHighPrivilegeBits, describeAnchorForbiddenBits } from '@objectstack/spec/security'; import { PermissionEvaluator, crudBucketForOperation } from './permission-evaluator.js'; +import { DelegatedAdminGate } from './delegated-admin-gate.js'; import { bootstrapDeclaredPositions } from './bootstrap-declared-positions.js'; import { bootstrapDeclaredPermissions, upsertPackagePermissionSet } from './bootstrap-declared-permissions.js'; import { bootstrapBuiltinRoles } from './bootstrap-builtin-positions.js'; @@ -128,27 +130,11 @@ export interface SecurityPluginOptions { * - metadata service (MetadataFacade for reading permission sets and RLS policies) */ /** - * [ADR-0090 D5/D9] Does a permission-set definition (authored shape OR - * sys_permission_set row shape with JSON-ish columns) carry bits too - * dangerous for an audience anchor? Returns a human-readable description of - * the first offending bit, or null when the set is anchor-safe. + * [ADR-0090 D5/D9] Anchor-safety predicates moved to `@objectstack/spec/security` + * (P3) so the authoring linter (`validateSecurityPosture`) and this runtime + * gate share ONE definition. Re-exported here for existing consumers. */ -export function describeHighPrivilegeBits(def: any): string | null { - if (!def || typeof def !== 'object') return null; - const sys = def.systemPermissions ?? def.system_permissions; - if (Array.isArray(sys) && sys.length > 0) return 'system permissions'; - let objects: any = def.objects; - if (typeof objects === 'string') { try { objects = JSON.parse(objects); } catch { objects = undefined; } } - if (objects && typeof objects === 'object') { - for (const [objName, rawPerm] of Object.entries(objects)) { - const p: any = rawPerm ?? {}; - if (p.viewAllRecords || p.modifyAllRecords) return `View/Modify All Data on '${objName}'`; - if (p.allowDelete || p.allowPurge || p.allowTransfer) return `delete/purge/transfer on '${objName}'`; - if (objName === '*') return "a '*' wildcard grant"; - } - } - return null; -} +export { describeHighPrivilegeBits } from '@objectstack/spec/security'; export class SecurityPlugin implements Plugin { name = 'com.objectstack.security'; @@ -196,6 +182,8 @@ export class SecurityPlugin implements Plugin { */ private metadata: any = null; private ql: any = null; + /** [ADR-0090 D12] Delegated-admin write gate — wired in start() once `ql` exists. */ + private delegatedAdminGate: DelegatedAdminGate | null = null; /** Unsubscribe handle for metadata-change cache invalidation (runtime metadata edits). */ private metadataWatch: { unsubscribe: () => void } | null = null; /** ADR-0055: cache the resolved master-detail relation per controlled_by_parent object. */ @@ -361,20 +349,35 @@ export class SecurityPlugin implements Plugin { rows = []; } const list = Array.isArray(rows) ? rows : rows?.records ?? []; + const parseJson = (v: any, fallback: any) => { + if (typeof v !== 'string') return v ?? fallback; + try { return JSON.parse(v || JSON.stringify(fallback)); } catch { return fallback; } + }; return list.map((r: any) => ({ name: r.name, label: r.label, - objects: typeof r.object_permissions === 'string' - ? JSON.parse(r.object_permissions || '{}') - : r.object_permissions ?? {}, - fields: typeof r.field_permissions === 'string' - ? JSON.parse(r.field_permissions || '{}') - : r.field_permissions ?? {}, + objects: parseJson(r.object_permissions, {}), + fields: parseJson(r.field_permissions, {}), + systemPermissions: parseJson(r.system_permissions, []), + // [ADR-0090 D12] Hydrate the delegated-admin scope so the gate can + // resolve a DB-authored delegate's authority. Null column → absent. + ...(r.admin_scope ? { adminScope: parseJson(r.admin_scope, undefined) } : {}), })); } : undefined; this.dbLoader = dbLoader; + // [ADR-0090 D12] Delegated-admin gate shares the SAME permission-set + // resolution as the CRUD middleware, so a delegate's authority and their + // ordinary grants can never drift. + this.delegatedAdminGate = ql + ? new DelegatedAdminGate({ + ql, + resolveSets: (context: any) => this.resolvePermissionSetsForContext(context), + logger: ctx.logger, + }) + : null; + // ADR-0021 D-C — expose the per-request READ scope as a reusable service. // The analytics raw-SQL path (which bypasses this engine middleware) // auto-bridges to `getService('security').getReadFilter(object, context)` @@ -442,6 +445,18 @@ export class SecurityPlugin implements Plugin { // is validated at seed time by the same predicate.) await this.assertAudienceAnchorBindingGate(opCtx); + // [ADR-0090 D12] Delegated-administration gate. Writes to the RBAC + // link tables (assignments / bindings / direct grants / env-set + // authoring) are a GOVERNED operation: tenant-level admins pass + // through to the ordinary CRUD/RLS checks; delegates need a covering + // adminScope (BU subtree + allowlist + strict containment); everyone + // else — including holders of plain CRUD grants on these tables — is + // denied. Runs BEFORE the empty-principal fall-open below so RBAC + // tables fail CLOSED for principal-less non-system contexts. + if (this.delegatedAdminGate) { + await this.delegatedAdminGate.assert(opCtx); + } + const roles = opCtx.context?.positions ?? []; const explicitPermissionSets = opCtx.context?.permissions ?? []; @@ -1236,7 +1251,10 @@ export class SecurityPlugin implements Plugin { } } catch { /* fall through to bootstrap lookup below */ } const boot = this.bootstrapPermissionSets.find((p) => p.name === setName); - const offending = describeHighPrivilegeBits(boot ?? setDef); + // [ADR-0090 D9] Anchor-tier predicate: `guest` faces the strictest tier + // (additionally no edit bit — read-only by default, create is the single + // case-by-case write); `everyone` uses the high-privilege predicate. + const offending = describeAnchorForbiddenBits(boot ?? setDef, positionName as 'everyone' | 'guest'); if (offending) { throw new PermissionDeniedError( `[Security] Access denied: permission set '${setName || setId}' cannot be bound to the '${positionName}' audience anchor — it carries ${offending} (ADR-0090 D5/D9). ` + diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 3ad89ecbe7..aa964b3be3 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3838,6 +3838,9 @@ "vercelStaticSiteConnectorExample (const)" ], "./security": [ + "AdminScope (type)", + "AdminScopeInput (type)", + "AdminScopeSchema (const)", "CriteriaSharingRule (type)", "CriteriaSharingRuleSchema (const)", "FieldPermission (type)", @@ -3876,6 +3879,8 @@ "TerritoryType (const)", "definePermissionSet (function)", "defineSharingRule (function)", + "describeAnchorForbiddenBits (function)", + "describeHighPrivilegeBits (function)", "permissionForm (const)" ], "./studio": [ diff --git a/packages/spec/liveness/permission.json b/packages/spec/liveness/permission.json index 0b6d6efc06..5f7be807ae 100644 --- a/packages/spec/liveness/permission.json +++ b/packages/spec/liveness/permission.json @@ -1,6 +1,6 @@ { "type": "permission", - "_note": "PermissionSetSchema (also aliased as `profile`). Seeded from docs/audits/2026-06-security-identity-property-liveness.md. CRUD/FLS/RLS enforced via plugin-security. allowTransfer/Restore/Purge are RBAC-mapped in the evaluator (#1883) — deny unless granted; the ObjectQL operations themselves are pending M2.", + "_note": "PermissionSetSchema (the `profile` alias and isProfile flag were removed by ADR-0090 D2). Seeded from docs/audits/2026-06-security-identity-property-liveness.md. CRUD/FLS/RLS enforced via plugin-security. allowTransfer/Restore/Purge are RBAC-mapped in the evaluator (#1883) — deny unless granted; the ObjectQL operations themselves are pending M2.", "props": { "name": { "status": "live", @@ -21,10 +21,10 @@ "evidence": "packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts (persisted per record as sys_permission_set.managed_by; 'package' rows re-seeded, platform/user rows never clobbered)", "note": "ADR-0086 D3 — per-record provenance on the metadata-persistence axis (package | platform | user)." }, - "isProfile": { + "adminScope": { "status": "live", - "evidence": "objectui: packages/app-shell/src/views/metadata-admin/previews/PermissionPreview.tsx:92 (!!d.isProfile)", - "note": "LIVE via objectui metadata-admin authoring UI — the 2026-06 audit missed objectui; verified by reading the file." + "evidence": "packages/plugins/plugin-security/src/delegated-admin-gate.ts — non-tenant-admin writes to sys_user_position / sys_position_permission_set / sys_user_permission_set / sys_permission_set require a covering adminScope (BU-subtree boundary + assignable-set allowlist + strict-containment for scope grants).", + "note": "ADR-0090 D12 — delegated administration; proven by packages/plugins/plugin-security/src/delegated-admin-gate.test.ts." }, "isDefault": { "status": "live", diff --git a/packages/spec/src/contracts/sharing-service.ts b/packages/spec/src/contracts/sharing-service.ts index dd2a28ec5c..1947549dcf 100644 --- a/packages/spec/src/contracts/sharing-service.ts +++ b/packages/spec/src/contracts/sharing-service.ts @@ -270,6 +270,16 @@ export interface HierarchyScopeContext { * 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). + * + * [ADR-0090 Addendum — assignment-level BU anchor] When resolving `unit` / + * `unit_and_below`, an implementation MUST prefer the caller's ANCHORED + * business units — the non-null `sys_user_position.business_unit_id` values on + * their position assignments — over their full `sys_business_unit_member` + * membership: a 华东 sales manager anchored to 华东 gets manager depth in 华东 + * only, not in an unrelated project unit they also happen to belong to. Only + * when the caller has NO anchored assignment does the resolver fall back to + * membership-derived units. (Capability bits are never BU-scoped; the anchor + * narrows DEPTH, it never grants.) */ export interface IHierarchyScopeResolver { /** diff --git a/packages/spec/src/security/high-privilege.ts b/packages/spec/src/security/high-privilege.ts new file mode 100644 index 0000000000..8bd1f48761 --- /dev/null +++ b/packages/spec/src/security/high-privilege.ts @@ -0,0 +1,76 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0090 D5/D9/D7] High-privilege predicates over permission-set shapes. + * + * Shared by the runtime audience-anchor gate (`@objectstack/plugin-security`) + * and the authoring-time security linter (`@objectstack/lint` + * `validateSecurityPosture`), so "too dangerous for an anchor" has exactly ONE + * definition — the lint and the gate can never drift apart (ADR-0049: a lint + * the runtime cannot enforce is not shipped as advisory security). + * + * Accepts BOTH the authored spec shape (`objects`, `systemPermissions`) and + * the `sys_permission_set` ROW shape (`object_permissions` / + * `system_permissions` JSON-string columns) — callers pass whatever they have. + */ + +/** Tolerant JSON access: value may be the parsed object or a JSON string column. */ +function coerceRecord(v: unknown): Record | undefined { + if (typeof v === 'string') { + try { v = JSON.parse(v); } catch { return undefined; } + } + return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : undefined; +} + +/** + * Does a permission-set definition carry bits too dangerous for an audience + * anchor (`everyone` / `guest`)? Returns a human-readable description of the + * first offending bit, or `null` when the set is anchor-safe. + * + * Offending bits: any `systemPermissions`, View/Modify All Data (VAMA), + * delete/purge/transfer on any object, or a `'*'` wildcard object grant. + */ +export function describeHighPrivilegeBits(def: any): string | null { + if (!def || typeof def !== 'object') return null; + const sysRaw = def.systemPermissions ?? def.system_permissions; + const sys = typeof sysRaw === 'string' + ? (() => { try { return JSON.parse(sysRaw); } catch { return undefined; } })() + : sysRaw; + if (Array.isArray(sys) && sys.length > 0) return 'system permissions'; + const objects = coerceRecord(def.objects ?? def.object_permissions); + if (objects) { + for (const [objName, rawPerm] of Object.entries(objects)) { + const p: any = rawPerm ?? {}; + if (p.viewAllRecords || p.modifyAllRecords) return `View/Modify All Data on '${objName}'`; + if (p.allowDelete || p.allowPurge || p.allowTransfer) return `delete/purge/transfer on '${objName}'`; + if (objName === '*') return "a '*' wildcard grant"; + } + } + return null; +} + +/** + * [ADR-0090 D9] Anchor-tier predicate. `everyone` uses the high-privilege + * predicate as-is; `guest` faces the STRICTEST tier — additionally no edit + * bit on any object (guest bindings are read-only by default; create is the + * single case-by-case exception, e.g. public form intake). + * + * Returns a description of the first offending bit, or `null` when the set + * may be bound to the given anchor. + */ +export function describeAnchorForbiddenBits( + def: any, + anchor: 'everyone' | 'guest', +): string | null { + const high = describeHighPrivilegeBits(def); + if (high) return high; + if (anchor !== 'guest') return null; + const objects = coerceRecord(def?.objects ?? def?.object_permissions); + if (objects) { + for (const [objName, rawPerm] of Object.entries(objects)) { + const p: any = rawPerm ?? {}; + if (p.allowEdit) return `edit on '${objName}' (guest bindings are read-only; create is the only case-by-case write)`; + } + } + return null; +} diff --git a/packages/spec/src/security/index.ts b/packages/spec/src/security/index.ts index 507f33a999..d68cd189b7 100644 --- a/packages/spec/src/security/index.ts +++ b/packages/spec/src/security/index.ts @@ -13,6 +13,7 @@ export * from './permission.zod'; export * from './permission.form'; export * from './capabilities'; +export * from './high-privilege'; export * from './sharing.zod'; export * from './territory.zod'; export * from './rls.zod'; diff --git a/packages/spec/src/security/permission.test.ts b/packages/spec/src/security/permission.test.ts index 5243d23956..c0fb8582e2 100644 --- a/packages/spec/src/security/permission.test.ts +++ b/packages/spec/src/security/permission.test.ts @@ -3,11 +3,45 @@ import { PermissionSetSchema, ObjectPermissionSchema, FieldPermissionSchema, + AdminScopeSchema, type PermissionSet, type ObjectPermission, type FieldPermission, } from './permission.zod'; +describe('AdminScopeSchema (ADR-0090 D12)', () => { + it('parses a delegated-admin scope with defaults', () => { + const scope = AdminScopeSchema.parse({ businessUnit: 'east' }); + expect(scope.businessUnit).toBe('east'); + expect(scope.includeSubtree).toBe(true); // default: whole subtree + expect(scope.manageAssignments).toBe(false); + expect(scope.manageBindings).toBe(false); + expect(scope.authorEnvironmentSets).toBe(false); + expect(scope.assignablePermissionSets).toEqual([]); + }); + + it('rides on a permission set via adminScope', () => { + const ps = PermissionSetSchema.parse({ + name: 'east_subsidiary_admin', + objects: { + sys_user_position: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, + }, + adminScope: { + businessUnit: 'east', + manageAssignments: true, + assignablePermissionSets: ['sales_user', 'support_user'], + }, + }); + expect(ps.adminScope?.businessUnit).toBe('east'); + expect(ps.adminScope?.manageAssignments).toBe(true); + expect(ps.adminScope?.assignablePermissionSets).toEqual(['sales_user', 'support_user']); + }); + + it('requires the businessUnit boundary', () => { + expect(() => AdminScopeSchema.parse({ manageAssignments: true })).toThrow(); + }); +}); + describe('ObjectPermissionSchema', () => { it('should apply default values to false', () => { const result = ObjectPermissionSchema.parse({}); diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index 8639a7ef14..acddef74b4 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -75,6 +75,47 @@ export const ObjectPermissionSchema = lazySchema(() => z.object({ writeScope: ObjectAccessScopeSchema.optional().describe('[ADR-0057 D1] Write depth: own|unit|unit_and_below|org'), })); +/** + * [ADR-0090 D12] Delegated-administration scope. + * + * Attaches to a permission set (and is therefore distributed via positions, + * audited in the same tables, and explained by the same engine as every other + * grant). Declares WHERE a delegate may administer (a business-unit subtree), + * WHAT they may do there (manage user↔position assignments, position↔set + * bindings, author environment-owned sets), and WHICH permission sets they may + * hand out (the anti-self-escalation allowlist — a delegate can never assign, + * to others or themselves, a set outside it). + * + * Runtime enforcement lives in `@objectstack/plugin-security`'s delegated-admin + * write gate on `sys_user_position` / `sys_position_permission_set` / + * `sys_user_permission_set` / `sys_permission_set`. Tenant-level admins + * (ADR-0066 superuser wildcard) are exempt; the `everyone`/`guest` audience + * anchors stay tenant-level only — no delegated scope can touch them. + */ +export const AdminScopeSchema = lazySchema(() => z.object({ + /** Root of the delegated subtree — `sys_business_unit.name` (machine name, portable across environments). */ + businessUnit: z.string().describe('[ADR-0090 D12] Delegation boundary: sys_business_unit.name of the subtree root'), + /** Whether the scope covers the whole subtree under `businessUnit` (default) or that single unit only. */ + includeSubtree: z.boolean().default(true).describe('Cover descendant business units too (default true)'), + /** May create/update/delete `sys_user_position` assignments (and direct `sys_user_permission_set` grants) within the boundary. */ + manageAssignments: z.boolean().default(false).describe('Manage user↔position assignments within the subtree'), + /** May create/delete `sys_position_permission_set` bindings whose blast radius lies within the boundary. */ + manageBindings: z.boolean().default(false).describe('Manage position↔permission-set bindings within the subtree'), + /** May author environment-owned permission sets (package-managed rows stay publish-only per ADR-0086). */ + authorEnvironmentSets: z.boolean().default(false).describe('Author environment-owned permission sets'), + /** + * The anti-self-escalation core: permission-set NAMES the delegate may + * assign/bind. Every set reached by a delegated write must be in this list; + * granting a set that itself carries an adminScope additionally requires the + * grantor's scope to STRICTLY contain the granted one. + */ + assignablePermissionSets: z.array(z.string()).default([]).describe('Allowlist of permission-set names the delegate may hand out'), +})); + +export type AdminScope = z.infer; +/** Authoring input for {@link AdminScope} — defaulted fields are optional. */ +export type AdminScopeInput = z.input; + /** * Field Level Security (FLS) */ @@ -205,7 +246,7 @@ export const PermissionSetSchema = lazySchema(() => z.object({ * - `current_user.id` - Current user ID * - `current_user.organization_id` - Active organization id * - `current_user.department` - User's department - * - `current_user.role` - User's role + * - `current_user.positions` - Held positions (ADR-0090 D3) * - `current_user.region` - User's geographic region * * @example Custom context @@ -218,6 +259,16 @@ export const PermissionSetSchema = lazySchema(() => z.object({ * ``` */ contextVariables: z.record(z.string(), z.unknown()).optional().describe('Context variables for RLS evaluation'), + + /** + * [ADR-0090 D12] Delegated-administration scope carried by this set. + * Holding the set makes the user a SCOPED administrator: they may manage + * assignments/bindings (and optionally author environment sets) within the + * declared business-unit subtree, handing out ONLY the allowlisted sets. + * See {@link AdminScopeSchema} for the full contract. + */ + adminScope: AdminScopeSchema.optional() + .describe('[ADR-0090 D12] Scoped delegated-administration grant (BU subtree + assignable-set allowlist)'), })); export type PermissionSet = z.infer; From dd36a891a1c479813deaa7f3f0bd274bea8f6bc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 06:18:38 +0000 Subject: [PATCH 2/2] fix: stamp todo_task OWD (D7 linter), drop always-true ql conditional (CodeQL) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H --- examples/app-todo/src/objects/task.object.ts | 4 ++++ .../plugin-security/src/security-plugin.ts | 15 +++++++-------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/examples/app-todo/src/objects/task.object.ts b/examples/app-todo/src/objects/task.object.ts index e209a97408..d5d686655a 100644 --- a/examples/app-todo/src/objects/task.object.ts +++ b/examples/app-todo/src/objects/task.object.ts @@ -5,6 +5,10 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; export const Task = ObjectSchema.create({ name: 'todo_task', + // [ADR-0090 D1] Explicit grandfather stamp: this demo object is + // intentionally org-shared; without it the secure default (unset OWD => + // private) owner-filters it and the D7 publish linter fails the build. + sharingModel: 'public_read_write', label: 'Task', pluralLabel: 'Tasks', icon: 'check-square', diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 638aa7c8fb..ebca83e40a 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -369,14 +369,13 @@ export class SecurityPlugin implements Plugin { // [ADR-0090 D12] Delegated-admin gate shares the SAME permission-set // resolution as the CRUD middleware, so a delegate's authority and their - // ordinary grants can never drift. - this.delegatedAdminGate = ql - ? new DelegatedAdminGate({ - ql, - resolveSets: (context: any) => this.resolvePermissionSetsForContext(context), - logger: ctx.logger, - }) - : null; + // ordinary grants can never drift. (`ql` is guaranteed non-null here — + // start() bailed out above without a middleware-capable engine.) + this.delegatedAdminGate = new DelegatedAdminGate({ + ql, + resolveSets: (context: any) => this.resolvePermissionSetsForContext(context), + logger: ctx.logger, + }); // ADR-0021 D-C — expose the per-request READ scope as a reusable service. // The analytics raw-SQL path (which bypasses this engine middleware)