From db01bdb6a424c601a008cc5c6874af949af6f577 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 02:28:35 +0000 Subject: [PATCH 1/9] =?UTF-8?q?feat(security)!:=20ADR-0090=20P1=20breaking?= =?UTF-8?q?=20wave=20=E2=80=94=20position=20rename,=20profile=20removal,?= =?UTF-8?q?=20secure=20OWD=20default,=20principal/external=20spec=20shapes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One coordinated pre-launch pass implementing ADR-0090 D1-D4 and the P1 spec shapes of D10/D11 (docs/adr/0090). No compatibility aliases. Renames (D3): sys_role→sys_position (+assignment/binding tables and fields), RoleSchema→PositionSchema (flat, parent removed), ctx.roles→ctx.positions, current_user.roles→current_user.positions (formula validators, RLS docs, CEL examples), stack roles:→positions:, metadata kinds role/profile→position. better-auth boundary untouched (sys_member.role, additionalOrgRoles, org_membership_level). Profile removal (D2): isProfile deleted from PermissionSetSchema and all fixtures; isDefault narrowed to an install-time suggestion; appDefaultProfileName→appDefaultPermissionSetName (isDefault-only). OWD (D1/D4/D11): enum reduced to the canonical four; custom objects with an owner field and no sharingModel now resolve PRIVATE (fail-closed for unrecognised stored values; system objects keep the public fall-through); new optional externalSharingModel field (external ≤ internal, P1 shape). Regression tests pin the objectui#2348 incident shape (unset OWD + C/R/U grant must not read others' records). Principal taxonomy (D10, shape only): principalKind/audience/onBehalfOf on ExecutionContext. Sharing recipients: 'role'→'position' expanded via sys_user_position ∪ the membership transition source; 'role_and_subordinates' removed — 'unit_and_subordinates' expands the BU subtree (ADR-0057 D5 re-homing, replacing the walk over a parent column that never existed). Translations patched in place per generated-file contract; conformance matrix row updated. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H --- .changeset/adr-0090-p1-breaking-wave.md | 47 +++++++ examples/app-crm/objectstack.config.ts | 8 +- examples/app-crm/src/security/index.ts | 8 +- .../{sales-roles.ts => sales-positions.ts} | 10 +- .../app-crm/src/security/sharing-rules.ts | 8 +- examples/app-showcase/src/coverage.ts | 2 +- examples/app-showcase/src/security/index.ts | 16 ++- examples/app-todo/test/mcp-actions.e2e.ts | 6 +- packages/cli/src/commands/serve.ts | 23 ++-- packages/cli/src/utils/format.ts | 6 +- packages/cli/test/i18n-extract.test.ts | 64 +++++----- packages/core/CHANGELOG.md | 4 +- .../security/resolve-authz-context.test.ts | 34 ++--- .../src/security/resolve-authz-context.ts | 48 +++---- .../dogfood/test/authz-conformance.matrix.ts | 8 +- packages/dogfood/test/fixtures/cbp-fixture.ts | 1 - .../test/fixtures/flow-runas-fixture.ts | 1 - .../test/fixtures/rls-owner-fixture.ts | 2 - ...wcase-bu-hierarchy-sharing.dogfood.test.ts | 2 +- ...howcase-d3-d4-capabilities.dogfood.test.ts | 1 - ...howcase-d7-default-profile.dogfood.test.ts | 10 +- ...e-declarative-rbac-seeding.dogfood.test.ts | 10 +- .../showcase-default-profile.dogfood.test.ts | 1 - .../test/showcase-invoice-cbp.dogfood.test.ts | 1 - ...ase-invoice-seed-isolation.dogfood.test.ts | 1 - .../showcase-public-read-owd.dogfood.test.ts | 2 +- ...wcase-scope-depth-fallback.dogfood.test.ts | 23 ++-- .../test/showcase-scope-depth.dogfood.test.ts | 1 - .../src/additional-domains.fixtures.ts | 5 +- .../downstream-contract/test/contract.test.ts | 4 +- packages/formula/src/cel-engine.test.ts | 4 +- packages/formula/src/stdlib.ts | 16 +-- packages/formula/src/types.ts | 10 +- packages/formula/src/validate.ts | 18 +-- packages/objectql/CHANGELOG.md | 4 +- packages/objectql/src/engine.ts | 16 +-- .../src/metadata-validation-sweep.test.ts | 4 +- packages/objectql/src/plugin.ts | 4 +- .../objectql/src/validation/rule-validator.ts | 2 +- packages/platform-objects/CHANGELOG.md | 12 +- .../en.metadata-forms.generated.ts | 8 -- .../es-ES.metadata-forms.generated.ts | 8 -- .../ja-JP.metadata-forms.generated.ts | 8 -- .../zh-CN.metadata-forms.generated.ts | 8 -- .../src/pages/sys-user.page.ts | 4 +- .../plugins/plugin-approvals/CHANGELOG.md | 8 +- .../src/approval-node.test.ts | 4 +- .../plugin-approvals/src/approval-node.ts | 2 +- .../src/approval-revise.test.ts | 6 +- .../src/approval-service.test.ts | 20 +-- .../plugin-approvals/src/approval-service.ts | 2 +- .../plugins/plugin-auth/src/auth-manager.ts | 49 ++++---- .../plugins/plugin-auth/src/auth-plugin.ts | 4 +- .../plugins/plugin-email/src/email-plugin.ts | 2 +- .../plugin-hono-server/src/hono-plugin.ts | 6 +- .../plugin-reports/src/report-service.test.ts | 2 +- .../plugin-reports/src/report-service.ts | 4 +- packages/plugins/plugin-security/CHANGELOG.md | 12 +- packages/plugins/plugin-security/README.md | 2 +- .../src/app-default-permission-set.test.ts | 27 ++++ .../src/app-default-permission-set.ts | 27 ++++ .../src/app-default-profile.test.ts | 31 ----- .../src/app-default-profile.ts | 36 ------ .../src/auto-org-admin-grant.ts | 2 +- ...oles.ts => bootstrap-builtin-positions.ts} | 20 +-- .../src/bootstrap-declared-permissions.ts | 4 +- ...les.ts => bootstrap-declared-positions.ts} | 38 +++--- packages/plugins/plugin-security/src/index.ts | 2 +- .../src/objects/default-permission-sets.ts | 16 +-- .../plugin-security/src/objects/index.ts | 6 +- .../src/objects/rbac-objects.test.ts | 8 +- .../src/objects/sys-permission-set.object.ts | 2 +- ... => sys-position-permission-set.object.ts} | 24 ++-- ...-role.object.ts => sys-position.object.ts} | 22 ++-- ....object.ts => sys-user-position.object.ts} | 39 +++--- .../src/permission-evaluator.ts | 4 +- .../plugin-security/src/rls-compiler.ts | 4 +- .../src/security-plugin.test.ts | 118 +++++++++--------- .../plugin-security/src/security-plugin.ts | 18 +-- .../src/translations/en.objects.generated.ts | 60 ++++----- .../translations/es-ES.objects.generated.ts | 44 +++---- .../translations/ja-JP.objects.generated.ts | 60 ++++----- .../translations/zh-CN.objects.generated.ts | 60 ++++----- packages/plugins/plugin-sharing/CHANGELOG.md | 18 +-- .../src/bootstrap-declared-sharing-rules.ts | 5 +- .../plugin-sharing/src/business-unit-graph.ts | 2 +- .../src/objects/sys-record-share.object.ts | 2 +- .../src/objects/sys-sharing-rule.object.ts | 4 +- .../plugin-sharing/src/position-graph.test.ts | 81 ++++++++++++ .../plugin-sharing/src/position-graph.ts | 91 ++++++++++++++ .../src/primary-bu-projection.ts | 2 +- .../plugin-sharing/src/role-graph.test.ts | 60 --------- .../plugins/plugin-sharing/src/role-graph.ts | 108 ---------------- .../plugins/plugin-sharing/src/rule-hooks.ts | 2 +- .../plugin-sharing/src/share-link-routes.ts | 4 +- .../plugin-sharing/src/share-link-service.ts | 2 +- .../plugin-sharing/src/sharing-plugin.ts | 2 +- .../src/sharing-rule-service.ts | 28 +++-- .../src/sharing-service.test.ts | 49 ++++++-- .../plugin-sharing/src/sharing-service.ts | 39 ++++-- .../plugins/plugin-sharing/src/team-graph.ts | 2 +- .../src/translations/en.objects.generated.ts | 2 +- .../translations/es-ES.objects.generated.ts | 2 +- .../translations/ja-JP.objects.generated.ts | 2 +- .../translations/zh-CN.objects.generated.ts | 2 +- packages/plugins/plugin-webhooks/CHANGELOG.md | 4 +- packages/rest/CHANGELOG.md | 4 +- packages/rest/src/rest-server.ts | 4 +- packages/runtime/CHANGELOG.md | 8 +- packages/runtime/src/dispatcher-plugin.ts | 2 +- .../runtime/src/http-dispatcher.keys.test.ts | 2 +- .../runtime/src/http-dispatcher.mcp.test.ts | 2 +- packages/runtime/src/http-dispatcher.ts | 6 +- .../resolve-execution-context.test.ts | 8 +- .../src/security/resolve-execution-context.ts | 8 +- packages/runtime/src/standalone-stack.test.ts | 22 ++-- packages/runtime/src/standalone-stack.ts | 4 +- .../src/builtin/crud-runas.test.ts | 4 +- .../src/runtime-identity.ts | 12 +- .../src/suspended-run-store.ts | 2 +- .../service-job/src/db-job-adapter.ts | 2 +- .../service-job/src/job-run-retention.ts | 2 +- .../src/__tests__/knowledge-service.test.ts | 6 +- .../src/knowledge-service.ts | 2 +- packages/services/service-queue/src/common.ts | 2 +- packages/spec/CHANGELOG.md | 14 +-- packages/spec/ZOD_SCHEMA_AUDIT_REPORT.md | 2 +- packages/spec/src/contracts/ai-service.ts | 2 +- packages/spec/src/contracts/auth-service.ts | 4 +- .../spec/src/contracts/automation-service.ts | 11 +- .../spec/src/contracts/metadata-service.ts | 2 +- .../spec/src/contracts/sharing-service.ts | 6 +- packages/spec/src/data/data-engine.test.ts | 8 +- packages/spec/src/data/field.zod.ts | 4 +- packages/spec/src/data/object.zod.ts | 16 ++- packages/spec/src/identity/eval-user.zod.ts | 68 +++++----- packages/spec/src/identity/index.ts | 4 +- packages/spec/src/identity/position.form.ts | 29 +++++ .../{role.test.ts => position.test.ts} | 44 +++---- packages/spec/src/identity/position.zod.ts | 62 +++++++++ packages/spec/src/identity/role.form.ts | 33 ----- packages/spec/src/identity/role.zod.ts | 56 --------- packages/spec/src/index.ts | 16 +-- .../spec/src/kernel/execution-context.test.ts | 8 +- .../spec/src/kernel/execution-context.zod.ts | 36 +++++- .../src/kernel/metadata-create-seeds.test.ts | 2 +- .../spec/src/kernel/metadata-plugin.test.ts | 2 +- .../spec/src/kernel/metadata-plugin.zod.ts | 6 +- .../spec/src/kernel/metadata-type-schemas.ts | 8 +- packages/spec/src/security/permission.form.ts | 3 +- packages/spec/src/security/permission.test.ts | 10 +- packages/spec/src/security/permission.zod.ts | 21 ++-- packages/spec/src/security/rls.test.ts | 20 +-- packages/spec/src/security/rls.zod.ts | 44 +++---- packages/spec/src/security/sharing.test.ts | 8 +- packages/spec/src/security/sharing.zod.ts | 6 +- .../src/shared/metadata-collection.test.ts | 2 +- .../src/shared/metadata-collection.zod.ts | 5 +- packages/spec/src/stack.test.ts | 12 +- packages/spec/src/stack.zod.ts | 8 +- .../src/system/constants/system-names.test.ts | 6 +- .../spec/src/system/constants/system-names.ts | 4 +- .../spec/src/system/metadata-form-registry.ts | 4 +- packages/spec/src/ui/app.zod.ts | 2 +- .../src/record-change-trigger.ts | 4 +- 165 files changed, 1280 insertions(+), 1215 deletions(-) create mode 100644 .changeset/adr-0090-p1-breaking-wave.md rename examples/app-crm/src/security/{sales-roles.ts => sales-positions.ts} (89%) create mode 100644 packages/plugins/plugin-security/src/app-default-permission-set.test.ts create mode 100644 packages/plugins/plugin-security/src/app-default-permission-set.ts delete mode 100644 packages/plugins/plugin-security/src/app-default-profile.test.ts delete mode 100644 packages/plugins/plugin-security/src/app-default-profile.ts rename packages/plugins/plugin-security/src/{bootstrap-builtin-roles.ts => bootstrap-builtin-positions.ts} (79%) rename packages/plugins/plugin-security/src/{bootstrap-declared-roles.ts => bootstrap-declared-positions.ts} (67%) rename packages/plugins/plugin-security/src/objects/{sys-role-permission-set.object.ts => sys-position-permission-set.object.ts} (67%) rename packages/plugins/plugin-security/src/objects/{sys-role.object.ts => sys-position.object.ts} (93%) rename packages/plugins/plugin-security/src/objects/{sys-user-role.object.ts => sys-user-position.object.ts} (56%) create mode 100644 packages/plugins/plugin-sharing/src/position-graph.test.ts create mode 100644 packages/plugins/plugin-sharing/src/position-graph.ts delete mode 100644 packages/plugins/plugin-sharing/src/role-graph.test.ts delete mode 100644 packages/plugins/plugin-sharing/src/role-graph.ts create mode 100644 packages/spec/src/identity/position.form.ts rename packages/spec/src/identity/{role.test.ts => position.test.ts} (81%) create mode 100644 packages/spec/src/identity/position.zod.ts delete mode 100644 packages/spec/src/identity/role.form.ts delete mode 100644 packages/spec/src/identity/role.zod.ts diff --git a/.changeset/adr-0090-p1-breaking-wave.md b/.changeset/adr-0090-p1-breaking-wave.md new file mode 100644 index 0000000000..6297dd1aba --- /dev/null +++ b/.changeset/adr-0090-p1-breaking-wave.md @@ -0,0 +1,47 @@ +--- +'@objectstack/spec': minor +'@objectstack/core': minor +'@objectstack/runtime': minor +'@objectstack/objectql': minor +'@objectstack/formula': minor +'@objectstack/rest': minor +'@objectstack/cli': minor +'@objectstack/plugin-security': minor +'@objectstack/plugin-sharing': minor +'@objectstack/plugin-auth': minor +'@objectstack/service-automation': minor +'@objectstack/trigger-record-change': minor +'@objectstack/platform-objects': minor +'@objectstack/metadata': minor +--- + +ADR-0090 P1 breaking wave — permission model v2 concept convergence. + +Pre-launch one-step renames and secure defaults (no compatibility aliases, per +ADR-0090 D3/D4 superseding ADR-0057 D5/D7's alias discipline): + +- `sys_role` → `sys_position`, `sys_user_role` → `sys_user_position` (field + `role` → `position`), `sys_role_permission_set` → `sys_position_permission_set` + (field `role_id` → `position_id`); `RoleSchema`/`defineRole` → + `PositionSchema`/`definePosition` with **no `parent`** (positions are flat; + hierarchy lives on the business-unit tree). +- `ExecutionContext.roles[]` → `positions[]`; the EvalUser/CEL contract + `current_user.roles` → `current_user.positions` (formula validators updated); + stack property `roles:` → `positions:`; metadata kinds `role`/`profile` → + `position` (profile kind removed). +- `isProfile` removed from `PermissionSetSchema` (ADR-0090 D2); `isDefault` + narrows to an install-time suggestion; `appDefaultProfileName` → + `appDefaultPermissionSetName` (isDefault-only). +- OWD enum drops legacy aliases `read`/`read_write`/`full`; new optional + `externalSharingModel` (external dial, `private` default) lands as P1 spec + shape (ADR-0090 D11). +- **Secure default (D1)**: a custom object with an owner field and NO + `sharingModel` now resolves `private` (was: fully public). System objects + keep their explicit posture. Unrecognised stored values fail closed. +- ExecutionContext gains the P1 principal-taxonomy shape (D10): + `principalKind` / `audience` / `onBehalfOf` (optional, semantics phase in + later). +- Sharing recipients: `role` → `position` (expanded via `sys_user_position` + ∪ the better-auth membership transition source); `role_and_subordinates` + removed — `unit_and_subordinates` now expands the business-unit subtree + (finishes ADR-0057 D5's re-homing). diff --git a/examples/app-crm/objectstack.config.ts b/examples/app-crm/objectstack.config.ts index e4915e3000..c62cb6dc6b 100644 --- a/examples/app-crm/objectstack.config.ts +++ b/examples/app-crm/objectstack.config.ts @@ -12,9 +12,9 @@ import * as actions from './src/actions/index.js'; import { allHooks } from './src/hooks/index.js'; import { allFlows } from './src/flows/index.js'; import { - SalesRepRole, - SalesManagerRole, - FinanceApproverRole, + SalesRepPosition, + SalesManagerPosition, + FinanceApproverPosition, SalesUserPermissionSet, GuestPortalProfile, HighValueOpportunitySharingRule, @@ -89,7 +89,7 @@ export default defineStack({ flows: allFlows, // Security - roles: [SalesRepRole, SalesManagerRole, FinanceApproverRole], + positions: [SalesRepPosition, SalesManagerPosition, FinanceApproverPosition], permissions: [SalesUserPermissionSet, GuestPortalProfile], sharingRules: [ HighValueOpportunitySharingRule, diff --git a/examples/app-crm/src/security/index.ts b/examples/app-crm/src/security/index.ts index c4d70386a4..99f94e182b 100644 --- a/examples/app-crm/src/security/index.ts +++ b/examples/app-crm/src/security/index.ts @@ -1,12 +1,12 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. export { - SalesRepRole, - SalesManagerRole, - FinanceApproverRole, + SalesRepPosition, + SalesManagerPosition, + FinanceApproverPosition, SalesUserPermissionSet, GuestPortalProfile, -} from './sales-roles.js'; +} from './sales-positions.js'; export { HighValueOpportunitySharingRule, diff --git a/examples/app-crm/src/security/sales-roles.ts b/examples/app-crm/src/security/sales-positions.ts similarity index 89% rename from examples/app-crm/src/security/sales-roles.ts rename to examples/app-crm/src/security/sales-positions.ts index de8c7d4ebe..08000817c6 100644 --- a/examples/app-crm/src/security/sales-roles.ts +++ b/examples/app-crm/src/security/sales-positions.ts @@ -1,18 +1,18 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { defineRole } from '@objectstack/spec/identity'; +import { definePosition } from '@objectstack/spec/identity'; import { definePermissionSet } from '@objectstack/spec/security'; /** * Example roles — a small sales hierarchy. */ -export const SalesRepRole = defineRole({ +export const SalesRepPosition = definePosition({ name: 'sales_rep', label: 'Sales Representative', description: 'Front-line sales representative.', }); -export const SalesManagerRole = defineRole({ +export const SalesManagerPosition = definePosition({ name: 'sales_manager', label: 'Sales Manager', description: 'Manages a team of sales reps.', @@ -20,7 +20,7 @@ export const SalesManagerRole = defineRole({ }); /** Referenced by the Discount Approval second step. */ -export const FinanceApproverRole = defineRole({ +export const FinanceApproverPosition = definePosition({ name: 'finance_approver', label: 'Finance Approver', description: 'Finance team member authorised to approve discounts above 30%.', @@ -34,7 +34,6 @@ export const FinanceApproverRole = defineRole({ export const SalesUserPermissionSet = definePermissionSet({ name: 'crm_sales_user', label: 'CRM Sales User', - isProfile: false, objects: { crm_account: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, crm_contact: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, @@ -55,7 +54,6 @@ export const SalesUserPermissionSet = definePermissionSet({ export const GuestPortalProfile = definePermissionSet({ name: 'guest_portal', label: 'Guest (Public Forms)', - isProfile: true, objects: { crm_lead: { allowRead: false, allowCreate: true, allowEdit: false, allowDelete: false }, }, diff --git a/examples/app-crm/src/security/sharing-rules.ts b/examples/app-crm/src/security/sharing-rules.ts index 4d4407a9e3..96425cc92f 100644 --- a/examples/app-crm/src/security/sharing-rules.ts +++ b/examples/app-crm/src/security/sharing-rules.ts @@ -15,7 +15,7 @@ export const HighValueOpportunitySharingRule = defineSharingRule({ condition: 'record.amount > 100000', accessLevel: 'edit', sharedWith: { - type: 'role', + type: 'position', value: 'sales_manager', }, active: true, @@ -32,12 +32,12 @@ export const RepLeadSharingRule = defineSharingRule({ description: "Share each rep's leads with the Sales Manager role for coaching visibility.", object: 'crm_lead', ownedBy: { - type: 'role', + type: 'position', value: 'sales_rep', }, accessLevel: 'read', sharedWith: { - type: 'role', + type: 'position', value: 'sales_manager', }, active: true, @@ -56,7 +56,7 @@ export const WonDealActivitySharingRule = defineSharingRule({ condition: "record.status == 'completed'", accessLevel: 'read', sharedWith: { - type: 'role', + type: 'position', value: 'sales_rep', }, active: true, diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts index c3325309fa..36d605a6e1 100644 --- a/examples/app-showcase/src/coverage.ts +++ b/examples/app-showcase/src/coverage.ts @@ -123,7 +123,7 @@ export const KIND_COVERAGE: Record = { profile: { status: 'demonstrated', files: ['src/security/index.ts'], - notes: 'MemberDefaultProfile — a permission set with isProfile: true (ADR-0056 D7).', + notes: 'MemberDefault — the isDefault permission set (ADR-0090 D5 suggestion flag).', }, role: { status: 'demonstrated', files: ['src/security/index.ts'] }, diff --git a/examples/app-showcase/src/security/index.ts b/examples/app-showcase/src/security/index.ts index 3b40155dd3..d8cceff104 100644 --- a/examples/app-showcase/src/security/index.ts +++ b/examples/app-showcase/src/security/index.ts @@ -35,7 +35,6 @@ export const ContributorPermissionSet = { name: 'showcase_contributor', label: 'Showcase Contributor', description: 'Standard access for contributors, with budget fields hidden and row-level scoping to own records.', - isProfile: false, objects: { showcase_project: { allowRead: true, allowCreate: false, allowEdit: true, allowDelete: false }, showcase_task: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, @@ -61,7 +60,7 @@ export const ContributorPermissionSet = { object: 'showcase_task', operation: 'select' as const, using: "assignee == current_user.email", - roles: ['contributor'], + positions: ['contributor'], enabled: true, priority: 10, }, @@ -76,7 +75,7 @@ export const ContributorPermissionSet = { object: 'showcase_invoice', operation: 'select' as const, using: "owner == current_user.email", - roles: ['contributor'], + positions: ['contributor'], enabled: true, priority: 10, }, @@ -92,7 +91,7 @@ export const ContributorPermissionSet = { object: 'showcase_invoice', operation: 'update' as const, check: "owner == current_user.email", - roles: ['contributor'], + positions: ['contributor'], enabled: true, priority: 10, }, @@ -114,7 +113,6 @@ export const MemberDefaultProfile = { name: 'showcase_member_default', label: 'Showcase Member (Default)', description: 'App-declared default profile for new sign-ups — read-mostly baseline (ADR-0056 D7).', - isProfile: true, isDefault: true, objects: { showcase_account: { allowRead: true }, @@ -136,7 +134,7 @@ export const RedProjectSharingRule = { object: 'showcase_project', condition: "record.health == 'red'", accessLevel: 'read' as const, - sharedWith: { type: 'role' as const, value: 'exec' }, + sharedWith: { type: 'position' as const, value: 'exec' }, active: true, }; @@ -155,7 +153,7 @@ export const HighValueRedProjectRule = { object: 'showcase_project', condition: "record.health == 'red' && record.budget > 100000", accessLevel: 'read' as const, - sharedWith: { type: 'role' as const, value: 'manager' }, + sharedWith: { type: 'position' as const, value: 'manager' }, active: true, }; @@ -166,9 +164,9 @@ export const ContributorTaskSharingRule = { label: "Contributor Tasks → Manager", description: "Share each contributor's tasks with managers for oversight.", object: 'showcase_task', - ownedBy: { type: 'role' as const, value: 'contributor' }, + ownedBy: { type: 'position' as const, value: 'contributor' }, accessLevel: 'read' as const, - sharedWith: { type: 'role' as const, value: 'manager' }, + sharedWith: { type: 'position' as const, value: 'manager' }, active: true, }; diff --git a/examples/app-todo/test/mcp-actions.e2e.ts b/examples/app-todo/test/mcp-actions.e2e.ts index dea4affbdf..092fa40d8c 100644 --- a/examples/app-todo/test/mcp-actions.e2e.ts +++ b/examples/app-todo/test/mcp-actions.e2e.ts @@ -101,7 +101,7 @@ function mcpRequest(body: unknown): Request { }); // The acting user — an authenticated, non-system principal. - const user = { userId: 'user_1', roles: [], permissions: [], systemPermissions: [] }; + const user = { userId: 'user_1', positions: [], permissions: [], systemPermissions: [] }; const bridge = bridgeFor(user); // ── Step 1 — the MCP server advertises the action tools ─────────── @@ -151,7 +151,7 @@ function mcpRequest(body: unknown): Request { ? o : { ...o, actions: o.actions.map((a: any) => (a.name === 'clone_task' ? { ...a, requiredPermissions: ['todo_admin'] } : a)) }, ); - const denyBridge = bridgeFor({ userId: 'user_2', roles: [], permissions: [], systemPermissions: [] }, gatedObjects); + const denyBridge = bridgeFor({ userId: 'user_2', positions: [], permissions: [], systemPermissions: [] }, gatedObjects); const denied = await callMcp(denyBridge, toolsCall(5, 'run_action', { actionName: 'clone_task', recordId: taskId })); check(denied.result?.isError === true, 'run_action denied when the caller lacks the capability'); check(/requires capability/i.test(denied.result?.content?.[0]?.text ?? ''), 'denial cites the missing capability'); @@ -159,7 +159,7 @@ function mcpRequest(body: unknown): Request { const denyList = JSON.parse((await callMcp(denyBridge, toolsCall(6, 'list_actions', {}))).result.content[0].text).actions as any[]; check(!denyList.some((a) => a.name === 'clone_task'), 'list_actions hides the gated action from a non-holder'); - const allowBridge = bridgeFor({ userId: 'admin_1', roles: [], permissions: [], systemPermissions: ['todo_admin'] }, gatedObjects); + const allowBridge = bridgeFor({ userId: 'admin_1', positions: [], permissions: [], systemPermissions: ['todo_admin'] }, gatedObjects); const allowed = await callMcp(allowBridge, toolsCall(7, 'run_action', { actionName: 'clone_task', recordId: taskId })); check(allowed.result?.isError !== true, 'run_action allowed for a holder of the capability'); const allowList = JSON.parse((await callMcp(allowBridge, toolsCall(8, 'list_actions', {}))).result.content[0].text).actions as any[]; diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 7e95b218fa..0336b26c78 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1192,13 +1192,12 @@ export default class Serve extends Command { // Collect application-defined org roles from the stack so // Better-Auth's organization plugin accepts invitations to - // those roles (otherwise it 400s with `ROLE_NOT_FOUND`). - // Sources: - // - top-level `roles[]` (role hierarchy entries) - // - `permissions[]` PermissionSets where `isProfile === true` - // (these double as role identifiers; e.g. CRM Profiles) - // Real RBAC enforcement is still owned by SecurityPlugin, which - // matches these names against `permission` metadata entries. + // those names (otherwise it 400s with `ROLE_NOT_FOUND`). + // better-auth boundary: its API keeps the word "roles" + // (ADR-0090 D3 exception). Sources: + // - top-level `positions[]` (flat distribution groups) + // - `permissions[]` PermissionSet names + // Real RBAC enforcement is still owned by SecurityPlugin. const additionalOrgRoles = new Set(); try { const stackAny: any = config ?? {}; @@ -1209,10 +1208,10 @@ export default class Serve extends Command { if (n && n !== 'owner' && n !== 'admin' && n !== 'member') additionalOrgRoles.add(n); } }; - collect(stackAny.roles); + collect(stackAny.positions); if (Array.isArray(stackAny.permissions)) { for (const p of stackAny.permissions) { - if (p && typeof p.name === 'string' && p.isProfile !== false) { + if (p && typeof p.name === 'string') { if (p.name !== 'owner' && p.name !== 'admin' && p.name !== 'member') additionalOrgRoles.add(p.name); } } @@ -1301,13 +1300,13 @@ export default class Serve extends Command { // Pair: SecurityPlugin (RBAC) — optional try { const securityPkg = '@objectstack/plugin-security'; - const { SecurityPlugin, appDefaultProfileName } = await import(/* webpackIgnore: true */ securityPkg); + const { SecurityPlugin, appDefaultPermissionSetName } = await import(/* webpackIgnore: true */ securityPkg); // ADR-0056 D7 — honor an app-declared default profile. A stack - // permission set marked `isProfile && isDefault` becomes the + // permission set marked `isDefault` becomes the // fallback for users with no explicit grants. The SecurityPlugin's // own scan only sees its built-in sets, so the CLI passes the // declared name through explicitly (undefined → built-in default). - const appDefaultProfile = appDefaultProfileName((config as any)?.permissions); + const appDefaultProfile = appDefaultPermissionSetName((config as any)?.permissions); await kernel.use(new SecurityPlugin(appDefaultProfile ? { fallbackPermissionSet: appDefaultProfile } : undefined)); trackPlugin('Security'); } catch { diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index 038dab5a05..a7ea1152b5 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -123,7 +123,7 @@ export interface MetadataStats { workflows: number; agents: number; apis: number; - roles: number; + positions: number; permissions: number; themes: number; datasources: number; @@ -163,7 +163,7 @@ export function collectMetadataStats(config: any): MetadataStats { workflows: count(config.workflows), agents: count(config.agents), apis: count(config.apis), - roles: count(config.roles), + positions: count(config.positions), permissions: count(config.permissions), themes: count(config.themes), datasources: count(config.datasources), @@ -268,7 +268,7 @@ export function printMetadataStats(stats: MetadataStats) { { label: 'Security', items: [ - ['Roles', stats.roles], + ['Positions', stats.positions], ['Permissions', stats.permissions], ], }, diff --git a/packages/cli/test/i18n-extract.test.ts b/packages/cli/test/i18n-extract.test.ts index 35e8973e00..0dc28a5577 100644 --- a/packages/cli/test/i18n-extract.test.ts +++ b/packages/cli/test/i18n-extract.test.ts @@ -10,7 +10,7 @@ import { const config: any = { objects: [ { - name: 'sys_role', + name: 'sys_position', label: 'Role', pluralLabel: 'Roles', description: 'Role definitions for RBAC', @@ -39,7 +39,7 @@ const config: any = { { name: 'merge', label: 'Merge', - objectName: 'sys_role', + objectName: 'sys_position', confirmText: 'Merge?', successMessage: 'Merged.', }, @@ -49,7 +49,7 @@ const config: any = { { en: { objects: { - sys_role: { + sys_position: { label: 'Role', // pluralLabel missing intentionally fields: { active: { label: 'Active' } }, @@ -65,19 +65,19 @@ describe('collectExpectedEntries', () => { const entries = collectExpectedEntries(config); const paths = entries.map((e) => e.path.join('.')); - expect(paths).toContain('objects.sys_role.label'); - expect(paths).toContain('objects.sys_role.pluralLabel'); - expect(paths).toContain('objects.sys_role.description'); - expect(paths).toContain('objects.sys_role.fields.label.label'); - expect(paths).toContain('objects.sys_role.fields.status.options.on'); - expect(paths).toContain('objects.sys_role.fields.status.options.off'); - expect(paths).toContain('objects.sys_role.fields.kind.options.internal'); - expect(paths).toContain('objects.sys_role.fields.kind.options.external'); - expect(paths).toContain('objects.sys_role._views.active.label'); - expect(paths).toContain('objects.sys_role._views.all.label'); - expect(paths).toContain('objects.sys_role._actions.merge.label'); - expect(paths).toContain('objects.sys_role._actions.merge.confirmText'); - expect(paths).toContain('objects.sys_role._actions.merge.successMessage'); + expect(paths).toContain('objects.sys_position.label'); + expect(paths).toContain('objects.sys_position.pluralLabel'); + expect(paths).toContain('objects.sys_position.description'); + expect(paths).toContain('objects.sys_position.fields.label.label'); + expect(paths).toContain('objects.sys_position.fields.status.options.on'); + expect(paths).toContain('objects.sys_position.fields.status.options.off'); + expect(paths).toContain('objects.sys_position.fields.kind.options.internal'); + expect(paths).toContain('objects.sys_position.fields.kind.options.external'); + expect(paths).toContain('objects.sys_position._views.active.label'); + expect(paths).toContain('objects.sys_position._views.all.label'); + expect(paths).toContain('objects.sys_position._actions.merge.label'); + expect(paths).toContain('objects.sys_position._actions.merge.confirmText'); + expect(paths).toContain('objects.sys_position._actions.merge.successMessage'); expect(paths).toContain('globalActions.export_csv.label'); expect(paths).toContain('globalActions.export_csv.successMessage'); expect(paths).toContain('metadataForms.flow.fields.name.label'); @@ -86,10 +86,10 @@ describe('collectExpectedEntries', () => { it('carries source values from the schema', () => { const entries = collectExpectedEntries(config); const byPath = Object.fromEntries(entries.map((e) => [e.path.join('.'), e.sourceValue])); - expect(byPath['objects.sys_role.label']).toBe('Role'); - expect(byPath['objects.sys_role.fields.status.options.on']).toBe('On'); - expect(byPath['objects.sys_role.fields.kind.options.internal']).toBe('Internal'); - expect(byPath['objects.sys_role._actions.merge.label']).toBe('Merge'); + expect(byPath['objects.sys_position.label']).toBe('Role'); + expect(byPath['objects.sys_position.fields.status.options.on']).toBe('On'); + expect(byPath['objects.sys_position.fields.kind.options.internal']).toBe('Internal'); + expect(byPath['objects.sys_position._actions.merge.label']).toBe('Merge'); expect(byPath['metadataForms.flow.fields.name.label']).toBe('Name'); }); }); @@ -104,9 +104,9 @@ describe('extractTranslations', () => { }); expect(counts.en).toBeGreaterThan(0); expect(counts['zh-CN']).toBe(counts.en); - expect(bundles.en.objects?.sys_role?.label).toBe('Role'); - expect(bundles['zh-CN'].objects?.sys_role?.label).toBe(''); - expect(bundles['zh-CN'].objects?.sys_role?.fields?.status?.options?.on).toBe(''); + expect(bundles.en.objects?.sys_position?.label).toBe('Role'); + expect(bundles['zh-CN'].objects?.sys_position?.label).toBe(''); + expect(bundles['zh-CN'].objects?.sys_position?.fields?.status?.options?.on).toBe(''); }); it('supports --fill=default (copy from source) and --fill=todo (prefix)', () => { @@ -115,14 +115,14 @@ describe('extractTranslations', () => { locales: ['zh-CN'], fill: 'default', }); - expect(bundles['zh-CN'].objects?.sys_role?.label).toBe('Role'); + expect(bundles['zh-CN'].objects?.sys_position?.label).toBe('Role'); const { bundles: todoBundles } = extractTranslations(config, { defaultLocale: 'en', locales: ['zh-CN'], fill: 'todo', }); - expect(todoBundles['zh-CN'].objects?.sys_role?.label).toBe('[TODO] Role'); + expect(todoBundles['zh-CN'].objects?.sys_position?.label).toBe('[TODO] Role'); }); it('mergeExisting carries through values already translated in the input bundle', () => { @@ -133,17 +133,17 @@ describe('extractTranslations', () => { }); // Existing translations are preserved verbatim so the generated file // is a complete, self-contained bundle (not just a delta). - expect(bundles.en.objects?.sys_role?.label).toBe('Role'); - expect(bundles.en.objects?.sys_role?.fields?.active?.label).toBe('Active'); + expect(bundles.en.objects?.sys_position?.label).toBe('Role'); + expect(bundles.en.objects?.sys_position?.fields?.active?.label).toBe('Active'); // Missing keys are still filled from schema defaults. - expect(bundles.en.objects?.sys_role?.pluralLabel).toBe('Roles'); - expect(bundles.en.objects?.sys_role?.fields?.label?.label).toBe('Display Name'); + expect(bundles.en.objects?.sys_position?.pluralLabel).toBe('Roles'); + expect(bundles.en.objects?.sys_position?.fields?.label?.label).toBe('Display Name'); }); it('filters by object name regex', () => { const cfg = { objects: [ - { name: 'sys_role', label: 'Role', fields: {} }, + { name: 'sys_position', label: 'Role', fields: {} }, { name: 'crm_account', label: 'Account', fields: {} }, ], }; @@ -152,7 +152,7 @@ describe('extractTranslations', () => { filter: /^sys_/, mergeExisting: false, }); - expect(bundles.en.objects?.sys_role).toBeDefined(); + expect(bundles.en.objects?.sys_position).toBeDefined(); expect(bundles.en.objects?.crm_account).toBeUndefined(); expect(totalExpected).toBe(1); }); @@ -167,7 +167,7 @@ describe('renderTranslationModule', () => { const ts = renderTranslationModule(bundles.en, { locale: 'en' }); expect(ts).toContain("import type { TranslationData } from '@objectstack/spec/system'"); expect(ts).toContain('export const enObjects:'); - expect(ts).toContain('sys_role:'); + expect(ts).toContain('sys_position:'); expect(ts).toContain('label: "Role"'); }); diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index eade839a26..6945ee9bb5 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -273,9 +273,9 @@ This is a metadata-only change (license text and `package.json` already agreed); the patch bump republishes the affected packages with the corrected `LICENSE`. -- aa33b02: fix(security): single-source the request authorization resolver — REST no longer drops sys_user_role +- aa33b02: fix(security): single-source the request authorization resolver — REST no longer drops sys_user_position - The REST server and the runtime dispatcher each carried their own copy of the request → ExecutionContext identity/role resolver, and they drifted on a security path. The REST copy silently omitted `sys_user_role` (so custom roles granted via the ADR-0057 D4 platform-RBAC path did not apply over REST), `sys_role_permission_set`, the `owner→org_owner` membership normalization, the platform-admin derivation, and the `ai_seat` synthesis — fail-closed (legitimate access denied), not an escalation. + The REST server and the runtime dispatcher each carried their own copy of the request → ExecutionContext identity/role resolver, and they drifted on a security path. The REST copy silently omitted `sys_user_position` (so custom roles granted via the ADR-0057 D4 platform-RBAC path did not apply over REST), `sys_position_permission_set`, the `owner→org_owner` membership normalization, the platform-admin derivation, and the `ai_seat` synthesis — fail-closed (legitimate access denied), not an escalation. Both entry points now delegate to a single shared resolver, `resolveAuthzContext` in `@objectstack/core/security` (joining the API-key verifier that already lived there). A contract test locks every authorization source and a lint gate (`check:authz-resolver`) prevents a future duplicate resolver or a dropped delegation. diff --git a/packages/core/src/security/resolve-authz-context.test.ts b/packages/core/src/security/resolve-authz-context.test.ts index ea0405173c..c8270e3935 100644 --- a/packages/core/src/security/resolve-authz-context.test.ts +++ b/packages/core/src/security/resolve-authz-context.test.ts @@ -7,7 +7,7 @@ import { resolveAuthzContext, resolveLocalizationContext } from './resolve-authz * Contract test for the SINGLE authorization resolver. Every authorization * source MUST be honored here — this is the regression net that would have * caught the REST-vs-dispatcher drift (the REST copy had silently dropped - * sys_user_role / sys_role_permission_set / platform_admin / ai_seat). + * sys_user_position / sys_position_permission_set / platform_admin / ai_seat). */ // Minimal in-memory ObjectQL: find(object, { where }) with `===` + `$in` match. @@ -30,36 +30,36 @@ const session = (userId: string, opts: { email?: string; org?: string } = {}) => const H = () => new Headers(); describe('resolveAuthzContext — single source of truth', () => { - it('resolves a custom role granted via sys_user_role (the REST-drift bug)', async () => { + it('resolves a custom role granted via sys_user_position (the REST-drift bug)', async () => { const ql = makeQl({ sys_user: [{ id: 'u1', email: 'ada@x.com' }], sys_member: [], - sys_user_role: [{ user_id: 'u1', role: 'contributor', organization_id: null }], + sys_user_position: [{ user_id: 'u1', role: 'contributor', organization_id: null }], sys_user_permission_set: [], }); const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); - expect(ctx.roles).toContain('contributor'); + expect(ctx.positions).toContain('contributor'); }); it('normalizes sys_member org roles (owner -> org_owner)', async () => { const ql = makeQl({ sys_user: [{ id: 'u1' }], sys_member: [{ user_id: 'u1', role: 'owner', organization_id: 'o1' }], - sys_user_role: [], + sys_user_position: [], sys_user_permission_set: [], }); const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); - expect(ctx.roles).toContain('org_owner'); + expect(ctx.positions).toContain('org_owner'); }); - it('resolves role-bound permission sets (sys_role_permission_set)', async () => { + it('resolves role-bound permission sets (sys_position_permission_set)', async () => { const ql = makeQl({ sys_user: [{ id: 'u1' }], sys_member: [], - sys_user_role: [{ user_id: 'u1', role: 'contributor', organization_id: null }], + sys_user_position: [{ user_id: 'u1', role: 'contributor', organization_id: null }], sys_user_permission_set: [], - sys_role: [{ id: 'r1', name: 'contributor' }], - sys_role_permission_set: [{ role_id: 'r1', permission_set_id: 'ps1' }], + sys_position: [{ id: 'r1', name: 'contributor' }], + sys_position_permission_set: [{ role_id: 'r1', permission_set_id: 'ps1' }], sys_permission_set: [{ id: 'ps1', name: 'contributor_ps', system_permissions: ['cap_x'] }], }); const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); @@ -71,31 +71,31 @@ describe('resolveAuthzContext — single source of truth', () => { const ql = makeQl({ sys_user: [{ id: 'u1' }], sys_member: [], - sys_user_role: [], + sys_user_position: [], sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', organization_id: null }], sys_permission_set: [{ id: 'psA', name: 'admin_full_access' }], }); const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); - expect(ctx.roles).toContain('platform_admin'); + expect(ctx.positions).toContain('platform_admin'); }); it('does NOT derive platform_admin from an ORG-scoped admin_full_access grant', async () => { const ql = makeQl({ sys_user: [{ id: 'u1' }], sys_member: [], - sys_user_role: [], + sys_user_position: [], sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', organization_id: 'o1' }], sys_permission_set: [{ id: 'psA', name: 'admin_full_access' }], }); const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); - expect(ctx.roles).not.toContain('platform_admin'); + expect(ctx.positions).not.toContain('platform_admin'); }); it('synthesizes ai_seat from sys_user.ai_access (sqlite integer 1)', async () => { const ql = makeQl({ sys_user: [{ id: 'u1', ai_access: 1 }], sys_member: [], - sys_user_role: [], + sys_user_position: [], sys_user_permission_set: [], }); const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); @@ -105,7 +105,7 @@ describe('resolveAuthzContext — single source of truth', () => { it('anonymous (no session, no api key) → empty context', async () => { const ctx = await resolveAuthzContext({ ql: makeQl({}), headers: H(), getSession: async () => undefined }); expect(ctx.userId).toBeUndefined(); - expect(ctx.roles).toEqual([]); + expect(ctx.positions).toEqual([]); expect(ctx.permissions).toEqual([]); }); }); @@ -137,7 +137,7 @@ describe('resolveAuthzContext — request-scoped read de-duplication (#2409)', ( const ql = makeCountingQl({ sys_user: [{ id: 'u1', email: 'ada@x.com', ai_access: 1 }], sys_member: [], - sys_user_role: [], + sys_user_position: [], sys_user_permission_set: [], }); const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 2c836fbf8b..1d04cc3ffa 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -2,18 +2,18 @@ /** * resolveAuthzContext — the SINGLE source of truth for resolving an inbound - * request's identity + authorization context (roles, permissions, RLS scoping). + * request's identity + authorization context (positions, permissions, RLS scoping). * * Every HTTP entry point (REST server, runtime dispatcher, MCP, any future * transport) MUST resolve authorization through this function — never by - * re-reading `sys_member` / `sys_user_role` / `sys_*_permission_set` itself. + * re-reading `sys_member` / `sys_user_position` / `sys_*_permission_set` itself. * * Why this exists: authorization resolution used to be DUPLICATED across the * REST server (`@objectstack/rest`) and the runtime dispatcher * (`@objectstack/runtime`). On a security path, duplicated logic drifts and the - * drift is silent: the REST copy had quietly omitted `sys_user_role` (so custom + * drift is silent: the REST copy had quietly omitted `sys_user_position` (so custom * roles granted via the ADR-0057 D4 platform-RBAC path didn't apply over REST), - * `sys_role_permission_set`, `mapMembershipRole` normalization, the + * `sys_position_permission_set`, `mapMembershipRole` normalization, the * platform-admin derivation, and the `ai_seat` synthesis. The API-key half was * already shared here (`resolveApiKeyPrincipal`); this completes the extraction * by bringing session + role/permission aggregation home too. There is now ONE @@ -21,13 +21,13 @@ * `getSession` their own way and delegate here. * * Fail-closed: every read is defensive. Missing services / tables yield a - * partial context (even `{ roles: [], permissions: [] }`) — enforcement is the + * partial context (even `{ positions: [], permissions: [] }`) — enforcement is the * SecurityPlugin's job, never this resolver's. */ import { mapMembershipRole, - BUILTIN_ROLE_PLATFORM_ADMIN, + BUILTIN_IDENTITY_PLATFORM_ADMIN, ADMIN_FULL_ACCESS, } from '@objectstack/spec'; @@ -39,7 +39,7 @@ export interface ResolvedAuthzContext { tenantId?: string; email?: string; accessToken?: string; - roles: string[]; + positions: string[]; permissions: string[]; systemPermissions: string[]; tabPermissions?: Record; @@ -79,12 +79,12 @@ async function tryFind(ql: any, object: string, where: any, limit = 100): Promis /** * Resolve the authorization context for an inbound request. Always resolves — - * never throws. Anonymous requests yield `{ roles: [], permissions: [], ... }`. + * never throws. Anonymous requests yield `{ positions: [], permissions: [], ... }`. */ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise { const { ql, headers } = input; const ctx: ResolvedAuthzContext = { - roles: [], + positions: [], permissions: [], systemPermissions: [], org_user_ids: [], @@ -153,20 +153,20 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise s.trim()).filter(Boolean)) { const r = mapMembershipRole(raw); - if (!ctx.roles.includes(r)) ctx.roles.push(r); + if (!ctx.positions.includes(r)) ctx.positions.push(r); } } } - // 4. [ADR-0057 D4] Platform-owned RBAC role assignments (sys_user_role) — the + // 4. [ADR-0057 D4] Platform-owned RBAC role assignments (sys_user_position) — the // source of truth for custom roles, decoupled from sys_member.role. // `organization_id = null` = global (cross-tenant); else match active org. - const userRoleRows = await tryFind(ql, 'sys_user_role', { user_id: userId }, 200); - for (const ur of userRoleRows) { + const userPositionRows = await tryFind(ql, 'sys_user_position', { user_id: userId }, 200); + for (const ur of userPositionRows) { const org = ur.organization_id ?? null; if (org && tenantId && org !== tenantId) continue; - const r = ur.role; - if (typeof r === 'string' && r && !ctx.roles.includes(r)) ctx.roles.push(r); + const r = ur.position; + if (typeof r === 'string' && r && !ctx.positions.includes(r)) ctx.positions.push(r); } // 5. Fellow-org user IDs so RLS can scope identity tables to collaborators. @@ -204,13 +204,13 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise 0) { - const roleRows = await tryFind(ql, 'sys_role', { name: { $in: ctx.roles } }, 100); - const roleIds = roleRows.map((r) => r.id).filter(Boolean); - if (roleIds.length > 0) { - const rpsRows = await tryFind(ql, 'sys_role_permission_set', { role_id: { $in: roleIds } }, 500); + // 6a. Position-bound permission sets (sys_position_permission_set): a position + // carries its permission sets. + if (ctx.positions.length > 0) { + const positionRows = await tryFind(ql, 'sys_position', { name: { $in: ctx.positions } }, 100); + const positionIds = positionRows.map((r) => r.id).filter(Boolean); + if (positionIds.length > 0) { + const rpsRows = await tryFind(ql, 'sys_position_permission_set', { position_id: { $in: positionIds } }, 500); for (const r of rpsRows) { const id = r.permission_set_id ?? r.permissionSetId; if (id) psIds.add(id); @@ -252,8 +252,8 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise; -const appDefault = appDefaultProfileName(stackPerms); +const appDefault = appDefaultPermissionSetName(stackPerms); const declaredDefault = stackPerms.find((p) => p?.name === appDefault) as unknown; describe('showcase: app-declared default profile, CLI-wired (ADR-0056 D7)', () => { @@ -29,7 +29,7 @@ describe('showcase: app-declared default profile, CLI-wired (ADR-0056 D7)', () = // The full CLI boot loads stack permission sets into the metadata service, so // `fallbackPermissionSet: ` resolves there. The lightweight harness does // not seed permission metadata, so we hand the declared default to the plugin - // directly — then wire it by NAME exactly as the CLI's appDefaultProfileName + // directly — then wire it by NAME exactly as the CLI's appDefaultPermissionSetName // path does (constructor uses the explicit name, not its own isDefault scan). stack = await bootStack(showcaseStack, { security: new SecurityPlugin({ @@ -43,7 +43,7 @@ describe('showcase: app-declared default profile, CLI-wired (ADR-0056 D7)', () = afterAll(async () => { await stack?.stop(); }); - it('appDefaultProfileName extracts the showcase default profile from stack metadata', () => { + it('appDefaultPermissionSetName extracts the showcase default profile from stack metadata', () => { expect(appDefault).toBe('showcase_member_default'); }); diff --git a/packages/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts b/packages/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts index aae4430ec7..11a7bbed42 100644 --- a/packages/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts +++ b/packages/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts @@ -1,8 +1,8 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // // ADR-0057 D6 / #2077 — stack-declared `roles` + `sharingRules` are seeded into -// sys_role / sys_sharing_rule at boot, so they stop being decorative. The issue -// reported booting the showcase yielded sys_role count = 0 and sys_sharing_rule +// sys_position / sys_sharing_rule at boot, so they stop being decorative. The issue +// reported booting the showcase yielded sys_position count = 0 and sys_sharing_rule // count = 0. This proves the opposite, plus the spec→runtime translation. // // @proof: showcase-declarative-rbac-seeding @@ -22,8 +22,8 @@ describe('showcase: declarative RBAC seeding (ADR-0057 D6 / #2077)', () => { }, 60_000); afterAll(async () => { await stack?.stop(); }); - it('declared roles land in sys_role (was count = 0)', async () => { - const roles = await ql.find('sys_role', { where: {}, context: { isSystem: true } }); + it('declared roles land in sys_position (was count = 0)', async () => { + const roles = await ql.find('sys_position', { where: {}, context: { isSystem: true } }); const names = (roles ?? []).map((r: any) => r.name); expect(names, 'showcase declares contributor/manager/exec').toEqual( expect.arrayContaining(['contributor', 'manager', 'exec']), @@ -50,7 +50,7 @@ describe('showcase: declarative RBAC seeding (ADR-0057 D6 / #2077)', () => { }); it('re-seed is idempotent (no duplicate rows on a second boot)', async () => { - const roles = await ql.find('sys_role', { where: { name: 'manager' }, context: { isSystem: true } }); + const roles = await ql.find('sys_position', { where: { name: 'manager' }, context: { isSystem: true } }); expect((roles ?? []).length, 'exactly one manager role').toBe(1); }); }); diff --git a/packages/dogfood/test/showcase-default-profile.dogfood.test.ts b/packages/dogfood/test/showcase-default-profile.dogfood.test.ts index c7f31cf15c..402e0b7a7d 100644 --- a/packages/dogfood/test/showcase-default-profile.dogfood.test.ts +++ b/packages/dogfood/test/showcase-default-profile.dogfood.test.ts @@ -19,7 +19,6 @@ import { PermissionSetSchema } from '@objectstack/spec/security'; const demoDefault = PermissionSetSchema.parse({ name: 'showcase_demo_default', label: 'Demo Default Profile', - isProfile: true, isDefault: true, // ← the D7 marker: this is the fallback for unprovisioned users objects: { showcase_announcement: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, diff --git a/packages/dogfood/test/showcase-invoice-cbp.dogfood.test.ts b/packages/dogfood/test/showcase-invoice-cbp.dogfood.test.ts index d4df578e79..38ce88fdee 100644 --- a/packages/dogfood/test/showcase-invoice-cbp.dogfood.test.ts +++ b/packages/dogfood/test/showcase-invoice-cbp.dogfood.test.ts @@ -25,7 +25,6 @@ const ADMIN_EMAIL = 'admin@objectos.ai'; const memberSet = PermissionSetSchema.parse({ name: 'showcase_cbp_member', label: 'Showcase CBP Member', - isProfile: true, objects: { showcase_account: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, showcase_product: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, diff --git a/packages/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts b/packages/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts index d381e53c50..057329e855 100644 --- a/packages/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts +++ b/packages/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts @@ -25,7 +25,6 @@ import { PermissionSetSchema } from '@objectstack/spec/security'; const demoSet = PermissionSetSchema.parse({ name: 'showcase_cbp_demo', label: 'Showcase CBP Demo', - isProfile: true, objects: { showcase_account: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, showcase_product: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, diff --git a/packages/dogfood/test/showcase-public-read-owd.dogfood.test.ts b/packages/dogfood/test/showcase-public-read-owd.dogfood.test.ts index a0f262e31c..25f050014b 100644 --- a/packages/dogfood/test/showcase-public-read-owd.dogfood.test.ts +++ b/packages/dogfood/test/showcase-public-read-owd.dogfood.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // // ADR-0056 — `read` (public-read) OWD proof on the REAL showcase app. -// `showcase_announcement` declares `sharingModel: 'read'` and nothing else: every +// `showcase_announcement` declares `sharingModel: 'public_read'` and nothing else: every // member READS every announcement, but only the OWNER may edit/delete it — derived // from the OWD baseline + auto-stamped `owner_id`, no RLS authored. This is the // sibling of the `private` proof: same owner-write protection, but rows are diff --git a/packages/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts b/packages/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts index 01ac6fd62e..ab5a1bce07 100644 --- a/packages/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts +++ b/packages/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts @@ -1,18 +1,18 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // // ADR-0056 D7 + ADR-0057 D1 — app-declared DEFAULT PROFILE honored via the CLI -// wiring (`appDefaultProfileName` → SecurityPlugin `fallbackPermissionSet`), +// wiring (`appDefaultPermissionSetName` → SecurityPlugin `fallbackPermissionSet`), // resolved BY NAME from metadata, carrying a hierarchy `readScope`. // // This is the companion to `showcase-scope-depth.dogfood.test.ts`. That test // injects the scope profile directly as a SecurityPlugin `defaultPermissionSet` // (an in-memory bootstrap set). THIS test exercises the path that // `objectstack dev`/`serve`/`start` actually take: the app declares the default -// profile in METADATA, the CLI computes its name with `appDefaultProfileName` +// profile in METADATA, the CLI computes its name with `appDefaultPermissionSetName` // and passes only that NAME as `fallbackPermissionSet`, and the SecurityPlugin // resolves the full set (incl. `readScope`) from `sys_permission_set` at request // time. The bug this guards: the artifact-serve path used to drop `permissions[]` -// from the stack config, so `appDefaultProfileName` saw nothing, the fallback +// from the stack config, so `appDefaultPermissionSetName` saw nothing, the fallback // silently degraded to the built-in owner-only `member_default`, and a grant-less // user never got the app's declared `readScope` widening. // @@ -21,7 +21,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; import { bootStack, type VerifyStack } from '@objectstack/verify'; -import { SecurityPlugin, appDefaultProfileName } from '@objectstack/plugin-security'; +import { SecurityPlugin, appDefaultPermissionSetName } from '@objectstack/plugin-security'; const OBJ = '/data/showcase_private_note'; const WHO = ['alice', 'bob', 'carol', 'dave'] as const; @@ -30,11 +30,10 @@ type Who = (typeof WHO)[number]; const PROFILE_NAME = 'scope_fallback_unit_and_below'; // The app-declared default profile, as it appears in stack `permissions[]` -// metadata. `isDefault: true` is what `appDefaultProfileName` keys off. +// metadata. `isDefault: true` is what `appDefaultPermissionSetName` keys off. const DEFAULT_PROFILE_METADATA = { name: PROFILE_NAME, label: 'Scope Fallback (unit_and_below)', - isProfile: true, isDefault: true, objects: { showcase_private_note: { @@ -57,7 +56,7 @@ async function bootFallbackWorld(withResolver = true): Promise { // declared `permissions[]`, handed to SecurityPlugin as the fallback. No // `defaultPermissionSets` carry the scope profile — it must resolve from DB. security: new SecurityPlugin({ - fallbackPermissionSet: appDefaultProfileName([DEFAULT_PROFILE_METADATA]), + fallbackPermissionSet: appDefaultPermissionSetName([DEFAULT_PROFILE_METADATA]), }), }); await stack.signIn(); @@ -134,11 +133,11 @@ async function titles(stack: VerifyStack, token: string): Promise { return (b.records ?? b.data ?? b ?? []).map((x: any) => x.title).filter(Boolean); } -describe('app-default-profile via fallbackPermissionSet (ADR-0056 D7 / ADR-0057 D1)', () => { - it('appDefaultProfileName picks the isDefault profile name (the CLI helper)', () => { - expect(appDefaultProfileName([DEFAULT_PROFILE_METADATA])).toBe(PROFILE_NAME); - // an add-on (isProfile:false) is never chosen as the default - expect(appDefaultProfileName([{ name: 'addon', isProfile: false, isDefault: true }])).toBeUndefined(); +describe('app-default-permission-set via fallbackPermissionSet (ADR-0056 D7 / ADR-0057 D1)', () => { + it('appDefaultPermissionSetName picks the isDefault profile name (the CLI helper)', () => { + expect(appDefaultPermissionSetName([DEFAULT_PROFILE_METADATA])).toBe(PROFILE_NAME); + // an add-on (isDefault absent) is never chosen as the default + expect(appDefaultPermissionSetName([{ name: 'addon', isDefault: true }])).toBeUndefined(); }); }); diff --git a/packages/dogfood/test/showcase-scope-depth.dogfood.test.ts b/packages/dogfood/test/showcase-scope-depth.dogfood.test.ts index 6cfb2bb88e..49797be652 100644 --- a/packages/dogfood/test/showcase-scope-depth.dogfood.test.ts +++ b/packages/dogfood/test/showcase-scope-depth.dogfood.test.ts @@ -27,7 +27,6 @@ function scopeProfile(scope: 'unit' | 'unit_and_below' | 'own_and_reports') { return PermissionSetSchema.parse({ name: `scope_${scope}_profile`, label: `Scope ${scope}`, - isProfile: true, isDefault: true, objects: { showcase_private_note: { diff --git a/packages/downstream-contract/src/additional-domains.fixtures.ts b/packages/downstream-contract/src/additional-domains.fixtures.ts index ae55841d6b..8389c7fcda 100644 --- a/packages/downstream-contract/src/additional-domains.fixtures.ts +++ b/packages/downstream-contract/src/additional-domains.fixtures.ts @@ -8,7 +8,7 @@ import type { DatasourceInput, MappingInput, CubeInput, ObjectExtensionInput } from '@objectstack/spec/data'; import type { ConnectorInput } from '@objectstack/spec/integration'; import type { SharingRuleInput, PermissionSetInput } from '@objectstack/spec/security'; -import type { RoleInput } from '@objectstack/spec/identity'; +import type { PositionInput } from '@objectstack/spec/identity'; import type { EmailTemplateDefinitionInput, TranslationBundleInput } from '@objectstack/spec/system'; import type { WebhookInput } from '@objectstack/spec/automation'; import type { ThemeInput } from '@objectstack/spec/ui'; @@ -54,7 +54,7 @@ export const DcSharingRule: SharingRuleInput = { active: true, }; -export const DcRole: RoleInput = { +export const DcRole: PositionInput = { name: 'dc_manager', label: 'DC Manager', description: 'Manager role.', @@ -63,7 +63,6 @@ export const DcRole: RoleInput = { export const DcPermissionSet: PermissionSetInput = { name: 'dc_user', label: 'DC User', - isProfile: false, objects: { dc_account: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, }, diff --git a/packages/downstream-contract/test/contract.test.ts b/packages/downstream-contract/test/contract.test.ts index f8b57dacec..7e89b1a255 100644 --- a/packages/downstream-contract/test/contract.test.ts +++ b/packages/downstream-contract/test/contract.test.ts @@ -5,7 +5,7 @@ import { ActionSchema, ReportSchema, PageSchema, ThemeSchema } from '@objectstac import { DatasourceSchema, MappingSchema, CubeSchema, ObjectExtensionSchema } from '@objectstack/spec/data'; import { ConnectorSchema } from '@objectstack/spec/integration'; import { SharingRuleSchema, PermissionSetSchema } from '@objectstack/spec/security'; -import { RoleSchema } from '@objectstack/spec/identity'; +import { PositionSchema } from '@objectstack/spec/identity'; import { EmailTemplateDefinitionSchema, TranslationBundleSchema } from '@objectstack/spec/system'; import { WebhookSchema } from '@objectstack/spec/automation'; import { LogCallAction } from '../src/log-call.action.js'; @@ -36,7 +36,7 @@ describe('downstream consumer contract (#2035)', () => { ['Datasource', DatasourceSchema, more.DcDatasource], ['Connector', ConnectorSchema, more.DcConnector], ['SharingRule', SharingRuleSchema, more.DcSharingRule], - ['Role', RoleSchema, more.DcRole], + ['Role', PositionSchema, more.DcRole], ['PermissionSet', PermissionSetSchema, more.DcPermissionSet], ['EmailTemplateDefinition', EmailTemplateDefinitionSchema, more.DcEmail], ['Webhook', WebhookSchema, more.DcWebhook], diff --git a/packages/formula/src/cel-engine.test.ts b/packages/formula/src/cel-engine.test.ts index 3cbdc4c60a..b7106b50b3 100644 --- a/packages/formula/src/cel-engine.test.ts +++ b/packages/formula/src/cel-engine.test.ts @@ -20,8 +20,8 @@ describe('celEngine', () => { }); it('exposes os.* namespace from EvalContext', () => { - const r = celEngine.evaluate(cel('os.user.role == "manager"'), { - user: { id: 'u1', role: 'manager' }, + const r = celEngine.evaluate(cel('"manager" in os.user.positions'), { + user: { id: 'u1', positions: ['manager'] }, }); expect(r).toEqual({ ok: true, value: true }); }); diff --git a/packages/formula/src/stdlib.ts b/packages/formula/src/stdlib.ts index 23e93c3be4..bfc242f56f 100644 --- a/packages/formula/src/stdlib.ts +++ b/packages/formula/src/stdlib.ts @@ -269,29 +269,21 @@ export function registerNumericCoercions(env: Environment): Environment { /** * Normalize the loosely-typed EvalContext user into the canonical EvalUser - * (ADR-0068). `roles` is preferred; a legacy singular `role` is folded in so - * existing call sites keep working. + * (ADR-0068, renamed by ADR-0090 D3): `positions` is the one canonical + * membership array. One-step rename — no legacy `roles`/`role` aliases parse. */ function toEvalUser(u: NonNullable): EvalUser { - const legacyRole = typeof u.role === 'string' && u.role ? [u.role] : []; - const roles = Array.isArray(u.roles) ? (u.roles as string[]) : []; + const positions = Array.isArray(u.positions) ? (u.positions as string[]) : []; const canonical = createEvalUser({ id: u.id, name: typeof u.name === 'string' ? u.name : undefined, email: typeof u.email === 'string' ? u.email : undefined, - roles: [...roles, ...legacyRole], + positions, organizationId: typeof u.organizationId === 'string' || u.organizationId === null ? (u.organizationId as string | null) : undefined, }); - // Back-compat: keep the DEPRECATED singular `role` readable so existing - // predicates (`os.user.role`, `current_user.role`) keep resolving during the - // ADR-0068 migration window. `roles[]` is the canonical surface; the footgun - // ADR-0068 removes is the server-side OVERWRITE of `role`, not read access. - if (typeof u.role === 'string' && u.role) { - (canonical as EvalUser & { role?: string }).role = u.role; - } return canonical; } diff --git a/packages/formula/src/types.ts b/packages/formula/src/types.ts index 70a2569829..11f7b15442 100644 --- a/packages/formula/src/types.ts +++ b/packages/formula/src/types.ts @@ -35,18 +35,16 @@ export interface EvalContext { * * ADR-0068: the canonical user contract is {@link EvalUser} from * `@objectstack/spec`, surfaced to predicates as `current_user` (aliases - * `user`, `ctx.user`). `roles: string[]` is the only canonical role field; - * the singular `role` is deprecated (its "overwritten to 'admin' on + * `user`, `ctx.user`). `positions: string[]` is the only canonical membership field; + * (the legacy singular's "overwritten to 'admin' on * promotion" behavior is the footgun ADR-0068 eliminates). */ user?: { id: string; - /** CANONICAL (ADR-0068). Scope-resolved role names assigned to the user. */ - roles?: string[]; + /** CANONICAL (ADR-0068, renamed ADR-0090 D3). Scope-resolved position names. */ + positions?: string[]; /** Active organization ID (null = platform / unscoped). */ organizationId?: string | null; - /** @deprecated ADR-0068 — use {@link roles}. Retained for back-compat only. */ - role?: string; email?: string; [key: string]: unknown; }; diff --git a/packages/formula/src/validate.ts b/packages/formula/src/validate.ts index fcdcbd2d92..bd3fa6157a 100644 --- a/packages/formula/src/validate.ts +++ b/packages/formula/src/validate.ts @@ -56,7 +56,7 @@ export interface ExprSchemaHint { /** * ADR-0068 D4 — the closed catalog of valid role names (built-in + declared). * When supplied, a role-membership predicate testing a role NOT in this set - * (e.g. `'org_admni' in current_user.roles`) is flagged as an error. Closes + * (e.g. `'org_admni' in current_user.positions`) is flagged as an error. Closes * the AI-hallucination hole where a model invents a plausible-but-nonexistent * role that then silently never matches. Absent => role checks are skipped. */ @@ -154,18 +154,18 @@ function levenshtein(a: string, b: string): number { return dp[m]; } -// ADR-0068 D4 — role-membership predicate heads: a role NAME literal used in a -// membership test against a user subject's `.roles` (or the deprecated singular -// `.role`). Matched names are validated against the closed role catalog. -const ROLE_IN_RE = /(['"])([a-z0-9_]+)\1\s+in\s+(?:current_user|user|ctx\.user)\.roles\b/g; -const ROLE_CONTAINS_RE = /(?:current_user|user|ctx\.user)\.roles\s*\.\s*contains\(\s*(['"])([a-z0-9_]+)\1\s*\)/g; +// ADR-0068 D4 — position-membership predicate heads: a position NAME literal +// used in a membership test against a user subject's `.positions` +// (ADR-0090 D3 rename). Matched names are validated against the closed catalog. +const ROLE_IN_RE = /(['"])([a-z0-9_]+)\1\s+in\s+(?:current_user|user|ctx\.user)\.positions\b/g; +const ROLE_CONTAINS_RE = /(?:current_user|user|ctx\.user)\.positions\s*\.\s*contains\(\s*(['"])([a-z0-9_]+)\1\s*\)/g; // Bounded quantifiers ({0,N}, not * / *?) keep this linear: a CEL `exists` // body is tiny in practice, and unbounded greedy/lazy scanners here backtrack -// polynomially (O(n^2)) on adversarial input like repeated `user.roles.exists(` +// polynomially (O(n^2)) on adversarial input like repeated `user.positions.exists(` // (ADR-0068 D4 ReDoS hardening). The pre-`==` class excludes `=` so the bounded // run stops cleanly before the operator without a lazy quantifier. -const ROLE_EXISTS_RE = /(?:current_user|user|ctx\.user)\.roles\s*\.\s*exists\s*\([^,)]{0,64},[^)=]{0,128}==\s*(['"])([a-z0-9_]+)\1/g; -const ROLE_EQ_RE = /(?:current_user|user|ctx\.user)\.role\s*==\s*(['"])([a-z0-9_]+)\1/g; +const ROLE_EXISTS_RE = /(?:current_user|user|ctx\.user)\.positions\s*\.\s*exists\s*\([^,)]{0,64},[^)=]{0,128}==\s*(['"])([a-z0-9_]+)\1/g; +const ROLE_EQ_RE = /(?:current_user|user|ctx\.user)\.position\s*==\s*(['"])([a-z0-9_]+)\1/g; /** * Flag role-membership predicates referencing a role outside the closed catalog diff --git a/packages/objectql/CHANGELOG.md b/packages/objectql/CHANGELOG.md index a74059542e..5e59bb2143 100644 --- a/packages/objectql/CHANGELOG.md +++ b/packages/objectql/CHANGELOG.md @@ -654,8 +654,8 @@ ADR-0057 — ERP authorization core. Adds permission-grant access DEPTH (`own`/`own_and_reports`/`unit`/`unit_and_below`/`org`), renames `sys_department` → `sys_business_unit` (no aliases — see BREAKING above), introduces the platform-owned - `sys_user_role` assignment, and seeds stack-declared `roles`/`sharingRules` into - `sys_role`/`sys_sharing_rule` at boot (closes #2077). Hierarchy-relative scopes are + `sys_user_position` assignment, and seeds stack-declared `roles`/`sharingRules` into + `sys_position`/`sys_sharing_rule` at boot (closes #2077). Hierarchy-relative scopes are delegated to a pluggable `IHierarchyScopeResolver` (open edition fails closed to owner-only; `defineStack` errors without `requires: ['hierarchy-security']`). Also fixes a latent over-grant where `engine.find({ filter })` was ignored (driver reads diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 1c721e4c02..aeea424525 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -111,7 +111,7 @@ function applyFormulaPlan( if (!plan.length) return; const now = nowSnapshot ?? new Date(); const timezone = execCtx?.timezone; - const user = execCtx?.userId ? { id: String(execCtx.userId), role: execCtx?.roles?.[0] } : undefined; + const user = execCtx?.userId ? { id: String(execCtx.userId), positions: execCtx?.positions ?? [] } : undefined; const org = execCtx?.tenantId ? { id: String(execCtx.tenantId) } : undefined; for (const rec of records) { if (rec == null) continue; @@ -685,7 +685,7 @@ export class ObjectQL implements IDataEngine { return { userId: execCtx.userId, tenantId: execCtx.tenantId, - roles: execCtx.roles, + positions: execCtx.positions, accessToken: execCtx.accessToken, // Propagate system-elevated flag so hooks can distinguish engine // self-writes (e.g. approval status mirror) from genuine user writes. @@ -701,15 +701,15 @@ export class ObjectQL implements IDataEngine { * Build the acting-user object (ADR-0068 EvalUser shape) surfaced to * validation-time predicates as `current_user` — notably per-option * `visibleWhen` authorization gating (objectui#2284). Returns undefined for - * system / unauthenticated writes, where role predicates then fail-open. + * system / unauthenticated writes, where membership predicates then fail-open. */ private buildEvalUser( execCtx?: ExecutionContext, - ): { id: string; roles: string[]; organizationId: string | null } | undefined { + ): { id: string; positions: string[]; organizationId: string | null } | undefined { if (!execCtx || execCtx.userId == null) return undefined; return { id: String(execCtx.userId), - roles: execCtx.roles ?? [], + positions: execCtx.positions ?? [], organizationId: execCtx.tenantId != null ? String(execCtx.tenantId) : null, }; } @@ -806,7 +806,7 @@ export class ObjectQL implements IDataEngine { const result = ExpressionEngine.evaluate(dv as any, { now, timezone: execCtx?.timezone, - user: execCtx?.userId ? { id: String(execCtx.userId), role: execCtx?.roles?.[0] } : undefined, + user: execCtx?.userId ? { id: String(execCtx.userId), positions: execCtx?.positions ?? [] } : undefined, org: execCtx?.tenantId ? { id: String(execCtx.tenantId) } : undefined, record: out, extra: { object }, @@ -3169,7 +3169,7 @@ export class ObjectRepository { ...params, userId: this.context.userId, tenantId: this.context.tenantId, - roles: this.context.roles, + roles: this.context.positions, }); } throw new Error(`Actions not supported by engine`); @@ -3312,7 +3312,7 @@ export class ScopedContext { get tenantId() { return this.executionContext.tenantId; } /** Alias for tenantId — matches ObjectQLContext.spaceId convention */ get spaceId() { return this.executionContext.tenantId; } - get roles() { return this.executionContext.roles; } + get roles() { return this.executionContext.positions; } get isSystem() { return this.executionContext.isSystem; } /** Internal: expose the transaction handle for driver-level access */ diff --git a/packages/objectql/src/metadata-validation-sweep.test.ts b/packages/objectql/src/metadata-validation-sweep.test.ts index e3ad7d64e3..5c9170d5a7 100644 --- a/packages/objectql/src/metadata-validation-sweep.test.ts +++ b/packages/objectql/src/metadata-validation-sweep.test.ts @@ -206,8 +206,8 @@ const FIXTURES: Record = { invalidatedField: 'name', }, profile: { - valid: { name: 'sweep_profile', label: 'Sweep', isProfile: true, objects: {} }, - invalid: { label: 'No name', isProfile: true, objects: {} }, + valid: { name: 'sweep_profile', label: 'Sweep', objects: {} }, + invalid: { label: 'No name', objects: {} }, invalidatedField: 'name', }, role: { diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 66f9575878..a6f4fa5123 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -651,7 +651,7 @@ export class ObjectQLPlugin implements Plugin { const existing = await this.ql!.findOne(hookCtx.object, { where: { id: hookCtx.input.id }, context: { - roles: [], + positions: [], permissions: [], isSystem: true, ...(hookCtx.transaction ? { transaction: hookCtx.transaction } : {}), @@ -676,7 +676,7 @@ export class ObjectQLPlugin implements Plugin { const existing = await this.ql!.findOne(hookCtx.object, { where: { id: hookCtx.input.id }, context: { - roles: [], + positions: [], permissions: [], isSystem: true, ...(hookCtx.transaction ? { transaction: hookCtx.transaction } : {}), diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index 4360af3aa1..5bc940cab0 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -277,7 +277,7 @@ function toExpression(cond: string | Expression): Expression { * UX, not a security boundary — a caller can still submit a hidden value — so on * write we re-evaluate the predicate for the *picked* value(s) and reject any * that resolve cleanly to FALSE. This enforces both role/context gating - * (`'admin' in current_user.roles`) and cascade integrity (`record.country == + * (`'admin' in current_user.positions`) and cascade integrity (`record.country == * 'cn'`), server-side. * * Only WRITTEN fields are checked — an unchanged persisted value is left alone. diff --git a/packages/platform-objects/CHANGELOG.md b/packages/platform-objects/CHANGELOG.md index 19c3dfc044..74ee3aa443 100644 --- a/packages/platform-objects/CHANGELOG.md +++ b/packages/platform-objects/CHANGELOG.md @@ -498,8 +498,8 @@ ADR-0057 — ERP authorization core. Adds permission-grant access DEPTH (`own`/`own_and_reports`/`unit`/`unit_and_below`/`org`), renames `sys_department` → `sys_business_unit` (no aliases — see BREAKING above), introduces the platform-owned - `sys_user_role` assignment, and seeds stack-declared `roles`/`sharingRules` into - `sys_role`/`sys_sharing_rule` at boot (closes #2077). Hierarchy-relative scopes are + `sys_user_position` assignment, and seeds stack-declared `roles`/`sharingRules` into + `sys_position`/`sys_sharing_rule` at boot (closes #2077). Hierarchy-relative scopes are delegated to a pluggable `IHierarchyScopeResolver` (open edition fails closed to owner-only; `defineStack` errors without `requires: ['hierarchy-security']`). Also fixes a latent over-grant where `engine.find({ filter })` was ignored (driver reads @@ -956,8 +956,8 @@ priority, items }`) and an optional `navigationContributions` field on the into the two capability plugins that already register and operate them, split by concern (the two are orthogonal — sharing objects never reference RBAC objects): - - **`@objectstack/plugin-security`** (RBAC) gains `sys_role`, - `sys_permission_set`, `sys_user_permission_set`, `sys_role_permission_set`, + - **`@objectstack/plugin-security`** (RBAC) gains `sys_position`, + `sys_permission_set`, `sys_user_permission_set`, `sys_position_permission_set`, and the `defaultPermissionSets` seed (which its `bootstrap-platform-admin` already consumes). The RBAC + default-permission-set tests move with them. - **`@objectstack/plugin-sharing`** gains `sys_record_share`, @@ -1120,8 +1120,8 @@ channel)`. The inbox channel writes a `delivered` receipt on materialization. `i18n.loadTranslations` (the i18n service is optional — load is best-effort). - `plugin-webhooks` ← `sys_webhook`, `sys_webhook_delivery` - `plugin-approvals` ← `sys_approval_request`, `sys_approval_action` - - `plugin-security` ← `sys_role`, `sys_permission_set`, - `sys_user_permission_set`, `sys_role_permission_set` + - `plugin-security` ← `sys_position`, `sys_permission_set`, + `sys_user_permission_set`, `sys_position_permission_set` - `plugin-sharing` ← `sys_record_share`, `sys_sharing_rule`, `sys_share_link` - `@objectstack/platform-objects` translation bundles are regenerated to drop those objects' keys (its extract config already excluded them); all other diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index a91500cf50..b5d251d7b9 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -1312,10 +1312,6 @@ export const enMetadataForms: NonNullable = { label: "Label", helpText: "Display label for admins" }, - isProfile: { - label: "Is Profile", - helpText: "Profile = base set assigned to users. Permission Set = additive grant." - }, systemPermissions: { label: "System Permissions", helpText: "List of system capability keys" @@ -1371,10 +1367,6 @@ export const enMetadataForms: NonNullable = { label: "Label", helpText: "Display label for admins" }, - isProfile: { - label: "Is Profile", - helpText: "Profile = base set assigned to users. Permission Set = additive grant." - }, systemPermissions: { label: "System Permissions", helpText: "List of system capability keys" diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 14b234eea2..3b2de070a7 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -1312,10 +1312,6 @@ export const esESMetadataForms: NonNullable = label: "Etiqueta", helpText: "Etiqueta mostrada para administradores" }, - isProfile: { - label: "Es perfil", - helpText: "Profile = conjunto base asignado a usuarios. Permission Set = concesión adicional." - }, systemPermissions: { label: "Permisos del sistema", helpText: "Lista de claves de capacidades del sistema" @@ -1371,10 +1367,6 @@ export const esESMetadataForms: NonNullable = label: "Etiqueta", helpText: "Etiqueta mostrada para administradores" }, - isProfile: { - label: "Es perfil", - helpText: "Profile = conjunto base asignado a usuarios. Permission Set = concesión adicional." - }, systemPermissions: { label: "Permisos del sistema", helpText: "Lista de claves de capacidades del sistema" diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index e1af08f661..5b83f552cd 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -1312,10 +1312,6 @@ export const jaJPMetadataForms: NonNullable = label: "表示名", helpText: "管理者向け表示ラベル" }, - isProfile: { - label: "プロファイルか", - helpText: "Profile = ユーザーに割り当てる基本セット。Permission Set = 追加権限。" - }, systemPermissions: { label: "システム権限", helpText: "システム機能キーのリスト" @@ -1371,10 +1367,6 @@ export const jaJPMetadataForms: NonNullable = label: "表示名", helpText: "管理者向け表示ラベル" }, - isProfile: { - label: "プロファイルか", - helpText: "Profile = ユーザーに割り当てる基本セット。Permission Set = 追加権限。" - }, systemPermissions: { label: "システム権限", helpText: "システム機能キーのリスト" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 81d91eb4e2..3c934db4cb 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -1312,10 +1312,6 @@ export const zhCNMetadataForms: NonNullable = label: "显示名称", helpText: "面向管理员的显示标签" }, - isProfile: { - label: "是否配置文件", - helpText: "勾选后作为完整配置文件(而非附加权限集)" - }, systemPermissions: { label: "系统权限", helpText: "应用访问、API、管理操作" @@ -1371,10 +1367,6 @@ export const zhCNMetadataForms: NonNullable = label: "显示名称", helpText: "面向管理员显示的标签" }, - isProfile: { - label: "是否配置文件", - helpText: "配置文件 = 分配给用户的基础集合。权限集 = 附加授予。" - }, systemPermissions: { label: "系统权限", helpText: "系统能力键列表" diff --git a/packages/platform-objects/src/pages/sys-user.page.ts b/packages/platform-objects/src/pages/sys-user.page.ts index 77ae86f128..c97d7920a6 100644 --- a/packages/platform-objects/src/pages/sys-user.page.ts +++ b/packages/platform-objects/src/pages/sys-user.page.ts @@ -33,7 +33,7 @@ import type { Page } from '@objectstack/spec/ui'; * on a user profile (Sessions / Linked Accounts / Organizations / * Personal OAuth Apps). Without this override, the synthesizer * auto-generates a tab per object that has a FK to sys_user - * (sys_role.created_by, sys_email.updated_by, sys_user_preference, + * (sys_position.created_by, sys_email.updated_by, sys_user_preference, * sys_email_template.created_by, …) producing dozens of noisy * "查看全部" cards on every profile. * - `discussion: []` removes the Chatter feed — it has no business @@ -112,7 +112,7 @@ export const SysUserDetailPage: Page = { // ── Tabs: curated related lists ─────────────────────────────── // Only the 4 lists that are semantically about THIS user account. - // Everything else (sys_role created_by, sys_email_template + // Everything else (sys_position created_by, sys_email_template // updated_by, …) is incidental authorship metadata and would only // create noise. tabs: { diff --git a/packages/plugins/plugin-approvals/CHANGELOG.md b/packages/plugins/plugin-approvals/CHANGELOG.md index 4a53241c23..eea7cc8499 100644 --- a/packages/plugins/plugin-approvals/CHANGELOG.md +++ b/packages/plugins/plugin-approvals/CHANGELOG.md @@ -355,8 +355,8 @@ ADR-0057 — ERP authorization core. Adds permission-grant access DEPTH (`own`/`own_and_reports`/`unit`/`unit_and_below`/`org`), renames `sys_department` → `sys_business_unit` (no aliases — see BREAKING above), introduces the platform-owned - `sys_user_role` assignment, and seeds stack-declared `roles`/`sharingRules` into - `sys_role`/`sys_sharing_rule` at boot (closes #2077). Hierarchy-relative scopes are + `sys_user_position` assignment, and seeds stack-declared `roles`/`sharingRules` into + `sys_position`/`sys_sharing_rule` at boot (closes #2077). Hierarchy-relative scopes are delegated to a pluggable `IHierarchyScopeResolver` (open edition fails closed to owner-only; `defineStack` errors without `requires: ['hierarchy-security']`). Also fixes a latent over-grant where `engine.find({ filter })` was ignored (driver reads @@ -765,8 +765,8 @@ `i18n.loadTranslations` (the i18n service is optional — load is best-effort). - `plugin-webhooks` ← `sys_webhook`, `sys_webhook_delivery` - `plugin-approvals` ← `sys_approval_request`, `sys_approval_action` - - `plugin-security` ← `sys_role`, `sys_permission_set`, - `sys_user_permission_set`, `sys_role_permission_set` + - `plugin-security` ← `sys_position`, `sys_permission_set`, + `sys_user_permission_set`, `sys_position_permission_set` - `plugin-sharing` ← `sys_record_share`, `sys_sharing_rule`, `sys_share_link` - `@objectstack/platform-objects` translation bundles are regenerated to drop those objects' keys (its extract config already excluded them); all other diff --git a/packages/plugins/plugin-approvals/src/approval-node.test.ts b/packages/plugins/plugin-approvals/src/approval-node.test.ts index 8f482ad567..0c4d0dd968 100644 --- a/packages/plugins/plugin-approvals/src/approval-node.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-node.test.ts @@ -5,7 +5,7 @@ import { AutomationEngine } from '@objectstack/service-automation'; import { ApprovalService } from './approval-service.js'; import { registerApprovalNode } from './approval-node.js'; -const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as any; +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as any; const noopLogger = { info() {}, warn() {}, error() {}, debug() {}, @@ -190,7 +190,7 @@ describe('Approval node bridge (ADR-0019)', () => { const request = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0]; await expect( - service.decideNode(request.id, { decision: 'approve', actorId: 'intruder' }, { isSystem: false, roles: [], permissions: [] } as any), + service.decideNode(request.id, { decision: 'approve', actorId: 'intruder' }, { isSystem: false, positions: [], permissions: [] } as any), ).rejects.toThrow(/FORBIDDEN/); }); }); diff --git a/packages/plugins/plugin-approvals/src/approval-node.ts b/packages/plugins/plugin-approvals/src/approval-node.ts index c57e1c94c0..2f1bc87773 100644 --- a/packages/plugins/plugin-approvals/src/approval-node.ts +++ b/packages/plugins/plugin-approvals/src/approval-node.ts @@ -48,7 +48,7 @@ interface MinimalLogger { warn?: (msg: any, ...rest: any[]) => void; } -const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; /** * Register the `approval` node executor on the automation engine. Idempotent at diff --git a/packages/plugins/plugin-approvals/src/approval-revise.test.ts b/packages/plugins/plugin-approvals/src/approval-revise.test.ts index a25cead420..6c56b9ea83 100644 --- a/packages/plugins/plugin-approvals/src/approval-revise.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-revise.test.ts @@ -19,8 +19,8 @@ import { ApprovalService } from './approval-service.js'; import { registerApprovalNode } from './approval-node.js'; import { bindApprovalLockHook, APPROVALS_HOOK_PACKAGE } from './lifecycle-hooks.js'; -const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as any; -const USER_CTX = { isSystem: false, roles: [], permissions: [] } as any; +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as any; +const USER_CTX = { isSystem: false, positions: [], permissions: [] } as any; const noopLogger = { info() {}, warn() {}, error() {}, debug() {} }; @@ -288,7 +288,7 @@ describe('Send back for revision (ADR-0044)', () => { const editAttempt = () => hook!({ object: 'fin_expense', input: { id: 'x1', data: { amount: 1200 } }, - session: { isSystem: false, roles: [] }, + session: { isSystem: false, positions: [] }, }); await expect(editAttempt()).rejects.toThrow(/RECORD_LOCKED/); // pending → locked diff --git a/packages/plugins/plugin-approvals/src/approval-service.test.ts b/packages/plugins/plugin-approvals/src/approval-service.test.ts index 48371c9427..3fe3a26dea 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.test.ts @@ -110,8 +110,8 @@ function makeFakeEngine() { }; } -const CTX = { userId: 'u1', tenantId: 't1', roles: [], permissions: [] } as any; -const SYS = { isSystem: true, roles: [], permissions: [] } as any; +const CTX = { userId: 'u1', tenantId: 't1', positions: [], permissions: [] } as any; +const SYS = { isSystem: true, positions: [], permissions: [] } as any; function nodeConfig(approvers: string[], extra: Record = {}) { return { @@ -219,7 +219,7 @@ describe('ApprovalService (node era)', () => { it('decideNode: blocks a non-approver in a non-system context', async () => { const req = await svc.openNodeRequest(openInput(['u9']), CTX); await expect( - svc.decideNode(req.id, { decision: 'approve', actorId: 'mallory' }, { isSystem: false, roles: [], permissions: [] } as any), + svc.decideNode(req.id, { decision: 'approve', actorId: 'mallory' }, { isSystem: false, positions: [], permissions: [] } as any), ).rejects.toThrow(/FORBIDDEN/); }); @@ -319,7 +319,7 @@ describe('ApprovalService (node era)', () => { it('recall: blocks a non-submitter in a non-system context', async () => { const req = await svc.openNodeRequest(openInput(['u9']), CTX); - await expect(svc.recall(req.id, { actorId: 'u9' }, { roles: [], permissions: [] } as any)) + await expect(svc.recall(req.id, { actorId: 'u9' }, { positions: [], permissions: [] } as any)) .rejects.toThrow(/FORBIDDEN/); }); @@ -483,7 +483,7 @@ describe('ApprovalService (node era)', () => { it('remind: only the submitter may nudge', async () => { const req = await svc.openNodeRequest(openInput(['u9']), CTX); - await expect(svc.remind(req.id, { actorId: 'u9' }, { roles: [], permissions: [] } as any)) + await expect(svc.remind(req.id, { actorId: 'u9' }, { positions: [], permissions: [] } as any)) .rejects.toThrow(/FORBIDDEN/); }); @@ -503,7 +503,7 @@ describe('ApprovalService (node era)', () => { const req = await svc.openNodeRequest(openInput(['u9']), CTX); await svc.comment(req.id, { actorId: 'u1', comment: 'Numbers attached.' }, CTX); await svc.comment(req.id, { actorId: 'u9', comment: 'Thanks, reviewing.' }, CTX); - await expect(svc.comment(req.id, { actorId: 'outsider', comment: 'hi' }, { roles: [], permissions: [] } as any)) + await expect(svc.comment(req.id, { actorId: 'outsider', comment: 'hi' }, { positions: [], permissions: [] } as any)) .rejects.toThrow(/FORBIDDEN/); const actions = await svc.listActions(req.id, SYS); expect(actions.filter(a => a.action === 'comment')).toHaveLength(2); @@ -882,7 +882,7 @@ describe('record-lock hook (node era)', () => { engine.fire('beforeUpdate', { object: 'opportunity', input: { id: 'opp1', data: { amount: 200 } }, - session: { isSystem: false, roles: [], userId: 'u1' }, + session: { isSystem: false, positions: [], userId: 'u1' }, }), ).rejects.toThrow(/RECORD_LOCKED/); }); @@ -892,7 +892,7 @@ describe('record-lock hook (node era)', () => { engine.fire('beforeUpdate', { object: 'opportunity', input: { id: 'opp1', data: { approval_status: 'approved' } }, - session: { isSystem: false, roles: [] }, + session: { isSystem: false, positions: [] }, }), ).resolves.toBeUndefined(); }); @@ -902,7 +902,7 @@ describe('record-lock hook (node era)', () => { engine.fire('beforeUpdate', { object: 'opportunity', input: { id: 'opp1', data: { amount: 200 } }, - session: { isSystem: true, roles: [] }, + session: { isSystem: true, positions: [] }, }), ).resolves.toBeUndefined(); }); @@ -922,7 +922,7 @@ describe('record-lock hook (node era)', () => { engine.fire('beforeUpdate', { object: 'opportunity', input: { id: 'other_record', data: { amount: 200 } }, - session: { isSystem: false, roles: [] }, + session: { isSystem: false, positions: [] }, }), ).resolves.toBeUndefined(); }); diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index ee18d72d25..6db5238c4c 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -97,7 +97,7 @@ export type ActionTokenOutcome = | { ok: true; action: 'approve' | 'reject'; request: ApprovalRequestRow; approverId: string } | { ok: false; reason: 'invalid' | 'expired' | 'consumed' | 'not_pending' | 'not_approver'; request?: ApprovalRequestRow }; -const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; function uid(prefix: string): string { const g: any = globalThis as any; diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index f1b08865ba..0692de7be2 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -13,7 +13,7 @@ import type { import type { IDataEngine } from '@objectstack/core'; import type { IEmailService } from '@objectstack/spec/contracts'; import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveOrgLimit } from '@objectstack/types'; -import { mapMembershipRole, BUILTIN_ROLE_PLATFORM_ADMIN } from '@objectstack/spec'; +import { mapMembershipRole, BUILTIN_IDENTITY_PLATFORM_ADMIN } from '@objectstack/spec'; import { createObjectQLAdapterFactory, withSystemReadContext } from './objectql-adapter.js'; import { AUTH_USER_CONFIG, @@ -230,7 +230,7 @@ export interface AuthManagerOptions extends Partial { * admin capabilities. * * Typical source: the union of `permission` metadata names that have - * `isProfile: true`, collected from the loaded stack at CLI boot. + * declared names, collected from the loaded stack at CLI boot. * * @example ['sales_rep', 'sales_manager', 'service_agent'] */ @@ -1582,13 +1582,13 @@ export class AuthManager { // split on commas, PLUS the active membership mapped to canonical // `org_owner`/`org_admin`/`org_member`, PLUS `platform_admin` when the user // holds the admin_full_access permission set. `user.isPlatformAdmin` is a - // derived alias of `'platform_admin' in roles`. + // derived alias of `'platform_admin' in positions`. // // IMPORTANT: `user.role` is NOT overwritten anymore — consumers must gate - // on `roles[]` / `isPlatformAdmin` (e.g. via objectui's useIsWorkspaceAdmin), + // on `positions[]` / `isPlatformAdmin` (e.g. via objectui's useIsWorkspaceAdmin), // never on `user.role === 'admin'`. Consumers that match individual role // names (e.g. the Console approvals inbox resolving `role:` approvers) - // also read `roles` — business roles such as `manager` survive only there. + // also read `positions` — business names such as `manager` survive only there. // The raw membership role stays on the organization plugin's `member` payload. // // Better-auth's `sys_user` table doesn't carry a `role` column. We derive @@ -1603,7 +1603,7 @@ export class AuthManager { // metadata such as saved list views, dashboards, etc. // // ADR-0068 D2: rather than synthesizing `user.role = 'admin'`, both paths now - // contribute CANONICAL role names to `user.roles` (platform_admin / org_*), + // contribute CANONICAL names to `user.positions` (platform_admin / org_*), // and `user.isPlatformAdmin` is a derived alias. The raw membership role // remains available via the `organization` plugin's `member` payload. const dataEngine = this.config.dataEngine; @@ -1659,16 +1659,17 @@ export class AuthManager { } }; - // ADR-0068 D1/D2 — emit ONE canonical roles[] (identities-as-roles), with - // NO `role:'admin'` overwrite. isPlatformAdmin is a DERIVED alias of - // `'platform_admin' in roles`, retained for back-compat clients. + // ADR-0068 D1/D2 (renamed ADR-0090 D3) — emit ONE canonical + // positions[] (identity names + position names), with NO singular + // overwrite. isPlatformAdmin is a DERIVED alias of + // `'platform_admin' in positions`, retained for back-compat clients. const platformAdmin = await isPlatformAdmin(); const orgRoles = await activeOrgRoles(); const storedRole = typeof (user as any).role === 'string' ? (user as any).role : ''; - const roles = Array.from(new Set([ + const positions = Array.from(new Set([ ...storedRole.split(',').map((s: string) => s.trim()).filter(Boolean), ...orgRoles, - ...(platformAdmin ? [BUILTIN_ROLE_PLATFORM_ADMIN] : []), + ...(platformAdmin ? [BUILTIN_IDENTITY_PLATFORM_ADMIN] : []), ])); // ADR-0069 — authentication-policy gate posture (password expiry, @@ -1686,7 +1687,7 @@ export class AuthManager { ); return { - user: { ...user, roles, isPlatformAdmin: platformAdmin, ...(authGate ? { authGate } : {}) }, + user: { ...user, positions, isPlatformAdmin: platformAdmin, ...(authGate ? { authGate } : {}) }, session, }; })); @@ -2364,7 +2365,7 @@ export class AuthManager { if (!userId || !providerId) return; const engine = this.getDataEngine(); if (!engine) return; - const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] }; + const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; if (providerId === 'credential') { // Gained a local password → env-native. Only write if currently @@ -2446,7 +2447,7 @@ export class AuthManager { this._orgMfaRefreshing = true; void (async () => { try { - const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] }; + const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; const n = await engine.count('sys_organization', { where: { require_mfa: true }, context: SYSTEM_CTX, } as any); @@ -2479,7 +2480,7 @@ export class AuthManager { const engine = this.getDataEngine(); if (!engine || !userId) return undefined; try { - const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] }; + const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; const u = await engine.findOne('sys_user', { where: { id: userId }, fields: ['password_changed_at', 'two_factor_enabled', 'mfa_required_at'], @@ -2545,7 +2546,7 @@ export class AuthManager { const engine = this.getDataEngine(); if (!engine || !userId) return; try { - const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] }; + const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; await engine.update( 'sys_user', { id: userId, password_changed_at: new Date() }, @@ -2625,7 +2626,7 @@ export class AuthManager { if (count <= 0 || typeof verify !== 'function') return undefined; const engine = this.getDataEngine(); if (!engine) return undefined; - const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] }; + const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; let account: any; try { account = await engine.findOne('sys_account', { @@ -2663,7 +2664,7 @@ export class AuthManager { const engine = this.getDataEngine(); if (!engine) return; try { - const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] }; + const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; const account = await engine.findOne('sys_account', { where: { user_id: userId, provider_id: 'credential' }, fields: ['id', 'previous_password_hashes'], @@ -2695,7 +2696,7 @@ export class AuthManager { if (!engine) return; let locked = false; try { - const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] }; + const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; const u = await engine.findOne('sys_user', { where: { email }, fields: ['id', 'locked_until'], context: SYSTEM_CTX, } as any); @@ -2729,7 +2730,7 @@ export class AuthManager { const engine = this.getDataEngine(); if (!engine) return; try { - const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] }; + const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; const u = await engine.findOne('sys_user', { where: { email }, fields: ['id', 'failed_login_count', 'locked_until'], @@ -2768,7 +2769,7 @@ export class AuthManager { const engine = this.getDataEngine(); if (!engine || !userId) return; try { - const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] }; + const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; const patch: Record = { id: userId, last_login_at: new Date() }; // Cap to the column width (IPv6 textual max 45) — a malformed/oversized // forwarded header must not blow up the write. @@ -2787,7 +2788,7 @@ export class AuthManager { public async unlockUser(userId: string): Promise { const engine = this.getDataEngine(); if (!engine || !userId) return false; - const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] }; + const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; const u = await engine.findOne('sys_user', { where: { id: userId }, fields: ['id'], context: SYSTEM_CTX, } as any); @@ -2814,7 +2815,7 @@ export class AuthManager { const engine = this.getDataEngine(); if (!engine || !sessionId) return; try { - const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] }; + const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; const srow = await engine.findOne('sys_session', { where: { id: sessionId }, fields: ['id', 'created_at', 'last_activity_at', 'revoked_at'], @@ -2861,7 +2862,7 @@ export class AuthManager { const engine = this.getDataEngine(); if (!engine) return; try { - const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] }; + const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; const rows = await engine.find('sys_session', { where: { user_id: userId }, fields: ['id', 'created_at', 'expires_at', 'revoked_at'], diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 710e1fcc51..490f9d4b2c 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -979,7 +979,7 @@ export class AuthPlugin implements Plugin { const u: any = session.user; const isAdmin = u?.isPlatformAdmin === true || - (Array.isArray(u?.roles) && u.roles.includes('platform_admin')) || + (Array.isArray(u?.positions) && u.positions.includes('platform_admin')) || u?.role === 'admin'; if (!isAdmin) { return c.json({ success: false, error: { code: 'forbidden', message: 'Admin role required' } }, 403); @@ -1084,7 +1084,7 @@ export class AuthPlugin implements Plugin { const u: any = session.user; const isAdmin = u?.isPlatformAdmin === true || - (Array.isArray(u?.roles) && u.roles.includes('platform_admin')) || + (Array.isArray(u?.positions) && u.positions.includes('platform_admin')) || u?.role === 'admin'; if (!isAdmin) { return c.json({ success: false, error: { code: 'forbidden', message: 'Admin role required' } }, 403); diff --git a/packages/plugins/plugin-email/src/email-plugin.ts b/packages/plugins/plugin-email/src/email-plugin.ts index 51cee58698..a353e92333 100644 --- a/packages/plugins/plugin-email/src/email-plugin.ts +++ b/packages/plugins/plugin-email/src/email-plugin.ts @@ -12,7 +12,7 @@ import { makeTransport } from './transports/index.js'; import { BUILTIN_AUTH_TEMPLATES } from './templates/auth-templates.js'; import type { EmailTemplateDefinition as EmailTemplate } from '@objectstack/spec/system'; -const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; /** * Plugin configuration. diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index d9d184d191..701f933631 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -720,7 +720,7 @@ export class HonoServerPlugin implements Plugin { authenticated: true, userId: execCtx.userId, tenantId: execCtx.tenantId ?? null, - roles: execCtx.roles ?? [], + positions: execCtx.positions ?? [], permissionSets: execCtx.permissions ?? [], objects: {}, fields: {}, @@ -732,7 +732,7 @@ export class HonoServerPlugin implements Plugin { // resolve to zero permission sets (matches the // post-resolution fallback in security-plugin.ts). const requested = [ - ...(execCtx.roles ?? []), + ...(execCtx.positions ?? []), ...(execCtx.permissions ?? []), ]; let resolved: any[] = await evaluator @@ -791,7 +791,7 @@ export class HonoServerPlugin implements Plugin { authenticated: true, userId: execCtx.userId, tenantId: execCtx.tenantId ?? null, - roles: execCtx.roles ?? [], + positions: execCtx.positions ?? [], permissionSets: resolved.map((p: any) => p?.name).filter(Boolean), objects, fields, diff --git a/packages/plugins/plugin-reports/src/report-service.test.ts b/packages/plugins/plugin-reports/src/report-service.test.ts index e9a4d8aee4..c17c071ce6 100644 --- a/packages/plugins/plugin-reports/src/report-service.test.ts +++ b/packages/plugins/plugin-reports/src/report-service.test.ts @@ -68,7 +68,7 @@ function makeFakeEmail() { return email; } -const CTX = { userId: 'u1', tenantId: 't1', roles: [], permissions: [] }; +const CTX = { userId: 'u1', tenantId: 't1', positions: [], permissions: [] }; // ─── Rendering ───────────────────────────────────────────────────── diff --git a/packages/plugins/plugin-reports/src/report-service.ts b/packages/plugins/plugin-reports/src/report-service.ts index 44de47750c..de9c9cc037 100644 --- a/packages/plugins/plugin-reports/src/report-service.ts +++ b/packages/plugins/plugin-reports/src/report-service.ts @@ -44,7 +44,7 @@ export interface ReportEmail { /** Stamped only in tests / specialised callers to make `now` deterministic. */ export interface ReportClock { now(): Date } -const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; const DEFAULT_FORMAT: ReportFormat = 'csv'; const DEFAULT_INTERVAL_MIN = 1440; @@ -299,7 +299,7 @@ export class ReportService implements IReportService { context: { userId: context.userId, tenantId: context.tenantId, - roles: context.roles ?? [], + positions: context.positions ?? [], permissions: context.permissions ?? [], isSystem: context.isSystem ?? false, }, diff --git a/packages/plugins/plugin-security/CHANGELOG.md b/packages/plugins/plugin-security/CHANGELOG.md index aaad2a7ef5..85558f0ed7 100644 --- a/packages/plugins/plugin-security/CHANGELOG.md +++ b/packages/plugins/plugin-security/CHANGELOG.md @@ -392,8 +392,8 @@ ADR-0057 — ERP authorization core. Adds permission-grant access DEPTH (`own`/`own_and_reports`/`unit`/`unit_and_below`/`org`), renames `sys_department` → `sys_business_unit` (no aliases — see BREAKING above), introduces the platform-owned - `sys_user_role` assignment, and seeds stack-declared `roles`/`sharingRules` into - `sys_role`/`sys_sharing_rule` at boot (closes #2077). Hierarchy-relative scopes are + `sys_user_position` assignment, and seeds stack-declared `roles`/`sharingRules` into + `sys_position`/`sys_sharing_rule` at boot (closes #2077). Hierarchy-relative scopes are delegated to a pluggable `IHierarchyScopeResolver` (open edition fails closed to owner-only; `defineStack` errors without `requires: ['hierarchy-security']`). Also fixes a latent over-grant where `engine.find({ filter })` was ignored (driver reads @@ -895,8 +895,8 @@ recognize a pre-existing row.`os.org`is unaffected (derived from`organizationId` into the two capability plugins that already register and operate them, split by concern (the two are orthogonal — sharing objects never reference RBAC objects): - - **`@objectstack/plugin-security`** (RBAC) gains `sys_role`, - `sys_permission_set`, `sys_user_permission_set`, `sys_role_permission_set`, + - **`@objectstack/plugin-security`** (RBAC) gains `sys_position`, + `sys_permission_set`, `sys_user_permission_set`, `sys_position_permission_set`, and the `defaultPermissionSets` seed (which its `bootstrap-platform-admin` already consumes). The RBAC + default-permission-set tests move with them. - **`@objectstack/plugin-sharing`** gains `sys_record_share`, @@ -934,8 +934,8 @@ recognize a pre-existing row.`os.org`is unaffected (derived from`organizationId` `i18n.loadTranslations` (the i18n service is optional — load is best-effort). - `plugin-webhooks` ← `sys_webhook`, `sys_webhook_delivery` - `plugin-approvals` ← `sys_approval_request`, `sys_approval_action` - - `plugin-security` ← `sys_role`, `sys_permission_set`, - `sys_user_permission_set`, `sys_role_permission_set` + - `plugin-security` ← `sys_position`, `sys_permission_set`, + `sys_user_permission_set`, `sys_position_permission_set` - `plugin-sharing` ← `sys_record_share`, `sys_sharing_rule`, `sys_share_link` - `@objectstack/platform-objects` translation bundles are regenerated to drop those objects' keys (its extract config already excluded them); all other diff --git a/packages/plugins/plugin-security/README.md b/packages/plugins/plugin-security/README.md index 22abc647a0..fbf23ad5bd 100644 --- a/packages/plugins/plugin-security/README.md +++ b/packages/plugins/plugin-security/README.md @@ -71,7 +71,7 @@ The plugin contributes these system objects to the kernel: | Object | Purpose | |:---|:---| -| `sys_role` | User role definitions. | +| `sys_position` | User role definitions. | | `sys_permission_set` | Bundles object and field permissions; can include RLS expressions. | Assignment tables (role ↔ user, role ↔ permission_set) are provided by [`@objectstack/plugin-auth`](../plugin-auth) when used together. diff --git a/packages/plugins/plugin-security/src/app-default-permission-set.test.ts b/packages/plugins/plugin-security/src/app-default-permission-set.test.ts new file mode 100644 index 0000000000..c38c7610cd --- /dev/null +++ b/packages/plugins/plugin-security/src/app-default-permission-set.test.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +import { describe, it, expect } from 'vitest'; +import { appDefaultPermissionSetName } from './app-default-permission-set'; + +describe('appDefaultPermissionSetName (ADR-0090 D5)', () => { + it('returns the name of the first isDefault permission set', () => { + expect( + appDefaultPermissionSetName([ + { name: 'read_only' }, + { name: 'member_std', isDefault: true }, + { name: 'member_other', isDefault: true }, + ]), + ).toBe('member_std'); + }); + + it('returns undefined when nothing is marked default', () => { + expect(appDefaultPermissionSetName([{ name: 'read_only' }])).toBeUndefined(); + expect(appDefaultPermissionSetName(undefined)).toBeUndefined(); + expect(appDefaultPermissionSetName([])).toBeUndefined(); + }); + + it('ignores malformed entries', () => { + expect( + appDefaultPermissionSetName([null, 42, { isDefault: true }, { name: '', isDefault: true }, { name: 'ok', isDefault: true }]), + ).toBe('ok'); + }); +}); diff --git a/packages/plugins/plugin-security/src/app-default-permission-set.ts b/packages/plugins/plugin-security/src/app-default-permission-set.ts new file mode 100644 index 0000000000..23c03c56ac --- /dev/null +++ b/packages/plugins/plugin-security/src/app-default-permission-set.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0090 D5 (interim wiring, supersedes ADR-0056 D7) — resolve the + * app-declared default permission-set NAME from a stack's `permissions[]`. + * + * A permission set marked `isDefault` declares the app's suggested default + * access posture. Until the built-in `everyone` position lands (ADR-0090 P2), + * the CLI keeps using this name as the runtime fallback for users with no + * explicit grants; P2 replaces the fallback mechanism with an install-time + * suggestion bound to `everyone`. + * + * Returns the first `isDefault` set's `name`, or `undefined` when none is + * declared (callers then keep the built-in `member_default` fallback). + */ +export function appDefaultPermissionSetName(permissions: unknown): string | undefined { + if (!Array.isArray(permissions)) return undefined; + for (const p of permissions) { + if (p && typeof p === 'object') { + const ps = p as { name?: unknown; isDefault?: unknown }; + if (ps.isDefault === true && typeof ps.name === 'string' && ps.name.length > 0) { + return ps.name; + } + } + } + return undefined; +} diff --git a/packages/plugins/plugin-security/src/app-default-profile.test.ts b/packages/plugins/plugin-security/src/app-default-profile.test.ts deleted file mode 100644 index 17fa70cfc1..0000000000 --- a/packages/plugins/plugin-security/src/app-default-profile.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect } from 'vitest'; -import { appDefaultProfileName } from './app-default-profile.js'; - -describe('appDefaultProfileName (ADR-0056 D7)', () => { - it('returns the name of the first isProfile+isDefault permission set', () => { - const perms = [ - { name: 'add_on', isProfile: false, isDefault: true }, // not a profile → skipped - { name: 'member', isProfile: true }, // not default → skipped - { name: 'app_default', isProfile: true, isDefault: true }, - { name: 'second_default', isProfile: true, isDefault: true }, - ]; - expect(appDefaultProfileName(perms)).toBe('app_default'); - }); - - it('treats a profile with no explicit isProfile flag as a profile', () => { - expect(appDefaultProfileName([{ name: 'd', isDefault: true }])).toBe('d'); - }); - - it('returns undefined when no default profile is declared', () => { - expect(appDefaultProfileName([{ name: 'a', isProfile: true }])).toBeUndefined(); - expect(appDefaultProfileName([])).toBeUndefined(); - expect(appDefaultProfileName(undefined)).toBeUndefined(); - expect(appDefaultProfileName(null)).toBeUndefined(); - expect(appDefaultProfileName('nope')).toBeUndefined(); - }); - - it('ignores a default flag on a non-profile add-on permission set', () => { - expect(appDefaultProfileName([{ name: 'addon', isProfile: false, isDefault: true }])).toBeUndefined(); - }); -}); diff --git a/packages/plugins/plugin-security/src/app-default-profile.ts b/packages/plugins/plugin-security/src/app-default-profile.ts deleted file mode 100644 index cf791f87fb..0000000000 --- a/packages/plugins/plugin-security/src/app-default-profile.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * ADR-0056 D7 — resolve the app-declared default profile NAME from a stack's - * `permissions[]` array. - * - * A permission set marked `isProfile && isDefault` declares the app's default - * access posture for users with no explicit grants. The {@link SecurityPlugin} - * constructor scans its `defaultPermissionSets` option for that flag — but the - * CLI constructs `new SecurityPlugin()` with NO options, so an `isDefault` - * profile declared purely in app METADATA would never be honored. The CLI calls - * this helper to pull the name out of the stack and pass it as - * `fallbackPermissionSet`, wiring the declaration through to `pnpm dev`. - * - * Returns the first matching profile's `name`, or `undefined` when none is - * declared (callers then keep the built-in `member_default` fallback). - */ -export function appDefaultProfileName(permissions: unknown): string | undefined { - if (!Array.isArray(permissions)) return undefined; - for (const p of permissions) { - if (p && typeof p === 'object') { - const ps = p as { name?: unknown; isProfile?: unknown; isDefault?: unknown }; - // `isProfile !== false` mirrors the stack convention where profiles double - // as the user's baseline; permission-set add-ons set `isProfile: false`. - if ( - ps.isDefault === true && - ps.isProfile !== false && - typeof ps.name === 'string' && - ps.name.length > 0 - ) { - return ps.name; - } - } - } - return undefined; -} diff --git a/packages/plugins/plugin-security/src/auto-org-admin-grant.ts b/packages/plugins/plugin-security/src/auto-org-admin-grant.ts index 5a3b3c5e13..a66a7349f9 100644 --- a/packages/plugins/plugin-security/src/auto-org-admin-grant.ts +++ b/packages/plugins/plugin-security/src/auto-org-admin-grant.ts @@ -31,7 +31,7 @@ * **Anti-escalation:** `organization_admin` itself (declared in * `platform-objects/src/security/default-permission-sets.ts`) is * deliberately read-only on the global RBAC tables - * (`sys_permission_set`, `sys_user_permission_set`, `sys_role`, …), + * (`sys_permission_set`, `sys_user_permission_set`, `sys_position`, …), * so a freshly-granted org admin cannot rebind themselves to * `admin_full_access`. */ diff --git a/packages/plugins/plugin-security/src/bootstrap-builtin-roles.ts b/packages/plugins/plugin-security/src/bootstrap-builtin-positions.ts similarity index 79% rename from packages/plugins/plugin-security/src/bootstrap-builtin-roles.ts rename to packages/plugins/plugin-security/src/bootstrap-builtin-positions.ts index 90a212049c..f602849f44 100644 --- a/packages/plugins/plugin-security/src/bootstrap-builtin-roles.ts +++ b/packages/plugins/plugin-security/src/bootstrap-builtin-positions.ts @@ -2,11 +2,11 @@ /** * bootstrapBuiltinRoles — seed the framework's reserved built-in identity roles - * (ADR-0068 D2) into `sys_role`. + * (ADR-0068 D2) into `sys_position`. * * The four built-in roles (`platform_admin`, `org_owner`, `org_admin`, - * `org_member`) are a normalized PROJECTION surfaced in `current_user.roles`. - * Seeding their `sys_role` rows makes the role catalog (consumed by role-bound + * `org_member`) are a normalized PROJECTION surfaced in `current_user.positions`. + * Seeding their `sys_position` rows makes the role catalog (consumed by role-bound * permission sets, sharing-rule recipients, and the ADR-0068 D4 role-catalog * validator) self-describing and AI-groundable. Their SOURCES OF TRUTH — * `sys_member.role` for the org_* roles and the unscoped `admin_full_access` @@ -17,7 +17,7 @@ * the platform-admin and declared-role bootstraps. */ -import { BUILTIN_ROLE_NAMES, BUILTIN_ROLE_METADATA } from '@objectstack/spec'; +import { BUILTIN_IDENTITY_NAMES, BUILTIN_IDENTITY_METADATA } from '@objectstack/spec'; const SYSTEM_CTX = { isSystem: true }; @@ -53,19 +53,19 @@ export async function bootstrapBuiltinRoles( } let seeded = 0; let updated = 0; - for (const name of BUILTIN_ROLE_NAMES) { - const meta = BUILTIN_ROLE_METADATA[name]; + for (const name of BUILTIN_IDENTITY_NAMES) { + const meta = BUILTIN_IDENTITY_METADATA[name]; const fields = { label: meta.label, description: meta.description, managed_by: 'system' }; - const existing = await tryFind(ql, 'sys_role', { name }, 1); + const existing = await tryFind(ql, 'sys_position', { name }, 1); if (existing[0]?.id) { - if (await tryUpdate(ql, 'sys_role', { id: existing[0].id, ...fields })) updated += 1; + if (await tryUpdate(ql, 'sys_position', { id: existing[0].id, ...fields })) updated += 1; } else { - const created = await tryInsert(ql, 'sys_role', { + const created = await tryInsert(ql, 'sys_position', { id: genId('role'), name, ...fields, active: true, is_default: false, }); if (created) seeded += 1; } } - options.logger?.info?.('[security] built-in identity roles seeded into sys_role', { seeded, updated, total: BUILTIN_ROLE_NAMES.length }); + options.logger?.info?.('[security] built-in identity roles seeded into sys_position', { seeded, updated, total: BUILTIN_IDENTITY_NAMES.length }); return { seeded, updated }; } diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts b/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts index a3634df6e2..2425fdb477 100644 --- a/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts +++ b/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts @@ -3,7 +3,7 @@ /** * bootstrapDeclaredPermissions — seed stack-declared `permissions` into * `sys_permission_set` (ADR-0086 D5; the exact sibling of - * `bootstrapDeclaredRoles`). + * `bootstrapDeclaredPositions`). * * `stack.permissions` has always been declarable and runtime-ENFORCED (the * evaluator resolves declared sets through the metadata registry), but it was @@ -26,7 +26,7 @@ * * Runs on `kernel:ready` after `bootstrapPlatformAdmin` (so the platform * defaults keep their existing insert-once shape) and alongside - * `bootstrapDeclaredRoles`. + * `bootstrapDeclaredPositions`. */ const SYSTEM_CTX = { isSystem: true }; diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-roles.ts b/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts similarity index 67% rename from packages/plugins/plugin-security/src/bootstrap-declared-roles.ts rename to packages/plugins/plugin-security/src/bootstrap-declared-positions.ts index 473f1b30b7..d04c4e0c61 100644 --- a/packages/plugins/plugin-security/src/bootstrap-declared-roles.ts +++ b/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts @@ -1,20 +1,20 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * bootstrapDeclaredRoles — seed stack-declared `roles` into `sys_role` + * bootstrapDeclaredPositions — seed stack-declared `roles` into `sys_position` * (ADR-0057 D6, closes #2077). * - * Reads the validated `role` metadata (registered from the stack's `roles: []` - * via `metadataService.list('role')`) and idempotently upserts each into - * `sys_role` by `name`, so the runtime role→permission-set resolution - * (`resolveExecutionContext` → `sys_role` → `sys_role_permission_set`) and + * Reads the validated `role` metadata (registered from the stack's `positions: []` + * via `metadataService.list('position')`) and idempotently upserts each into + * `sys_position` by `name`, so the runtime role→permission-set resolution + * (`resolveExecutionContext` → `sys_position` → `sys_position_permission_set`) and * sharing-rule role recipients stop being decorative. Runs on `kernel:ready` * alongside the platform-admin bootstrap. * * Pre-launch posture (ADR-0057): upsert only — no prune. Role visibility * HIERARCHY is NOT seeded here: per ADR-0057 D5 the role is a capability * bundle, and "manager sees subordinates" lives on the `sys_business_unit` - * tree, not `sys_role.parent`. + * tree, not `sys_position.parent`. */ const SYSTEM_CTX = { isSystem: true }; @@ -58,7 +58,7 @@ function readDeclared(engine: any, type: string): any[] { return []; } -export async function bootstrapDeclaredRoles( +export async function bootstrapDeclaredPositions( ql: any, metadataService: any, options: SeedOptions = {}, @@ -66,30 +66,30 @@ export async function bootstrapDeclaredRoles( if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') { return { seeded: 0, updated: 0 }; } - let roles: any[] = readDeclared(ql, 'role'); - if (roles.length === 0) { + let positions: any[] = readDeclared(ql, 'position'); + if (positions.length === 0) { try { - const listed = metadataService?.list?.('role'); - roles = typeof (listed as any)?.then === 'function' ? await listed : (listed ?? []); - } catch { roles = []; } + const listed = metadataService?.list?.('position'); + positions = typeof (listed as any)?.then === 'function' ? await listed : (listed ?? []); + } catch { positions = []; } } - if (!Array.isArray(roles) || roles.length === 0) return { seeded: 0, updated: 0 }; + if (!Array.isArray(positions) || positions.length === 0) return { seeded: 0, updated: 0 }; let seeded = 0; let updated = 0; - for (const r of roles) { + for (const r of positions) { if (!r?.name) continue; const fields = { label: r.label ?? r.name, description: r.description ?? null }; - const existing = await tryFind(ql, 'sys_role', { name: r.name }, 1); + const existing = await tryFind(ql, 'sys_position', { name: r.name }, 1); if (existing[0]?.id) { - if (await tryUpdate(ql, 'sys_role', { id: existing[0].id, ...fields })) updated += 1; + if (await tryUpdate(ql, 'sys_position', { id: existing[0].id, ...fields })) updated += 1; } else { - const created = await tryInsert(ql, 'sys_role', { - id: genId('role'), name: r.name, ...fields, active: true, is_default: false, + const created = await tryInsert(ql, 'sys_position', { + id: genId('position'), name: r.name, ...fields, active: true, is_default: false, }); if (created) seeded += 1; } } - options.logger?.info?.('[security] declared roles seeded into sys_role', { seeded, updated, total: roles.length }); + options.logger?.info?.('[security] declared positions seeded into sys_position', { seeded, updated, total: positions.length }); return { seeded, updated }; } diff --git a/packages/plugins/plugin-security/src/index.ts b/packages/plugins/plugin-security/src/index.ts index 17aab180ee..15b3bb445e 100644 --- a/packages/plugins/plugin-security/src/index.ts +++ b/packages/plugins/plugin-security/src/index.ts @@ -27,4 +27,4 @@ export { export { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; export { bootstrapDeclaredPermissions } from './bootstrap-declared-permissions.js'; export { claimSeedOwnership } from './claim-seed-ownership.js'; -export { appDefaultProfileName } from './app-default-profile.js'; +export { appDefaultPermissionSetName } from './app-default-permission-set.js'; diff --git a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts index 0963d8d3ae..6ff453841b 100644 --- a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts +++ b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts @@ -56,7 +56,7 @@ const denyWritesOnManagedObjects = (): Record { it('organization_admin is read-only on global RBAC tables to prevent privilege escalation', () => { const orgAdmin = defaultPermissionSets.find((p) => p.name === 'organization_admin')!; for (const obj of [ - 'sys_role', + 'sys_position', 'sys_permission_set', - 'sys_role_permission_set', + 'sys_position_permission_set', 'sys_user_permission_set', - 'sys_user_role', + 'sys_user_position', ]) { const perms = (orgAdmin.objects as any)[obj]; expect(perms, `${obj} explicit perms missing`).toBeDefined(); @@ -103,7 +103,7 @@ 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_role'); + expect(SysRole.name).toBe('sys_position'); expect(SysPermissionSet.name).toBe('sys_permission_set'); expect(SysRole.isSystem).toBe(true); expect(SysPermissionSet.isSystem).toBe(true); 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 6cea93b88b..e3e244f500 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 @@ -32,7 +32,7 @@ export const SysPermissionSet = ObjectSchema.create({ highlightFields: ['label', 'name', 'active'], // Custom actions — permission sets are templates assigned to roles or - // users (via sys_role_permission_set / sys_user_permission_set). The + // 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 // clone (build a new permset by tweaking an existing one). Both hit diff --git a/packages/plugins/plugin-security/src/objects/sys-role-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts similarity index 67% rename from packages/plugins/plugin-security/src/objects/sys-role-permission-set.object.ts rename to packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts index bea408dfad..df9e6afeb0 100644 --- a/packages/plugins/plugin-security/src/objects/sys-role-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts @@ -3,39 +3,39 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; /** - * sys_role_permission_set — Role ↔ PermissionSet binding. + * sys_position_permission_set — Role ↔ PermissionSet binding. * - * Allows administrators to compose a `sys_role` from one or more + * Allows administrators to compose a `sys_position` from one or more * `sys_permission_set` rows. At request time, the runtime resolver * (`resolveExecutionContext`) collects every permission set bound to - * the user's roles via this table and injects their names into + * the user's positions via this table and injects their names into * `ExecutionContext.permissions[]` for downstream RBAC evaluation. * * @namespace sys */ export const SysRolePermissionSet = ObjectSchema.create({ - name: 'sys_role_permission_set', + name: 'sys_position_permission_set', label: 'Role Permission Set', pluralLabel: 'Role Permission Sets', icon: 'shield-plus', isSystem: true, managedBy: 'system', - description: 'Binds a permission set to a role.', - titleFormat: '{role_id} → {permission_set_id}', - highlightFields: ['role_id', 'permission_set_id'], + description: 'Binds a permission set to a position.', + titleFormat: '{position_id} → {permission_set_id}', + highlightFields: ['position_id', 'permission_set_id'], fields: { id: Field.text({ label: 'Binding ID', required: true, readonly: true, - description: 'UUID of the role-permission-set binding.', + description: 'UUID of the position-permission-set binding.', }), - role_id: Field.lookup('sys_role', { + position_id: Field.lookup('sys_position', { label: 'Role', required: true, - description: 'Foreign key to sys_role.', + description: 'Foreign key to sys_position.', }), permission_set_id: Field.lookup('sys_permission_set', { @@ -58,8 +58,8 @@ export const SysRolePermissionSet = ObjectSchema.create({ }, indexes: [ - { fields: ['role_id', 'permission_set_id'], unique: true }, - { fields: ['role_id'] }, + { fields: ['position_id', 'permission_set_id'], unique: true }, + { fields: ['position_id'] }, { fields: ['permission_set_id'] }, ], diff --git a/packages/plugins/plugin-security/src/objects/sys-role.object.ts b/packages/plugins/plugin-security/src/objects/sys-position.object.ts similarity index 93% rename from packages/plugins/plugin-security/src/objects/sys-role.object.ts rename to packages/plugins/plugin-security/src/objects/sys-position.object.ts index fb9d55a5f2..47dc516bba 100644 --- a/packages/plugins/plugin-security/src/objects/sys-role.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-position.object.ts @@ -3,7 +3,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; /** - * sys_role — System Role Object + * sys_position — System Role Object * * RBAC role definition for the ObjectStack platform. * Roles group permissions and are assigned to users or members. @@ -11,7 +11,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * @namespace sys */ export const SysRole = ObjectSchema.create({ - name: 'sys_role', + name: 'sys_position', label: 'Role', pluralLabel: 'Roles', icon: 'shield', @@ -51,7 +51,7 @@ export const SysRole = ObjectSchema.create({ locations: ['list_item', 'record_header'], type: 'api', method: 'PATCH', - target: '/api/v1/data/sys_role/{id}', + target: '/api/v1/data/sys_position/{id}', bodyExtra: { active: true }, successMessage: 'Role activated', refreshAfter: true, @@ -65,7 +65,7 @@ export const SysRole = ObjectSchema.create({ locations: ['list_item', 'record_header'], type: 'api', method: 'PATCH', - target: '/api/v1/data/sys_role/{id}', + 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', @@ -80,14 +80,14 @@ export const SysRole = ObjectSchema.create({ locations: ['list_item', 'record_header'], type: 'api', method: 'PATCH', - target: '/api/v1/data/sys_role/{id}', + 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', refreshAfter: true, }, { - // Clone — POST a new sys_role row pre-filled from the source. The + // Clone — POST a new sys_position row pre-filled from the source. The // dialog asks only for the new API name / label so the operator // can rename atomically; permissions JSON is copied wholesale via // defaultFromRow. @@ -99,7 +99,7 @@ export const SysRole = ObjectSchema.create({ locations: ['list_item', 'record_header'], type: 'api', method: 'POST', - target: '/api/v1/data/sys_role', + target: '/api/v1/data/sys_position', bodyExtra: { is_default: false, active: true }, successMessage: 'Role cloned', refreshAfter: true, @@ -117,7 +117,7 @@ export const SysRole = ObjectSchema.create({ type: 'grid', name: 'active', label: 'Active', - data: { provider: 'object', object: 'sys_role' }, + data: { provider: 'object', object: 'sys_position' }, columns: ['label', 'name', 'is_default', 'updated_at'], filter: [{ field: 'active', operator: 'equals', value: true }], sort: [{ field: 'label', order: 'asc' }], @@ -127,7 +127,7 @@ export const SysRole = ObjectSchema.create({ type: 'grid', name: 'default_roles', label: 'Default', - data: { provider: 'object', object: 'sys_role' }, + data: { provider: 'object', object: 'sys_position' }, columns: ['label', 'name', 'description', 'active'], filter: [{ field: 'is_default', operator: 'equals', value: true }], sort: [{ field: 'label', order: 'asc' }], @@ -137,7 +137,7 @@ export const SysRole = ObjectSchema.create({ type: 'grid', name: 'custom', label: 'Custom', - data: { provider: 'object', object: 'sys_role' }, + data: { provider: 'object', object: 'sys_position' }, columns: ['label', 'name', 'active', 'updated_at'], filter: [{ field: 'is_default', operator: 'equals', value: false }], sort: [{ field: 'label', order: 'asc' }], @@ -147,7 +147,7 @@ export const SysRole = ObjectSchema.create({ type: 'grid', name: 'all_roles', label: 'All', - data: { provider: 'object', object: 'sys_role' }, + data: { provider: 'object', object: 'sys_position' }, columns: ['label', 'name', 'active', 'is_default', 'updated_at'], sort: [{ field: 'label', order: 'asc' }], pagination: { pageSize: 50 }, diff --git a/packages/plugins/plugin-security/src/objects/sys-user-role.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts similarity index 56% rename from packages/plugins/plugin-security/src/objects/sys-user-role.object.ts rename to packages/plugins/plugin-security/src/objects/sys-user-position.object.ts index 1503647a80..ca707adf3b 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-role.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts @@ -3,38 +3,39 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; /** - * sys_user_role — User ↔ Role assignment (ADR-0057 D4). + * sys_user_position — User ↔ Role assignment (ADR-0057 D4). * - * The platform-owned source of truth for "who holds which RBAC role", - * decoupled from better-auth's `sys_member.role` (which is reframed to - * org-administration: owner/admin/member). At request time the runtime - * resolver (`resolveExecutionContext`) reads assignments from this table - * (∪ `sys_member.role` during the transition window) into - * `ExecutionContext.roles[]`. + * 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 + * `ExecutionContext.positions[]`. * - * `role` stores the role's machine name (matches `sys_role.name`), mirroring - * how `ctx.roles` is keyed everywhere downstream. `organization_id = null` - * means a cross-tenant (global) assignment. + * `position` stores the position's machine name (matches + * `sys_position.name`), mirroring how `ctx.positions` is keyed everywhere + * downstream. `organization_id = null` means a cross-tenant (global) + * assignment. * * @namespace sys */ export const SysUserRole = ObjectSchema.create({ - name: 'sys_user_role', + name: 'sys_user_position', label: 'User Role', pluralLabel: 'User Roles', icon: 'user-cog', isSystem: true, managedBy: 'system', - description: 'Assigns an RBAC role (sys_role.name) to a user. Platform-owned (ADR-0057 D4).', - titleFormat: '{user_id} → {role}', - highlightFields: ['user_id', 'role', 'organization_id'], + 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'], fields: { id: Field.text({ label: 'Assignment ID', required: true, readonly: true, - description: 'UUID of the user-role assignment.', + description: 'UUID of the user-position assignment.', }), user_id: Field.lookup('sys_user', { @@ -43,11 +44,11 @@ export const SysUserRole = ObjectSchema.create({ description: 'Foreign key to sys_user.', }), - role: Field.text({ + position: Field.text({ label: 'Role', required: true, maxLength: 100, - description: 'RBAC role machine name (references sys_role.name).', + description: 'Position machine name (references sys_position.name).', }), organization_id: Field.lookup('sys_organization', { @@ -76,9 +77,9 @@ export const SysUserRole = ObjectSchema.create({ }, indexes: [ - { fields: ['user_id', 'role', 'organization_id'], unique: true }, + { fields: ['user_id', 'position', 'organization_id'], unique: true }, { fields: ['user_id'] }, - { fields: ['role'] }, + { fields: ['position'] }, { fields: ['organization_id'] }, ], diff --git a/packages/plugins/plugin-security/src/permission-evaluator.ts b/packages/plugins/plugin-security/src/permission-evaluator.ts index 3f45d523ac..ea99735f69 100644 --- a/packages/plugins/plugin-security/src/permission-evaluator.ts +++ b/packages/plugins/plugin-security/src/permission-evaluator.ts @@ -266,11 +266,11 @@ export class PermissionEvaluator { * Resolve permission sets for a list of identifier names from metadata. * * Identifiers are matched to `PermissionSet.name`. The names may be - * either role names (when `sys_role.name` is reused as a permission set + * either role names (when `sys_position.name` is reused as a permission set * name — common for default admin/member/viewer roles) or explicit * permission set names supplied through `ExecutionContext.permissions[]` * (resolved by `resolveExecutionContext` from `sys_user_permission_set` - * and `sys_role_permission_set`). + * and `sys_position_permission_set`). * * Async because the underlying metadata service exposes `list()` as a * Promise — synchronous iteration would silently yield zero results diff --git a/packages/plugins/plugin-security/src/rls-compiler.ts b/packages/plugins/plugin-security/src/rls-compiler.ts index bde626f66f..f8d21f1e52 100644 --- a/packages/plugins/plugin-security/src/rls-compiler.ts +++ b/packages/plugins/plugin-security/src/rls-compiler.ts @@ -18,7 +18,7 @@ interface RLSUserContext { * `organization_id` — see better-auth's organization plugin). */ organization_id?: string; - roles?: string[]; + positions?: string[]; /** * IDs of all users that share the active organization with the * current user (incl. self). Pre-resolved by the runtime so RLS can @@ -155,7 +155,7 @@ export class RLSCompiler { const userCtx: RLSUserContext = { id: executionContext?.userId, organization_id: executionContext?.tenantId, - roles: executionContext?.roles, + positions: executionContext?.positions, org_user_ids: (executionContext as any)?.org_user_ids, // Unique identifier — safe for ownership predicates (see RLSUserContext). email: (executionContext as any)?.email, diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index b6d6e14896..3c9a7115b3 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -156,7 +156,6 @@ describe('SecurityPlugin', () => { const tenantPolicySet: PermissionSet = { name: 'member_default', label: 'Member', - isProfile: true, objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, rowLevelSecurity: [ { name: 'tenant_isolation', object: '*', operation: 'all', using: 'organization_id = current_user.organization_id' }, @@ -173,7 +172,7 @@ describe('SecurityPlugin', () => { await plugin.start(harness.ctx); const opCtx: any = { object: 'task', operation: 'insert', data: { name: 'A' }, - context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }, + context: { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }, }; await harness.run(opCtx); // SecurityPlugin no longer touches organization_id — that's plugin-org-scoping's job. @@ -188,7 +187,7 @@ describe('SecurityPlugin', () => { await plugin.start(harness.ctx); const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, - context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }, + context: { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }, }; await harness.run(opCtx); expect(opCtx.ast.where).toBeUndefined(); @@ -201,7 +200,7 @@ describe('SecurityPlugin', () => { await plugin.start(harness.ctx); const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, - context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }, + context: { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }, }; await harness.run(opCtx); expect(opCtx.ast.where).toEqual({ organization_id: 'org-1' }); @@ -228,7 +227,7 @@ describe('SecurityPlugin', () => { await plugin.start(harness.ctx); const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, - context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }, + context: { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }, }; await harness.run(opCtx); // No deny sentinel, no organization_id where clause: the read @@ -243,14 +242,13 @@ describe('SecurityPlugin', () => { const ownerPolicySet: PermissionSet = { name: 'member_default', label: 'Member', - isProfile: true, objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, rowLevelSecurity: [ { name: 'owner_only_writes', object: '*', operation: 'update', using: 'created_by = current_user.id' }, { name: 'owner_only_deletes', object: '*', operation: 'delete', using: 'created_by = current_user.id' }, ], } as any; - const memberCtx = { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }; + const memberCtx = { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }; const ownerFields = ['id', 'created_by', 'name']; it('DENIES an update when the target row is not visible under the write filter (not the owner)', async () => { @@ -299,7 +297,7 @@ describe('SecurityPlugin', () => { // otherwise a grant-holder could destroy out-of-scope rows by id. purge // maps onto the `delete` RLS class. const purgerSet: PermissionSet = { - name: 'purger', label: 'Purger', isProfile: true, + name: 'purger', label: 'Purger', objects: { '*': { allowRead: true, allowPurge: true } }, rowLevelSecurity: [ { name: 'owner_only_deletes', object: '*', operation: 'delete', using: 'created_by = current_user.id' }, @@ -324,7 +322,7 @@ describe('SecurityPlugin', () => { it('SKIPS the check when no RLS policy applies (e.g. modifyAllRecords / admin) — no extra read', async () => { const adminSet: PermissionSet = { - name: 'admin_full_access', label: 'Admin', isProfile: true, + name: 'admin_full_access', label: 'Admin', objects: { '*': { allowRead: true, allowEdit: true, allowDelete: true, modifyAllRecords: true, viewAllRecords: true } }, // no rowLevelSecurity } as any; @@ -370,7 +368,7 @@ describe('SecurityPlugin', () => { await plugin.start(harness.ctx); const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, - context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }, + context: { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }, }; await harness.run(opCtx); expect(opCtx.ast.where).toBeUndefined(); @@ -390,14 +388,14 @@ describe('SecurityPlugin', () => { await plugin.start(harness.ctx); const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, - context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }, + context: { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }, }; await harness.run(opCtx); expect(opCtx.ast.where).toEqual(RLS_DENY_FILTER); }); // Post-resolution fallback: roles is non-empty (e.g. better-auth - // sys_member.role = 'owner') but no sys_role binding maps that name to + // sys_member.role = 'owner') but no sys_position binding maps that name to // a permission set, so resolvePermissionSets returns []. Without the // post-resolution fallback both CRUD and RLS would be skipped → users // with org membership but no granted permission set could read every @@ -424,13 +422,13 @@ describe('SecurityPlugin', () => { // ------------------------------------------------------------------------- describe('ADR-0066 private posture + requiredPermissions (middleware)', () => { const memberSet: PermissionSet = { - name: 'member_default', label: 'Member', isProfile: true, + name: 'member_default', label: 'Member', objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, } as any; // Platform super-admin: super-user wildcard + the capability + a tenant_isolation // RLS policy (to prove the posture-gated bypass actually short-circuits it). const adminSet: PermissionSet = { - name: 'admin_full_access', label: 'Admin', isProfile: true, + name: 'admin_full_access', label: 'Admin', objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true } }, systemPermissions: ['manage_platform_settings'], rowLevelSecurity: [ @@ -450,7 +448,7 @@ describe('SecurityPlugin', () => { await plugin.start(harness.ctx); const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, - context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }, + context: { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }, }; await expect(harness.run(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' }); }); @@ -466,7 +464,7 @@ describe('SecurityPlugin', () => { await plugin.start(harness.ctx); const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, - context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }, + context: { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }, }; await expect(harness.run(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError', @@ -538,7 +536,7 @@ describe('SecurityPlugin', () => { it('does NOT bypass for a non-admin on a better-auth-managed object (no leak)', async () => { const memberWithRls: PermissionSet = { - name: 'member_default', label: 'Member', isProfile: true, + name: 'member_default', label: 'Member', objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, rowLevelSecurity: [ { name: 'tenant_isolation', object: '*', operation: 'all', using: 'organization_id = current_user.organization_id' }, @@ -555,7 +553,7 @@ describe('SecurityPlugin', () => { await plugin.start(harness.ctx); const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, - context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }, + context: { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }, }; await harness.run(opCtx); // The member is still tenant-scoped — the managedBy bypass is admin-only. @@ -568,11 +566,11 @@ describe('SecurityPlugin', () => { // ------------------------------------------------------------------------- describe('ADR-0066 ⑤ per-operation requiredPermissions (middleware)', () => { const memberSet: PermissionSet = { - name: 'member_default', label: 'Member', isProfile: true, + name: 'member_default', label: 'Member', objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, } as any; const memberWithCap: PermissionSet = { - name: 'member_default', label: 'Member', isProfile: true, + name: 'member_default', label: 'Member', objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, systemPermissions: ['manage_x'], } as any; @@ -587,7 +585,7 @@ describe('SecurityPlugin', () => { await plugin.start(harness.ctx); const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, - context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }, + context: { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }, }; await expect(harness.run(opCtx)).resolves.toBeDefined(); }); @@ -599,7 +597,7 @@ describe('SecurityPlugin', () => { await plugin.start(harness.ctx); const opCtx: any = { object: 'task', operation: 'insert', data: { name: 'A' }, - context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }, + context: { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }, }; await expect(harness.run(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError', @@ -614,7 +612,7 @@ describe('SecurityPlugin', () => { await plugin.start(harness.ctx); const opCtx: any = { object: 'task', operation: 'insert', data: { name: 'A' }, - context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }, + context: { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }, }; await expect(harness.run(opCtx)).resolves.toBeDefined(); expect(opCtx.data.owner_id).toBe('u1'); // proves it flowed past the gate @@ -631,13 +629,13 @@ describe('SecurityPlugin', () => { // read is denied under the array form (unlike the per-op map above)… const readCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, - context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }, + context: { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }, }; await expect(harness.run(readCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' }); // …and so is create. const createCtx: any = { object: 'task', operation: 'insert', data: { name: 'A' }, - context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }, + context: { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }, }; await expect(harness.run(createCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' }); }); @@ -666,7 +664,7 @@ describe('SecurityPlugin', () => { const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet], orgScoping: true }); await plugin.init(harness.ctx); await plugin.start(harness.ctx); - const ctx = { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }; + const ctx = { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }; const filter = await plugin.getReadFilter('task', ctx); expect(filter).toEqual({ organization_id: 'org-1' }); }); @@ -676,7 +674,7 @@ describe('SecurityPlugin', () => { const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet] }); await plugin.init(harness.ctx); await plugin.start(harness.ctx); - const filter = await plugin.getReadFilter('task', { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }); + const filter = await plugin.getReadFilter('task', { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }); expect(filter).toBeUndefined(); }); @@ -689,7 +687,7 @@ describe('SecurityPlugin', () => { }); await plugin.init(harness.ctx); await plugin.start(harness.ctx); - const filter = await plugin.getReadFilter('task', { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }); + const filter = await plugin.getReadFilter('task', { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }); expect(filter).toEqual(RLS_DENY_FILTER); }); @@ -703,7 +701,7 @@ describe('SecurityPlugin', () => { }); await plugin.init(harness.ctx); await plugin.start(harness.ctx); - const filter = await plugin.getReadFilter('task', { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }); + const filter = await plugin.getReadFilter('task', { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }); expect(filter).toBeUndefined(); }); @@ -721,7 +719,7 @@ describe('SecurityPlugin', () => { const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet], orgScoping: true }); await plugin.init(harness.ctx); await plugin.start(harness.ctx); - const filter = await plugin.getReadFilter('task', { roles: [], permissions: [] }); + const filter = await plugin.getReadFilter('task', { positions: [], permissions: [] }); expect(filter).toBeUndefined(); }); @@ -734,7 +732,7 @@ describe('SecurityPlugin', () => { (plugin as any).permissionEvaluator.resolvePermissionSets = async () => { throw new Error('metadata service unavailable'); }; - const filter = await plugin.getReadFilter('task', { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }); + const filter = await plugin.getReadFilter('task', { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }); expect(filter).toEqual(RLS_DENY_FILTER); }); }); @@ -747,7 +745,6 @@ describe('SecurityPlugin', () => { const flsPolicySet: PermissionSet = { name: 'member_default', label: 'Member', - isProfile: true, objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, }, @@ -769,7 +766,7 @@ describe('SecurityPlugin', () => { object: 'task', operation: 'insert', data: { name: 'A', salary: 9999 }, - context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: ['member_default'] }, + context: { userId: 'u1', tenantId: 'org-1', positions: [], permissions: ['member_default'] }, }; await expect(harness.run(opCtx)).rejects.toThrow(/Field write denied/); await expect(harness.run(opCtx)).rejects.toMatchObject({ @@ -789,7 +786,7 @@ describe('SecurityPlugin', () => { object: 'task', operation: 'update', data: { ssn: 'leaked-123' }, - context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: ['member_default'] }, + context: { userId: 'u1', tenantId: 'org-1', positions: [], permissions: ['member_default'] }, }; await expect(harness.run(opCtx)).rejects.toMatchObject({ details: { forbiddenFields: ['ssn'] }, @@ -813,7 +810,7 @@ describe('SecurityPlugin', () => { data: { name: 'edited' }, // only editable field written → passes write gate // Engine's echoed post-image (pre-set; harness `next` is a no-op). result: { id: 't1', name: 'edited', salary: 9999, ssn: 'secret-leak' }, - context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: ['member_default'] }, + context: { userId: 'u1', tenantId: 'org-1', positions: [], permissions: ['member_default'] }, }; await harness.run(opCtx); // ssn (readable:false) stripped; salary (readable:true) retained. @@ -835,7 +832,7 @@ describe('SecurityPlugin', () => { operation: 'insert', data: { name: 'A' }, result: { id: 't2', name: 'A', salary: 100, ssn: 'server-generated' }, - context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: ['member_default'] }, + context: { userId: 'u1', tenantId: 'org-1', positions: [], permissions: ['member_default'] }, }; await harness.run(opCtx); expect(opCtx.result.ssn).toBeUndefined(); @@ -886,7 +883,7 @@ describe('SecurityPlugin', () => { object: 'task', operation: 'insert', data: { name: 'A', salary: 1, ssn: 'x' }, - context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: ['member_default'] }, + context: { userId: 'u1', tenantId: 'org-1', positions: [], permissions: ['member_default'] }, }; await expect(harness.run(opCtx)).rejects.toMatchObject({ details: { forbiddenFields: ['salary', 'ssn'] }, @@ -905,7 +902,7 @@ describe('SecurityPlugin', () => { object: 'task', operation: 'insert', data: { name: 'A' }, - context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: ['member_default'] }, + context: { userId: 'u1', tenantId: 'org-1', positions: [], permissions: ['member_default'] }, }; await expect(harness.run(opCtx)).resolves.toBeTruthy(); // owner_id was auto-injected (still in scope for tests) @@ -927,7 +924,7 @@ describe('SecurityPlugin', () => { { name: 'a' }, { name: 'b', salary: 9 }, // offender on row 2 ], - context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: ['member_default'] }, + context: { userId: 'u1', tenantId: 'org-1', positions: [], permissions: ['member_default'] }, }; await expect(harness.run(opCtx)).rejects.toMatchObject({ details: { forbiddenFields: ['salary'] }, @@ -964,7 +961,7 @@ describe('SecurityPlugin', () => { object: 'task', operation: 'insert', data: { name: 'A', description: 'foo' }, - context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: ['member_default'] }, + context: { userId: 'u1', tenantId: 'org-1', positions: [], permissions: ['member_default'] }, }; await expect(harness.run(opCtx)).resolves.toBeTruthy(); }); @@ -982,7 +979,7 @@ describe('SecurityPlugin', () => { operation: 'find', ast: { where: undefined }, result: undefined, - context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: ['member_default'] }, + context: { userId: 'u1', tenantId: 'org-1', positions: [], permissions: ['member_default'] }, }; // emulate the engine populating result inside next() const orig = harness.run; @@ -1003,7 +1000,6 @@ describe('SecurityPlugin', () => { const checkPolicySet: PermissionSet = { name: 'member_default', label: 'Member', - isProfile: true, objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, rowLevelSecurity: [ { name: 'emea_insert_check', object: '*', operation: 'insert', check: "region == 'EMEA'" }, @@ -1022,7 +1018,7 @@ describe('SecurityPlugin', () => { await plugin.start(harness.ctx); return harness; }; - const ctx = () => ({ userId: 'u1', tenantId: 'org-1', roles: [], permissions: ['member_default'] }); + const ctx = () => ({ userId: 'u1', tenantId: 'org-1', positions: [], permissions: ['member_default'] }); it('INSERT whose post-image satisfies the check succeeds', async () => { const h = await started(); @@ -1078,10 +1074,10 @@ describe('SecurityPlugin', () => { // Superuser set — proves the gate is NOT grant-gated (blocks even // modifyAllRecords) and carries no RLS so the pre-image check is a no-op. const adminSet: PermissionSet = { - name: 'admin_full_access', label: 'Admin', isProfile: true, + name: 'admin_full_access', label: 'Admin', objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, modifyAllRecords: true } }, } as any; - const adminCtx = { userId: 'admin1', tenantId: 'org-1', roles: [], permissions: ['admin_full_access'] }; + const adminCtx = { userId: 'admin1', tenantId: 'org-1', positions: [], permissions: ['admin_full_access'] }; const runGate = async (opCtx: any, findOneImpl?: (q: any) => any) => { const plugin = new SecurityPlugin({ fallbackPermissionSet: 'admin_full_access' }); @@ -1649,7 +1645,7 @@ describe('RLSCompiler', () => { it('should compile equality expression with current_user property', () => { const compiler = new RLSCompiler(); const policy: any = { object: 'task', operation: 'select', using: 'owner_id = current_user.id' }; - const ctx: any = { userId: 'user-42', tenantId: 'tenant-1', roles: [] }; + const ctx: any = { userId: 'user-42', tenantId: 'tenant-1', positions: [] }; const filter = compiler.compileFilter([policy], ctx); expect(filter).toEqual({ owner_id: 'user-42' }); }); @@ -1661,7 +1657,7 @@ describe('RLSCompiler', () => { // system find (the value is baked in, not a placeholder). const compiler = new RLSCompiler(); const policy: any = { object: 'showcase_invoice', operation: 'select', using: 'owner = current_user.email' }; - const ctx: any = { userId: 'u1', email: 'ada@example.com', roles: [] }; + const ctx: any = { userId: 'u1', email: 'ada@example.com', positions: [] }; const filter = compiler.compileFilter([policy], ctx); expect(filter).toEqual({ owner: 'ada@example.com' }); }); @@ -1672,7 +1668,7 @@ describe('RLSCompiler', () => { // variable drops the (only) policy → deny sentinel. const compiler = new RLSCompiler(); const policy: any = { object: 'showcase_invoice', operation: 'select', using: 'owner = current_user.name' }; - const ctx: any = { userId: 'u1', email: 'ada@example.com', roles: [] }; + const ctx: any = { userId: 'u1', email: 'ada@example.com', positions: [] }; const filter = compiler.compileFilter([policy], ctx); expect(filter).toEqual(RLS_DENY_FILTER); }); @@ -1706,7 +1702,7 @@ describe('RLSCompiler', () => { const ctx: any = { userId: 'u1', tenantId: 'org-1', - roles: [], + positions: [], org_user_ids: ['u1', 'u2', 'u3'], }; const filter = compiler.compileFilter([policy], ctx); @@ -1724,7 +1720,7 @@ describe('RLSCompiler', () => { operation: 'select', using: 'id IN (current_user.org_user_ids)', }; - const ctx: any = { userId: 'u1', tenantId: null, roles: [], org_user_ids: [] }; + const ctx: any = { userId: 'u1', tenantId: null, positions: [], org_user_ids: [] }; const filter = compiler.compileFilter([policy], ctx); expect(filter).toEqual(RLS_DENY_FILTER); }); @@ -1733,7 +1729,7 @@ describe('RLSCompiler', () => { const compiler = new RLSCompiler(); const p1: any = { object: 'task', operation: 'select', using: 'owner_id = current_user.id' }; const p2: any = { object: 'task', operation: 'select', using: "status = 'public'" }; - const ctx: any = { userId: 'u99', tenantId: 't1', roles: [] }; + const ctx: any = { userId: 'u99', tenantId: 't1', positions: [] }; const filter = compiler.compileFilter([p1, p2], ctx); expect(filter).toEqual({ $or: [{ owner_id: 'u99' }, { status: 'public' }] }); }); @@ -1759,7 +1755,7 @@ describe('RLSCompiler', () => { // `sys_user_self`) still grant access — see the next test. const compiler = new RLSCompiler(); const policy: any = { object: '*', operation: 'all', using: 'organization_id = current_user.organization_id' }; - const ctx: any = { userId: 'u1', tenantId: null, roles: [] }; + const ctx: any = { userId: 'u1', tenantId: null, positions: [] }; const filter = compiler.compileFilter([policy], ctx); expect(filter).toEqual(RLS_DENY_FILTER); }); @@ -1770,7 +1766,7 @@ describe('RLSCompiler', () => { const compiler = new RLSCompiler(); const tenantPolicy: any = { object: '*', operation: 'all', using: 'organization_id = current_user.organization_id' }; const selfPolicy: any = { object: 'sys_user', operation: 'select', using: 'id = current_user.id' }; - const ctx: any = { userId: 'u1', tenantId: null, roles: [] }; + const ctx: any = { userId: 'u1', tenantId: null, positions: [] }; const filter = compiler.compileFilter([tenantPolicy, selfPolicy], ctx); expect(filter).toEqual({ id: 'u1' }); }); @@ -1779,7 +1775,7 @@ describe('RLSCompiler', () => { // Sanity check — the deny path is only triggered by *missing* values. const compiler = new RLSCompiler(); const policy: any = { object: '*', operation: 'all', using: 'organization_id = current_user.organization_id' }; - const ctx: any = { userId: 'u1', tenantId: 'org-1', roles: [] }; + const ctx: any = { userId: 'u1', tenantId: 'org-1', positions: [] }; const filter = compiler.compileFilter([policy], ctx); expect(filter).toEqual({ organization_id: 'org-1' }); }); @@ -1832,7 +1828,7 @@ describe('RLSCompiler', () => { operation: 'select', using: 'assigned_to_id IN (current_user.team_member_ids)', }; - const ctx: any = { userId: 'mgr-1', tenantId: 'org-1', roles: [], rlsMembership: { team_member_ids: [] } }; + const ctx: any = { userId: 'mgr-1', tenantId: 'org-1', positions: [], rlsMembership: { team_member_ids: [] } }; const filter = compiler.compileFilter([policy], ctx); expect(filter).toEqual(RLS_DENY_FILTER); }); @@ -2018,11 +2014,11 @@ describe('SecurityPlugin — ADR-0066 D3 field-level requiredPermissions', () => }, }; const setNoCap: PermissionSet = { - name: 'fld_member', isProfile: true, + name: 'fld_member', objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, } as any; const setWithCap: PermissionSet = { - name: 'fld_cap', isProfile: true, + name: 'fld_cap', objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, systemPermissions: ['view_salary'], } as any; @@ -2050,7 +2046,7 @@ describe('SecurityPlugin — ADR-0066 D3 field-level requiredPermissions', () => it('masks a capability-gated field on read when the caller lacks the capability', async () => { const h = harnessFor([setNoCap], 'fld_member'); await h.plugin.init(h.ctx); await h.plugin.start(h.ctx); - const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, result: [{ id: 'r1', name: 'A', salary: 100 }], context: { userId: 'u1', roles: [], permissions: [] } }; + const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, result: [{ id: 'r1', name: 'A', salary: 100 }], context: { userId: 'u1', positions: [], permissions: [] } }; await h.run(opCtx); expect(opCtx.result[0].name).toBe('A'); expect(opCtx.result[0].salary).toBeUndefined(); @@ -2059,7 +2055,7 @@ describe('SecurityPlugin — ADR-0066 D3 field-level requiredPermissions', () => it('does NOT mask when the caller holds the capability', async () => { const h = harnessFor([setWithCap], 'fld_cap'); await h.plugin.init(h.ctx); await h.plugin.start(h.ctx); - const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, result: [{ id: 'r1', name: 'A', salary: 100 }], context: { userId: 'u1', roles: [], permissions: ['fld_cap'] } }; + const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, result: [{ id: 'r1', name: 'A', salary: 100 }], context: { userId: 'u1', positions: [], permissions: ['fld_cap'] } }; await h.run(opCtx); expect(opCtx.result[0].salary).toBe(100); }); @@ -2067,14 +2063,14 @@ describe('SecurityPlugin — ADR-0066 D3 field-level requiredPermissions', () => it('denies a write to a capability-gated field when the caller lacks the capability', async () => { const h = harnessFor([setNoCap], 'fld_member'); await h.plugin.init(h.ctx); await h.plugin.start(h.ctx); - const opCtx: any = { object: 'task', operation: 'insert', data: { name: 'A', salary: 200 }, context: { userId: 'u1', roles: [], permissions: [] } }; + const opCtx: any = { object: 'task', operation: 'insert', data: { name: 'A', salary: 200 }, context: { userId: 'u1', positions: [], permissions: [] } }; await expect(h.run(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' }); }); it('allows the write when the caller holds the capability', async () => { const h = harnessFor([setWithCap], 'fld_cap'); await h.plugin.init(h.ctx); await h.plugin.start(h.ctx); - const opCtx: any = { object: 'task', operation: 'insert', data: { name: 'A', salary: 200 }, context: { userId: 'u1', roles: [], permissions: ['fld_cap'] } }; + const opCtx: any = { object: 'task', operation: 'insert', data: { name: 'A', salary: 200 }, context: { userId: 'u1', positions: [], permissions: ['fld_cap'] } }; await expect(h.run(opCtx)).resolves.toBeDefined(); }); }); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 7fa99868cc..7bd3a4ccbd 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -3,9 +3,9 @@ import { Plugin, PluginContext } from '@objectstack/core'; import type { PermissionSet, RowLevelSecurityPolicy } from '@objectstack/spec/security'; import { PermissionEvaluator, crudBucketForOperation } from './permission-evaluator.js'; -import { bootstrapDeclaredRoles } from './bootstrap-declared-roles.js'; +import { bootstrapDeclaredPositions } from './bootstrap-declared-positions.js'; import { bootstrapDeclaredPermissions, upsertPackagePermissionSet } from './bootstrap-declared-permissions.js'; -import { bootstrapBuiltinRoles } from './bootstrap-builtin-roles.js'; +import { bootstrapBuiltinRoles } from './bootstrap-builtin-positions.js'; import { bootstrapSystemCapabilities } from './bootstrap-system-capabilities.js'; import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js'; import { matchesFilterCondition } from '@objectstack/formula'; @@ -231,7 +231,7 @@ export class SecurityPlugin implements Plugin { group: 'group_access_control', priority: 100, items: [ - { id: 'nav_roles', type: 'object', label: 'Roles', objectName: 'sys_role', icon: 'shield-check' }, + { id: 'nav_roles', type: 'object', label: 'Roles', objectName: 'sys_position', icon: 'shield-check' }, { id: 'nav_capabilities', type: 'object', label: 'Capabilities', objectName: 'sys_capability', icon: 'badge-check' }, { id: 'nav_permission_sets', type: 'object', label: 'Permission Sets', objectName: 'sys_permission_set', icon: 'lock' }, ], @@ -411,7 +411,7 @@ export class SecurityPlugin implements Plugin { // the publish materializer pass straight through. await this.assertPackageManagedWriteGate(opCtx); - const roles = opCtx.context?.roles ?? []; + const roles = opCtx.context?.positions ?? []; const explicitPermissionSets = opCtx.context?.permissions ?? []; // Skip security checks if no roles AND no explicit permission sets @@ -826,10 +826,10 @@ export class SecurityPlugin implements Plugin { const report = await bootstrapPlatformAdmin(ql, this.bootstrapPermissionSets, { logger: ctx.logger, }); - // [ADR-0057 D6 / #2077] Seed stack-declared roles into sys_role so they + // [ADR-0057 D6 / #2077] Seed stack-declared roles into sys_position so they // stop being decorative (role→permission-set resolution + recipients). try { - await bootstrapDeclaredRoles(ql, this.metadata, { logger: ctx.logger }); + await bootstrapDeclaredPositions(ql, this.metadata, { logger: ctx.logger }); } catch (e) { ctx.logger.warn('[security] declared-role seeding failed', { error: (e as Error).message }); } @@ -1032,7 +1032,7 @@ export class SecurityPlugin implements Plugin { ): Promise | null | undefined> { // System operations bypass scoping (mirrors the middleware's isSystem skip). if (context?.isSystem) return undefined; - const roles = context?.roles ?? []; + const roles = context?.positions ?? []; const explicit = context?.permissions ?? []; // Unauthenticated + role-less + permission-less → no scope (the auth layer, // not RLS, gates anonymous access; the analytics REST endpoint already 401s @@ -1067,7 +1067,7 @@ export class SecurityPlugin implements Plugin { private async resolvePermissionSetsForContext( context: any, ): Promise { - const roles = context?.roles ?? []; + const roles = context?.positions ?? []; const explicitPermissionSets = context?.permissions ?? []; const requested = [...roles, ...explicitPermissionSets]; // Implicit baseline: an authenticated request that named no roles/perms @@ -1084,7 +1084,7 @@ export class SecurityPlugin implements Plugin { { logger: this.logger }, ); // Post-resolution fallback — closes the fail-open hole where a populated - // `roles` array maps to no permission set yet (no sys_role binding), which + // `roles` array maps to no permission set yet (no sys_position binding), which // would otherwise skip RLS entirely and expose every tenant's data. if ( permissionSets.length === 0 && diff --git a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts index 406d78abbf..c549cb80a7 100644 --- a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts @@ -9,17 +9,17 @@ import type { TranslationData } from '@objectstack/spec/system'; export const enObjects: NonNullable = { - sys_role: { - label: "Role", - pluralLabel: "Roles", - description: "Role definitions for RBAC access control", + sys_position: { + label: "Position", + pluralLabel: "Positions", + description: "Position definitions — flat capability-distribution groups (ADR-0090 D3)", fields: { label: { label: "Display Name" }, name: { label: "API Name", - help: "Unique machine name for the role (e.g. admin, editor, viewer)" + help: "Unique machine name for the position (e.g. admin, editor, viewer)" }, description: { label: "Description" @@ -32,11 +32,11 @@ export const enObjects: NonNullable = { label: "Active" }, is_default: { - label: "Default Role", + label: "Default Position", help: "Automatically assigned to new users" }, id: { - label: "Role ID" + label: "Position ID" }, created_at: { label: "Created At" @@ -49,34 +49,34 @@ export const enObjects: NonNullable = { active: { label: "Active" }, - default_roles: { + default_positions: { label: "Default" }, custom: { label: "Custom" }, - all_roles: { + all_positions: { label: "All" } }, _actions: { - activate_role: { - label: "Activate Role", - successMessage: "Role activated" + activate_position: { + label: "Activate Position", + successMessage: "Position activated" }, - deactivate_role: { - label: "Deactivate Role", - confirmText: "Deactivate this role? Users with the role keep their assignment but the role stops granting permissions until re-activated.", - successMessage: "Role deactivated" + deactivate_position: { + label: "Deactivate Position", + confirmText: "Deactivate this position? Users with the position keep their assignment but the position stops granting permissions until re-activated.", + successMessage: "Position deactivated" }, - set_default_role: { + set_default_position: { label: "Set as Default", - 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" }, - clone_role: { - label: "Clone Role", - successMessage: "Role cloned" + clone_position: { + label: "Clone Position", + successMessage: "Position cloned" } } }, @@ -188,18 +188,18 @@ export const enObjects: NonNullable = { } } }, - sys_role_permission_set: { - label: "Role Permission Set", - pluralLabel: "Role Permission Sets", - description: "Binds a permission set to a role.", + sys_position_permission_set: { + label: "Position Permission Set", + pluralLabel: "Position Permission Sets", + description: "Binds a permission set to a position.", fields: { id: { label: "Binding ID", - help: "UUID of the role-permission-set binding." + help: "UUID of the position-permission-set binding." }, - role_id: { - label: "Role", - help: "Foreign key to sys_role." + position_id: { + label: "Position", + help: "Foreign key to sys_position." }, permission_set_id: { label: "Permission Set", diff --git a/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts index 61536b6dfc..49c49872d2 100644 --- a/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts @@ -9,17 +9,17 @@ import type { TranslationData } from '@objectstack/spec/system'; export const esESObjects: NonNullable = { - sys_role: { - label: "Rol", - pluralLabel: "Roles", - description: "Definiciones de rol para el control de acceso RBAC", + sys_position: { + label: "Puesto", + pluralLabel: "Puestoes", + description: "Definiciones de puesto para el contpuesto de acceso RBAC", fields: { label: { label: "Nombre visible" }, name: { label: "Nombre de API", - help: "Nombre técnico único del rol (p. ej. admin, editor, viewer)." + help: "Nombre técnico único del puesto (p. ej. admin, editor, viewer)." }, description: { label: "Descripción" @@ -32,7 +32,7 @@ export const esESObjects: NonNullable = { label: "Activo" }, is_default: { - label: "Rol predeterminado", + label: "Puesto predeterminado", help: "Se asigna automáticamente a los nuevos usuarios." }, id: { @@ -49,34 +49,34 @@ export const esESObjects: NonNullable = { active: { label: "Activo" }, - default_roles: { + default_positions: { label: "Predeterminado" }, custom: { label: "Personalizado" }, - all_roles: { + all_positions: { label: "Todos" } }, _actions: { - activate_role: { + activate_position: { label: "Activar rol", - successMessage: "Rol activado" + successMessage: "Puesto activado" }, - deactivate_role: { + deactivate_position: { label: "Desactivar rol", - confirmText: "¿Desactivar este rol? Los usuarios con el rol conservan su asignación, pero el rol deja de otorgar permisos hasta que se vuelva a activar.", - successMessage: "Rol desactivado" + confirmText: "¿Desactivar este rol? Los usuarios con el puesto conservan su asignación, pero el puesto deja de otorgar permisos hasta que se vuelva a activar.", + successMessage: "Puesto desactivado" }, - set_default_role: { + set_default_position: { label: "Establecer como predeterminado", - confirmText: "¿Convertir este en el rol predeterminado para los nuevos usuarios? Los usuarios existentes no se ven afectados.", - successMessage: "Rol predeterminado actualizado" + confirmText: "¿Convertir este en el puesto predeterminado para los nuevos usuarios? Los usuarios existentes no se ven afectados.", + successMessage: "Puesto predeterminado actualizado" }, - clone_role: { + clone_position: { label: "Clonar rol", - successMessage: "Rol clonado" + successMessage: "Puesto clonado" } } }, @@ -188,7 +188,7 @@ export const esESObjects: NonNullable = { } } }, - sys_role_permission_set: { + sys_position_permission_set: { label: "Conjunto de permisos de rol", pluralLabel: "Conjuntos de permisos de rol", description: "Vincula un conjunto de permisos a un rol.", @@ -197,9 +197,9 @@ export const esESObjects: NonNullable = { label: "ID de vinculación", help: "UUID de la vinculación rol-conjunto de permisos." }, - role_id: { - label: "Rol", - help: "Clave foránea a sys_role." + position_id: { + label: "Puesto", + help: "Clave foránea a sys_position." }, permission_set_id: { label: "Conjunto de permisos", diff --git a/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts index e905d970e4..6679ac5607 100644 --- a/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts @@ -9,17 +9,17 @@ import type { TranslationData } from '@objectstack/spec/system'; export const jaJPObjects: NonNullable = { - sys_role: { - label: "ロール", - pluralLabel: "ロール", - description: "RBAC アクセス制御のためのロール定義", + sys_position: { + label: "ポジション", + pluralLabel: "ポジション", + description: "RBAC アクセス制御のためのポジション定義", fields: { label: { label: "表示名" }, name: { label: "API 名", - help: "ロールの一意の機械名(例: admin、editor、viewer)" + help: "ポジションの一意の機械名(例: admin、editor、viewer)" }, description: { label: "説明" @@ -32,11 +32,11 @@ export const jaJPObjects: NonNullable = { label: "有効" }, is_default: { - label: "デフォルトロール", + label: "デフォルトポジション", help: "新規ユーザーに自動的に割り当てられます" }, id: { - label: "ロール ID" + label: "ポジション ID" }, created_at: { label: "作成日時" @@ -49,34 +49,34 @@ export const jaJPObjects: NonNullable = { active: { label: "有効" }, - default_roles: { + default_positions: { label: "デフォルト" }, custom: { label: "カスタム" }, - all_roles: { + all_positions: { label: "すべて" } }, _actions: { - activate_role: { - label: "ロールを有効化", - successMessage: "ロールが有効化されました" + activate_position: { + label: "ポジションを有効化", + successMessage: "ポジションが有効化されました" }, - deactivate_role: { - label: "ロールを無効化", - confirmText: "このロールを無効化しますか?このロールを持つユーザーの割り当ては維持されますが、再度有効化するまで権限の付与は停止されます。", - successMessage: "ロールが無効化されました" + deactivate_position: { + label: "ポジションを無効化", + confirmText: "このポジションを無効化しますか?このポジションを持つユーザーの割り当ては維持されますが、再度有効化するまで権限の付与は停止されます。", + successMessage: "ポジションが無効化されました" }, - set_default_role: { + set_default_position: { label: "デフォルトに設定", - confirmText: "このロールを新規ユーザーのデフォルトロールにしますか?既存のユーザーには影響しません。", - successMessage: "デフォルトロールを更新しました" + confirmText: "このポジションを新規ユーザーのデフォルトポジションにしますか?既存のユーザーには影響しません。", + successMessage: "デフォルトポジションを更新しました" }, - clone_role: { - label: "ロールを複製", - successMessage: "ロールを複製しました" + clone_position: { + label: "ポジションを複製", + successMessage: "ポジションを複製しました" } } }, @@ -188,18 +188,18 @@ export const jaJPObjects: NonNullable = { } } }, - sys_role_permission_set: { - label: "ロール権限セット", - pluralLabel: "ロール権限セット", - description: "権限セットをロールにバインドします。", + sys_position_permission_set: { + label: "ポジション権限セット", + pluralLabel: "ポジション権限セット", + description: "権限セットをポジションにバインドします。", fields: { id: { label: "バインド ID", - help: "ロール権限セットバインドの UUID。" + help: "ポジション権限セットバインドの UUID。" }, - role_id: { - label: "ロール", - help: "sys_role への外部キー。" + position_id: { + label: "ポジション", + help: "sys_position への外部キー。" }, permission_set_id: { label: "権限セット", diff --git a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts index d27ef37e66..ba968e5ebd 100644 --- a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts @@ -9,17 +9,17 @@ import type { TranslationData } from '@objectstack/spec/system'; export const zhCNObjects: NonNullable = { - sys_role: { - label: "角色", - pluralLabel: "角色", - description: "用于 RBAC 访问控制的角色定义", + sys_position: { + label: "岗位", + pluralLabel: "岗位", + description: "用于 RBAC 访问控制的岗位定义", fields: { label: { label: "显示名称" }, name: { label: "API 名称", - help: "角色的唯一机器名称(例如 admin、editor、viewer)" + help: "岗位的唯一机器名称(例如 admin、editor、viewer)" }, description: { label: "描述" @@ -32,11 +32,11 @@ export const zhCNObjects: NonNullable = { label: "启用" }, is_default: { - label: "默认角色", + label: "默认岗位", help: "自动分配给新用户" }, id: { - label: "角色 ID" + label: "岗位 ID" }, created_at: { label: "创建时间" @@ -49,34 +49,34 @@ export const zhCNObjects: NonNullable = { active: { label: "启用" }, - default_roles: { + default_positions: { label: "默认" }, custom: { label: "自定义" }, - all_roles: { + all_positions: { label: "全部" } }, _actions: { - activate_role: { - label: "激活角色", - successMessage: "角色已激活" + activate_position: { + label: "激活岗位", + successMessage: "岗位已激活" }, - deactivate_role: { - label: "停用角色", - confirmText: "确定要停用此角色吗?拥有该角色的用户仍保留其分配,但在重新激活之前该角色将不再授予权限。", - successMessage: "角色已停用" + deactivate_position: { + label: "停用岗位", + confirmText: "确定要停用此岗位吗?拥有该岗位的用户仍保留其分配,但在重新激活之前该岗位将不再授予权限。", + successMessage: "岗位已停用" }, - set_default_role: { + set_default_position: { label: "设为默认", - confirmText: "将此角色设为新用户的默认角色吗?现有用户不受影响。", - successMessage: "已更新默认角色" + confirmText: "将此岗位设为新用户的默认岗位吗?现有用户不受影响。", + successMessage: "已更新默认岗位" }, - clone_role: { - label: "克隆角色", - successMessage: "已克隆角色" + clone_position: { + label: "克隆岗位", + successMessage: "已克隆岗位" } } }, @@ -188,18 +188,18 @@ export const zhCNObjects: NonNullable = { } } }, - sys_role_permission_set: { - label: "角色权限集", - pluralLabel: "角色权限集", - description: "将权限集绑定到角色。", + sys_position_permission_set: { + label: "岗位权限集", + pluralLabel: "岗位权限集", + description: "将权限集绑定到岗位。", fields: { id: { label: "绑定 ID", - help: "角色-权限集绑定记录的 UUID。" + help: "岗位-权限集绑定记录的 UUID。" }, - role_id: { - label: "角色", - help: "指向 sys_role 的外键。" + position_id: { + label: "岗位", + help: "指向 sys_position 的外键。" }, permission_set_id: { label: "权限集", diff --git a/packages/plugins/plugin-sharing/CHANGELOG.md b/packages/plugins/plugin-sharing/CHANGELOG.md index 1d51ba8047..1c3ced0026 100644 --- a/packages/plugins/plugin-sharing/CHANGELOG.md +++ b/packages/plugins/plugin-sharing/CHANGELOG.md @@ -373,8 +373,8 @@ ADR-0057 — ERP authorization core. Adds permission-grant access DEPTH (`own`/`own_and_reports`/`unit`/`unit_and_below`/`org`), renames `sys_department` → `sys_business_unit` (no aliases — see BREAKING above), introduces the platform-owned - `sys_user_role` assignment, and seeds stack-declared `roles`/`sharingRules` into - `sys_role`/`sys_sharing_rule` at boot (closes #2077). Hierarchy-relative scopes are + `sys_user_position` assignment, and seeds stack-declared `roles`/`sharingRules` into + `sys_position`/`sys_sharing_rule` at boot (closes #2077). Hierarchy-relative scopes are delegated to a pluggable `IHierarchyScopeResolver` (open edition fails closed to owner-only; `defineStack` errors without `requires: ['hierarchy-security']`). Also fixes a latent over-grant where `engine.find({ filter })` was ignored (driver reads @@ -455,13 +455,13 @@ ### Minor Changes -- 2365d07: feat(sharing): configurable role-hierarchy widening — `role_and_subordinates` recipient (ADR-0056 D6) +- 2365d07: feat(sharing): configurable role-hierarchy widening — `unit_and_subordinates` recipient (ADR-0056 D6) Role-hierarchy access widening ("a manager sees records shared with their team") is now **implemented and configurable per sharing rule**, not a hardcoded no-op. The - `role_and_subordinates` recipient (declarable on `sys_sharing_rule.recipient_type`) expands, + `unit_and_subordinates` recipient (declarable on `sys_sharing_rule.recipient_type`) expands, at evaluation time, to the named role **plus every subordinate role** by walking the - `sys_role.parent` hierarchy via a new `RoleGraphService` (mirroring the department/team + `sys_position.parent` hierarchy via a new `PositionGraphService` (mirroring the department/team graphs; cycle-safe). Previously `Role.parent` was declared but never consumed — a silent no-op flagged by the ADR-0056 audit. This is the Salesforce "grant access using hierarchies" model expressed declaratively: each rule chooses whether to roll up the hierarchy. Unit-proven @@ -805,8 +805,8 @@ into the two capability plugins that already register and operate them, split by concern (the two are orthogonal — sharing objects never reference RBAC objects): - - **`@objectstack/plugin-security`** (RBAC) gains `sys_role`, - `sys_permission_set`, `sys_user_permission_set`, `sys_role_permission_set`, + - **`@objectstack/plugin-security`** (RBAC) gains `sys_position`, + `sys_permission_set`, `sys_user_permission_set`, `sys_position_permission_set`, and the `defaultPermissionSets` seed (which its `bootstrap-platform-admin` already consumes). The RBAC + default-permission-set tests move with them. - **`@objectstack/plugin-sharing`** gains `sys_record_share`, @@ -844,8 +844,8 @@ `i18n.loadTranslations` (the i18n service is optional — load is best-effort). - `plugin-webhooks` ← `sys_webhook`, `sys_webhook_delivery` - `plugin-approvals` ← `sys_approval_request`, `sys_approval_action` - - `plugin-security` ← `sys_role`, `sys_permission_set`, - `sys_user_permission_set`, `sys_role_permission_set` + - `plugin-security` ← `sys_position`, `sys_permission_set`, + `sys_user_permission_set`, `sys_position_permission_set` - `plugin-sharing` ← `sys_record_share`, `sys_sharing_rule`, `sys_share_link` - `@objectstack/platform-objects` translation bundles are regenerated to drop those objects' keys (its extract config already excluded them); all other diff --git a/packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts b/packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts index 457091c90b..18f8c6e677 100644 --- a/packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts +++ b/packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts @@ -27,7 +27,7 @@ import type { SharingRuleService } from './sharing-rule-service.js'; import type { SharingRuleRecipientType, ShareAccessLevel } from '@objectstack/spec/contracts'; import { compileCelToFilter } from '@objectstack/formula'; -const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; type Logger = { info?: (m: string, meta?: any) => void; warn?: (m: string, meta?: any) => void }; @@ -35,8 +35,7 @@ type Logger = { info?: (m: string, meta?: any) => void; warn?: (m: string, meta? function mapRecipientType(t: unknown): SharingRuleRecipientType | null { switch (t) { case 'user': return 'user'; - case 'role': return 'role'; - case 'role_and_subordinates': return 'role_and_subordinates'; + case 'position': return 'position'; // ADR-0057 D5: business-unit subtree recipient. case 'business_unit': return 'business_unit' as SharingRuleRecipientType; case 'unit_and_subordinates': return 'unit_and_subordinates' as SharingRuleRecipientType; diff --git a/packages/plugins/plugin-sharing/src/business-unit-graph.ts b/packages/plugins/plugin-sharing/src/business-unit-graph.ts index b590674ce0..e5fac402fa 100644 --- a/packages/plugins/plugin-sharing/src/business-unit-graph.ts +++ b/packages/plugins/plugin-sharing/src/business-unit-graph.ts @@ -4,7 +4,7 @@ import type { IBusinessUnitGraphService } from '@objectstack/spec/contracts'; import type { SharingEngine } from './sharing-service.js'; import { TeamGraphService } from './team-graph.js'; -const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; type DeptCache = { descendants?: Map; diff --git a/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts index c771bb3a3d..2a90d536f3 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts @@ -135,7 +135,7 @@ export const SysRecordShare = ObjectSchema.create({ // ── Recipient (who receives access) ────────────────────────── recipient_type: Field.select( - ['user', 'group', 'role', 'role_and_subordinates', 'guest'], + ['user', 'group', 'role', 'unit_and_subordinates', 'guest'], { label: 'Recipient Type', required: true, diff --git a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts index b1f38f81f2..11ecf5d9f8 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts @@ -132,12 +132,12 @@ export const SysSharingRule = ObjectSchema.create({ }), recipient_type: Field.select( - ['user', 'team', 'business_unit', 'role', 'role_and_subordinates', 'queue'], + ['user', 'team', 'business_unit', 'role', 'unit_and_subordinates', 'queue'], { label: 'Recipient Type', required: true, defaultValue: 'business_unit', - description: 'Kind of principal that receives access — expanded to user grants at evaluation time. `department` walks the parent_business_unit_id tree; `team` is flat (better-auth); `role` is the role\'s direct members; `role_and_subordinates` walks the sys_role.parent hierarchy to also include every subordinate role (ADR-0056 D6).', + description: 'Kind of principal that receives access — expanded to user grants at evaluation time. `department` walks the parent_business_unit_id tree; `team` is flat (better-auth); `role` is the role\'s direct members; `unit_and_subordinates` walks the sys_position.parent hierarchy to also include every subordinate role (ADR-0056 D6).', group: 'Recipient', }, ), diff --git a/packages/plugins/plugin-sharing/src/position-graph.test.ts b/packages/plugins/plugin-sharing/src/position-graph.test.ts new file mode 100644 index 0000000000..5837f349de --- /dev/null +++ b/packages/plugins/plugin-sharing/src/position-graph.test.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// ADR-0090 D3 — positions are FLAT: expansion resolves holders from the +// platform assignment table ∪ the better-auth membership transition source. +// The former hierarchy walk (descendant positions) was retired with the tree. + +import { describe, it, expect } from 'vitest'; +import { PositionGraphService } from './position-graph.js'; + +// Minimal engine: resolves find('sys_user_position') and find('sys_member'). +function makeEngine( + assignments: Array<{ position: string; user_id: string; organization_id?: string | null }>, + members: Array<{ role: string; user_id: string }>, +) { + return { + async find(object: string, options: any) { + const f = options?.filter ?? options?.where ?? {}; + if (object === 'sys_user_position') { + return assignments.filter( + (a) => + (f.position === undefined || a.position === f.position) && + (f.organization_id === undefined || a.organization_id === f.organization_id), + ); + } + if (object === 'sys_member') return members.filter((m) => f.role === undefined || m.role === f.role); + return []; + }, + } as any; +} + +const ASSIGNMENTS = [ + { position: 'sales_manager', user_id: 'u_mgr' }, + { position: 'sales_rep', user_id: 'u_rep1' }, + { position: 'sales_rep', user_id: 'u_rep2' }, +]; +const MEMBERS = [ + { role: 'sales_rep', user_id: 'u_legacy_rep' }, // better-auth transition source + { role: 'exec', user_id: 'u_exec' }, +]; + +describe('PositionGraphService (ADR-0090 D3 — flat expansion)', () => { + it('expands holders from the platform assignment table', async () => { + const g = new PositionGraphService({ engine: makeEngine(ASSIGNMENTS, []) }); + expect((await g.expandPositionUsers('sales_rep')).sort()).toEqual(['u_rep1', 'u_rep2']); + expect(await g.expandPositionUsers('sales_manager')).toEqual(['u_mgr']); + }); + + it('unions the better-auth membership transition source (no duplicates)', async () => { + const g = new PositionGraphService({ + engine: makeEngine([...ASSIGNMENTS, { position: 'sales_rep', user_id: 'u_legacy_rep' }], MEMBERS), + }); + const users = (await g.expandPositionUsers('sales_rep')).sort(); + expect(users).toEqual(['u_legacy_rep', 'u_rep1', 'u_rep2']); + }); + + it('membership-only holders still resolve (transition window)', async () => { + const g = new PositionGraphService({ engine: makeEngine([], MEMBERS) }); + expect(await g.expandPositionUsers('exec')).toEqual(['u_exec']); + }); + + it('unknown position / empty name → empty', async () => { + const g = new PositionGraphService({ engine: makeEngine(ASSIGNMENTS, MEMBERS) }); + expect(await g.expandPositionUsers('nope')).toEqual([]); + expect(await g.expandPositionUsers('')).toEqual([]); + }); + + it('caches per (org, position) within a pass', async () => { + let calls = 0; + const engine = { + async find(object: string, options: any) { + calls++; + if (object === 'sys_user_position') return [{ position: 'p', user_id: 'u1' }]; + return []; + }, + } as any; + const g = new PositionGraphService({ engine }); + await g.expandPositionUsers('p'); + const before = calls; + await g.expandPositionUsers('p'); + expect(calls).toBe(before); // second hit served from cache + }); +}); diff --git a/packages/plugins/plugin-sharing/src/position-graph.ts b/packages/plugins/plugin-sharing/src/position-graph.ts new file mode 100644 index 0000000000..91db535b4b --- /dev/null +++ b/packages/plugins/plugin-sharing/src/position-graph.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SharingEngine } from './sharing-service.js'; +import { TeamGraphService } from './team-graph.js'; + +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; + +type PositionCache = { + expand?: Map; +}; + +export interface PositionGraphOptions { + engine: SharingEngine; + /** Optional tenant scope; null means cross-tenant lookups. */ + organizationId?: string | null; + /** Optional shared cache across one evaluator pass. */ + cache?: PositionCache; + /** Reused for the better-auth membership expansion (sys_member.role). */ + teamGraph?: TeamGraphService; +} + +/** + * Position expansion (ADR-0090 D3). + * + * Positions are FLAT capability-distribution groups — there is no hierarchy + * to walk (the org tree lives on `sys_business_unit`; the former + * position-parent walk queried a column that never existed, ADR-0057 D5). + * The one job left here is resolving "who holds position P": + * + * 1. `sys_user_position` — the platform-owned source of truth + * (ADR-0057 D4), keyed by the position's machine name; + * 2. ∪ `sys_member.role` — the better-auth membership string, kept as a + * transition source (ADR-0057 D4 addendum) via {@link TeamGraphService}. + * + * All lookups elevate to a system context (assignments are platform + * metadata); callers own their own authorization. + */ +export class PositionGraphService { + private readonly engine: SharingEngine; + private readonly organizationId: string | null; + private readonly cache: PositionCache; + private readonly teamGraph: TeamGraphService; + + constructor(opts: PositionGraphOptions) { + this.engine = opts.engine; + this.organizationId = opts.organizationId ?? null; + this.cache = opts.cache ?? {}; + this.cache.expand ??= new Map(); + this.teamGraph = + opts.teamGraph ?? new TeamGraphService({ engine: this.engine, organizationId: this.organizationId }); + } + + /** Users holding `positionName` (assignment table ∪ membership transition source). */ + async expandPositionUsers(positionName: string, organizationId?: string): Promise { + if (!positionName) return []; + const org = organizationId ?? this.organizationId ?? '*'; + const key = `${org}::${positionName}`; + const cached = this.cache.expand!.get(key); + if (cached) return cached; + + const users = new Set(); + + // 1) Platform assignment table (source of truth). + const filter: Record = { position: positionName }; + const scopeOrg = organizationId ?? this.organizationId; + if (scopeOrg) filter.organization_id = scopeOrg; + try { + const rows = await this.engine.find('sys_user_position', { + filter, + fields: ['user_id'], + limit: 10000, + context: SYSTEM_CTX, + }); + for (const r of (rows ?? []) as any[]) { + const uid = String(r.user_id ?? ''); + if (uid) users.add(uid); + } + } catch { + /* table may not exist on minimal stacks — union source below still applies */ + } + + // 2) better-auth membership string (transition window, ADR-0057 D4). + for (const uid of await this.teamGraph.expandRoleUsers(positionName, scopeOrg ?? undefined)) { + users.add(uid); + } + + const result = Array.from(users); + this.cache.expand!.set(key, result); + return result; + } +} diff --git a/packages/plugins/plugin-sharing/src/primary-bu-projection.ts b/packages/plugins/plugin-sharing/src/primary-bu-projection.ts index a913d9a8ca..e7f2db9a45 100644 --- a/packages/plugins/plugin-sharing/src/primary-bu-projection.ts +++ b/packages/plugins/plugin-sharing/src/primary-bu-projection.ts @@ -23,7 +23,7 @@ * membership is usable single-tenant too). */ -const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; export const PRIMARY_BU_HOOK_PACKAGE = 'plugin-sharing:primary-bu'; diff --git a/packages/plugins/plugin-sharing/src/role-graph.test.ts b/packages/plugins/plugin-sharing/src/role-graph.test.ts deleted file mode 100644 index 53ea8a80f2..0000000000 --- a/packages/plugins/plugin-sharing/src/role-graph.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// ADR-0056 D6 — role-hierarchy graph powering the `role_and_subordinates` recipient. - -import { describe, it, expect } from 'vitest'; -import { RoleGraphService } from './role-graph.js'; - -// Minimal engine: resolves find('sys_role', {parent}) and find('sys_member', {role}). -function makeEngine(roles: Array<{ name: string; parent?: string | null }>, members: Array<{ role: string; user_id: string }>) { - return { - async find(object: string, options: any) { - const f = options?.filter ?? options?.where ?? {}; - if (object === 'sys_role') return roles.filter(r => (f.parent === undefined || r.parent === f.parent)); - if (object === 'sys_member') return members.filter(m => (f.role === undefined || m.role === f.role)); - return []; - }, - } as any; -} - -const ROLES = [ - { name: 'ceo', parent: null }, - { name: 'vp', parent: 'ceo' }, - { name: 'rep', parent: 'vp' }, - { name: 'rep2', parent: 'vp' }, -]; -const MEMBERS = [ - { role: 'ceo', user_id: 'u_ceo' }, - { role: 'vp', user_id: 'u_vp' }, - { role: 'rep', user_id: 'u_rep' }, - { role: 'rep2', user_id: 'u_rep2' }, -]; - -describe('RoleGraphService (ADR-0056 D6)', () => { - it('descendantRoles walks the hierarchy downward (incl. self)', async () => { - const g = new RoleGraphService({ engine: makeEngine(ROLES, MEMBERS) }); - expect((await g.descendantRoles('ceo')).sort()).toEqual(['ceo', 'rep', 'rep2', 'vp']); - expect((await g.descendantRoles('vp')).sort()).toEqual(['rep', 'rep2', 'vp']); - expect(await g.descendantRoles('rep')).toEqual(['rep']); - }); - - it('expandRoleAndSubordinates returns the role + all subordinate users', async () => { - const g = new RoleGraphService({ engine: makeEngine(ROLES, MEMBERS) }); - expect((await g.expandRoleAndSubordinates('ceo')).sort()).toEqual(['u_ceo', 'u_rep', 'u_rep2', 'u_vp']); - expect((await g.expandRoleAndSubordinates('vp')).sort()).toEqual(['u_rep', 'u_rep2', 'u_vp']); - expect(await g.expandRoleAndSubordinates('rep')).toEqual(['u_rep']); - }); - - it('is cycle-safe (A↔B parent loop terminates)', async () => { - const cyclic = [{ name: 'a', parent: 'b' }, { name: 'b', parent: 'a' }]; - const g = new RoleGraphService({ engine: makeEngine(cyclic, [{ role: 'a', user_id: 'ua' }, { role: 'b', user_id: 'ub' }]) }); - const d = (await g.descendantRoles('a')).sort(); - expect(d).toEqual(['a', 'b']); - expect((await g.expandRoleAndSubordinates('a')).sort()).toEqual(['ua', 'ub']); - }); - - it('unknown role → empty', async () => { - const g = new RoleGraphService({ engine: makeEngine(ROLES, MEMBERS) }); - expect(await g.expandRoleAndSubordinates('nope')).toEqual([]); - expect(await g.expandRoleAndSubordinates('')).toEqual([]); - }); -}); diff --git a/packages/plugins/plugin-sharing/src/role-graph.ts b/packages/plugins/plugin-sharing/src/role-graph.ts deleted file mode 100644 index 3ece0b6665..0000000000 --- a/packages/plugins/plugin-sharing/src/role-graph.ts +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SharingEngine } from './sharing-service.js'; -import { TeamGraphService } from './team-graph.js'; - -const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; - -type RoleCache = { - descendants?: Map; - expand?: Map; -}; - -export interface RoleGraphOptions { - engine: SharingEngine; - /** Optional tenant scope; null means cross-tenant lookups. */ - organizationId?: string | null; - /** Optional shared cache across one evaluator pass. */ - cache?: RoleCache; - /** Reused for role → direct-member-user expansion (sys_member.role). */ - teamGraph?: TeamGraphService; -} - -/** - * Role hierarchy graph (ADR-0056 D6). - * - * Walks `sys_role.parent` to resolve a role's SUBORDINATE roles, powering the - * declarative `role_and_subordinates` sharing-rule recipient — Salesforce-style - * "grant access using the role hierarchy", expressed per sharing rule rather - * than hardcoded. A role's `parent` is its manager role, so the subordinates of - * `R` are every role whose ancestor chain passes through `R`. - * - * All lookups elevate to a system context (the hierarchy is platform metadata); - * callers own their own authorization. Cycles are guarded by a visited set. - */ -export class RoleGraphService { - private readonly engine: SharingEngine; - private readonly organizationId: string | null; - private readonly cache: RoleCache; - private readonly teamGraph: TeamGraphService; - - constructor(opts: RoleGraphOptions) { - this.engine = opts.engine; - this.organizationId = opts.organizationId ?? null; - this.cache = opts.cache ?? {}; - this.cache.descendants ??= new Map(); - this.cache.expand ??= new Map(); - this.teamGraph = - opts.teamGraph ?? new TeamGraphService({ engine: this.engine, organizationId: this.organizationId }); - } - - /** Direct child roles of `roleName` (`sys_role.parent === roleName`). */ - private async childRoles(roleName: string): Promise { - const filter: Record = { parent: roleName }; - if (this.organizationId) filter.organization_id = this.organizationId; - let rows: any[] = []; - try { - rows = await this.engine.find('sys_role', { - filter, - fields: ['name'], - limit: 5000, - context: SYSTEM_CTX, - }); - } catch { - rows = []; - } - return Array.from(new Set((rows ?? []).map((r: any) => String(r.name ?? '')).filter(Boolean))); - } - - /** `roleName` plus every role beneath it in the hierarchy (BFS, cycle-safe). */ - async descendantRoles(roleName: string): Promise { - if (!roleName) return []; - const cached = this.cache.descendants!.get(roleName); - if (cached) return cached; - const out: string[] = []; - const seen = new Set(); - const queue: string[] = [roleName]; - while (queue.length) { - const r = queue.shift()!; - if (seen.has(r)) continue; - seen.add(r); - out.push(r); - for (const child of await this.childRoles(r)) { - if (!seen.has(child)) queue.push(child); - } - } - this.cache.descendants!.set(roleName, out); - return out; - } - - /** Users holding `roleName` OR any subordinate role (the `role_and_subordinates` set). */ - async expandRoleAndSubordinates(roleName: string, organizationId?: string): Promise { - if (!roleName) return []; - const org = organizationId ?? this.organizationId ?? '*'; - const key = `${org}::${roleName}`; - const cached = this.cache.expand!.get(key); - if (cached) return cached; - const roles = await this.descendantRoles(roleName); - const users = new Set(); - for (const role of roles) { - for (const uid of await this.teamGraph.expandRoleUsers(role, organizationId ?? this.organizationId ?? undefined)) { - users.add(uid); - } - } - const result = Array.from(users); - this.cache.expand!.set(key, result); - return result; - } -} diff --git a/packages/plugins/plugin-sharing/src/rule-hooks.ts b/packages/plugins/plugin-sharing/src/rule-hooks.ts index 3d1e66a8f4..2c8e916020 100644 --- a/packages/plugins/plugin-sharing/src/rule-hooks.ts +++ b/packages/plugins/plugin-sharing/src/rule-hooks.ts @@ -3,7 +3,7 @@ import type { SharingRuleService } from './sharing-rule-service.js'; import type { SharingRuleRow } from '@objectstack/spec/contracts'; -const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; export const SHARING_RULE_HOOK_PACKAGE = 'plugin-sharing:rules'; diff --git a/packages/plugins/plugin-sharing/src/share-link-routes.ts b/packages/plugins/plugin-sharing/src/share-link-routes.ts index dc70006fd2..237fa69b42 100644 --- a/packages/plugins/plugin-sharing/src/share-link-routes.ts +++ b/packages/plugins/plugin-sharing/src/share-link-routes.ts @@ -26,7 +26,7 @@ import type { ShareLinkExecutionContext } from '@objectstack/spec/contracts'; import type { ShareLinkService } from './share-link-service.js'; import type { SharingEngine } from './sharing-service.js'; -const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; export interface ShareLinkRoutesOptions { basePath?: string; @@ -246,7 +246,7 @@ export function registerShareLinkRoutes( sendError(res, 400, 'UNSUPPORTED', 'This share link does not expose messages'); return; } - const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; + const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; const rows = await engine.find('ai_messages', { where: { conversation_id: resolved.link.record_id }, sort: [{ field: 'created_at', order: 'asc' }], diff --git a/packages/plugins/plugin-sharing/src/share-link-service.ts b/packages/plugins/plugin-sharing/src/share-link-service.ts index cf35b87acd..9d002cd759 100644 --- a/packages/plugins/plugin-sharing/src/share-link-service.ts +++ b/packages/plugins/plugin-sharing/src/share-link-service.ts @@ -13,7 +13,7 @@ import type { import type { SharingEngine } from './sharing-service.js'; /** Service-elevated context for the plugin's own queries / mutations. */ -const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; /** URL-safe alphabet (RFC 4648 base64url minus padding). 64 symbols. */ const TOKEN_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; diff --git a/packages/plugins/plugin-sharing/src/sharing-plugin.ts b/packages/plugins/plugin-sharing/src/sharing-plugin.ts index 962e1757d0..d1add7eb18 100644 --- a/packages/plugins/plugin-sharing/src/sharing-plugin.ts +++ b/packages/plugins/plugin-sharing/src/sharing-plugin.ts @@ -45,7 +45,7 @@ export interface SharingPluginOptions { * - `sharingModel: 'private'` → reads filtered to `(owner_id == me) OR * (record explicitly shared with me)`. Writes require ownership or * an `edit`/`full` share. - * - `sharingModel: 'read'` → reads unrestricted; writes gated as + * - `sharingModel: 'public_read'` → reads unrestricted; writes gated as * above (typical "everyone can see, only owner can edit"). * - any other value (or no value) → no enforcement. This keeps * existing CRM behaviour identical until admins explicitly enable diff --git a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts index e39f88c305..fdd9665c3c 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts @@ -12,10 +12,10 @@ import type { import type { SharingEngine } from './sharing-service.js'; import type { SharingService } from './sharing-service.js'; import { TeamGraphService } from './team-graph.js'; -import { RoleGraphService } from './role-graph.js'; +import { PositionGraphService } from './position-graph.js'; import { BusinessUnitGraphService } from './business-unit-graph.js'; -const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; function uid(prefix: string): string { const g: any = globalThis as any; @@ -266,16 +266,28 @@ export class SharingRuleService implements ISharingRuleService { }); return dept.expandUsers(rule.recipient_id); } - if (rule.recipient_type === 'role') return team.expandRoleUsers(rule.recipient_id, rule.organization_id ?? undefined); - if (rule.recipient_type === 'role_and_subordinates') { - // ADR-0056 D6 — declarative role-hierarchy widening: this role + every - // subordinate role's users (configured per sharing rule, not hardcoded). - const roleGraph = new RoleGraphService({ + if (rule.recipient_type === 'position') { + // ADR-0090 D3 — positions are flat; expand holders via the platform + // assignment table (source of truth, ADR-0057 D4) ∪ the better-auth + // membership string (transition window). + const positionGraph = new PositionGraphService({ engine: this.engine, organizationId: rule.organization_id ?? null, teamGraph: team, }); - return roleGraph.expandRoleAndSubordinates(rule.recipient_id, rule.organization_id ?? undefined); + return positionGraph.expandPositionUsers(rule.recipient_id, rule.organization_id ?? undefined); + } + if (rule.recipient_type === 'unit_and_subordinates') { + // ADR-0057 D5 (finalized by ADR-0090 D3) — hierarchy widening is + // re-homed onto the BUSINESS-UNIT subtree: the unit named by + // `recipient_id` plus every descendant unit's members. The former + // position-tree walk queried a `parent` column that never existed. + const dept = new BusinessUnitGraphService({ + engine: this.engine, + organizationId: rule.organization_id ?? null, + teamGraph: team, + }); + return dept.expandUsers(rule.recipient_id); } // queue — v1 stores literal; treat as no-op until queue impl lands. return []; diff --git a/packages/plugins/plugin-sharing/src/sharing-service.test.ts b/packages/plugins/plugin-sharing/src/sharing-service.test.ts index dbba8ea9bf..d1a5569323 100644 --- a/packages/plugins/plugin-sharing/src/sharing-service.test.ts +++ b/packages/plugins/plugin-sharing/src/sharing-service.test.ts @@ -38,6 +38,7 @@ function makeFakeEngine(schemas: Record) { return { _tables: tables, + schemas, getSchema(name: string) { return schemas[name]; }, async find(object: string, options?: any) { const table = ensure(object); @@ -90,13 +91,31 @@ const ACCOUNT_SCHEMA = { const LEAD_SCHEMA = { name: 'lead', - sharingModel: 'read', + sharingModel: 'public_read', fields: { id: {}, name: {}, owner_id: {} }, }; -const PUBLIC_SCHEMA = { +// [ADR-0090 D1] A CUSTOM object with NO sharingModel + an owner field — +// the objectui#2348 leave_request incident shape. Under the secure default +// this now resolves PRIVATE (owner-scoped), never silently public. +const UNSET_OWD_SCHEMA = { name: 'task', - // no sharingModel — treated as public + // no sharingModel — custom object ⇒ defaults to private (ADR-0090 D1) + fields: { id: {}, name: {}, owner_id: {} }, +}; + +// A SYSTEM object with no sharingModel keeps the public fall-through +// (explicit ADR-0066 posture + bypass lists own the sys_* surface). +// Explicitly-public custom object (post-ADR-0090 D1 the only way to be public). +const EXPLICIT_PUBLIC_SCHEMA = { + name: 'task', + sharingModel: 'public_read_write', + fields: { id: {}, name: {}, owner_id: {} }, +}; + +const SYS_UNSET_OWD_SCHEMA = { + name: 'sys_widget', + isSystem: true, fields: { id: {}, name: {}, owner_id: {} }, }; @@ -128,7 +147,8 @@ describe('SharingService.buildReadFilter', () => { engine = makeFakeEngine({ account: ACCOUNT_SCHEMA, lead: LEAD_SCHEMA, - task: PUBLIC_SCHEMA, + task: UNSET_OWD_SCHEMA, + sys_widget: SYS_UNSET_OWD_SCHEMA, note: ORPHAN_SCHEMA, kbarticle: CANON_PUBLIC_READ_SCHEMA, whiteboard: CANON_PUBLIC_RW_SCHEMA, @@ -145,8 +165,21 @@ describe('SharingService.buildReadFilter', () => { expect(await svc.buildReadFilter('sys_user', { userId: 'u1' })).toBeNull(); }); - it('returns null for public objects', async () => { - expect(await svc.buildReadFilter('task', { userId: 'u1' })).toBeNull(); + it('[ADR-0090 D1] custom object with UNSET sharingModel defaults to PRIVATE (the objectui#2348 incident regression)', async () => { + // 张三 (u1) must NOT read 李四's records on an OWD-less custom object: + // the read filter narrows to owner_id = u1 instead of passing null. + const f = await svc.buildReadFilter('task', { userId: 'u1' }); + expect(f).toEqual({ owner_id: 'u1' }); + }); + + it('[ADR-0090 D1] system object with unset sharingModel keeps the public fall-through', async () => { + expect(await svc.buildReadFilter('sys_widget', { userId: 'u1' })).toBeNull(); + }); + + it('[ADR-0090 D4] an unrecognised stored sharingModel fails CLOSED to private', async () => { + engine.schemas.legacy = { name: 'legacy', sharingModel: 'read_write', fields: { id: {}, owner_id: {} } } as any; + const f = await svc.buildReadFilter('legacy', { userId: 'u1' }); + expect(f).toEqual({ owner_id: 'u1' }); }); it('returns null for read-only-sharing objects (writes are gated, reads are not)', async () => { @@ -192,7 +225,7 @@ describe('SharingService.canEdit', () => { engine = makeFakeEngine({ account: ACCOUNT_SCHEMA, lead: LEAD_SCHEMA, - task: PUBLIC_SCHEMA, + task: EXPLICIT_PUBLIC_SCHEMA, kbarticle: CANON_PUBLIC_READ_SCHEMA, whiteboard: CANON_PUBLIC_RW_SCHEMA, sys_record_share: { name: 'sys_record_share' }, @@ -316,7 +349,7 @@ describe('buildSharingMiddleware (engine integration)', () => { engine = makeFakeEngine({ account: ACCOUNT_SCHEMA, lead: LEAD_SCHEMA, - task: PUBLIC_SCHEMA, + task: EXPLICIT_PUBLIC_SCHEMA, sys_record_share: {}, }); svc = new SharingService({ engine }); diff --git a/packages/plugins/plugin-sharing/src/sharing-service.ts b/packages/plugins/plugin-sharing/src/sharing-service.ts index dfe8d1e2ec..36c8f5c0ba 100644 --- a/packages/plugins/plugin-sharing/src/sharing-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-service.ts @@ -34,7 +34,7 @@ function makeShareId(): string { } /** System-elevated context for the plugin's own queries / mutations. */ -const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; /** * Owner field convention. Hard-coded to `owner_id` for MVP — the @@ -47,21 +47,34 @@ const OWNER_FIELD = 'owner_id'; /** * Effective sharing model — collapses the authorable OWD vocabulary onto the - * three behaviours this service enforces (ADR-0056 D1): - * - `private` → owner-only read + write - * - `public_read` / legacy `read` → everyone reads, owner writes - * - everything else → public (no record-level filter) + * three behaviours this service enforces: + * - `private` → owner-only read + write + * - `public_read` → everyone reads, owner writes + * - `public_read_write` → public (no record-level filter) + * - `controlled_by_parent` → public here (scoped separately by the + * security plugin's master-detail path, ADR-0055) * - * "Everything else" covers the canonical `public_read_write`, the legacy - * `read_write` / `full` aliases, `controlled_by_parent` (scoped separately by - * the security plugin), and objects that declare no `sharingModel` at all — so - * existing behaviour is preserved until an admin opts an object in. + * [ADR-0090 D1] Secure default: a CUSTOM object (not `sys_*`, not + * `isSystem`) that declares NO `sharingModel` resolves to **`private`** — + * the former fall-through-to-public default silently granted org-wide + * read/write to any C/R/U grant holder (the objectui#2348 incident). + * System/platform objects keep their explicit ADR-0066 posture and the + * bypass list; an unset model on them stays public as before. + * + * [ADR-0090 D4] The legacy aliases (`read`/`read_write`/`full`) no longer + * parse at authoring. A stored value this function does not recognise + * fails CLOSED to `private` (never silently public). */ function effectiveSharingModel(schema: any): 'private' | 'read' | 'public' { const m = schema?.sharingModel ?? schema?.security?.sharingModel; if (m === 'private') return 'private'; - if (m === 'read' || m === 'public_read') return 'read'; - return 'public'; + if (m === 'public_read') return 'read'; + if (m === 'public_read_write' || m === 'controlled_by_parent') return 'public'; + if (m == null) { + const isSystem = schema?.isSystem === true || String(schema?.name ?? '').startsWith('sys_'); + return isSystem ? 'public' : 'private'; + } + return 'private'; } function hasOwnerField(schema: any): boolean { @@ -100,10 +113,10 @@ export class SharingService implements ISharingService { 'sys_user', 'sys_organization', 'sys_member', - 'sys_role', + 'sys_position', 'sys_permission_set', 'sys_user_permission_set', - 'sys_role_permission_set', + 'sys_position_permission_set', ...(options.bypassObjects ?? []), ]); } diff --git a/packages/plugins/plugin-sharing/src/team-graph.ts b/packages/plugins/plugin-sharing/src/team-graph.ts index 8552c264b5..9d146ee8f4 100644 --- a/packages/plugins/plugin-sharing/src/team-graph.ts +++ b/packages/plugins/plugin-sharing/src/team-graph.ts @@ -3,7 +3,7 @@ import type { ITeamGraphService } from '@objectstack/spec/contracts'; import type { SharingEngine } from './sharing-service.js'; -const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; type Cache = { expandUsers?: Map; diff --git a/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts index b733efe49b..8d76c3b882 100644 --- a/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts @@ -32,7 +32,7 @@ export const enObjects: NonNullable = { user: "user", group: "group", role: "role", - role_and_subordinates: "role_and_subordinates", + unit_and_subordinates: "unit_and_subordinates", guest: "guest" } }, diff --git a/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts index 6806f98771..859323974f 100644 --- a/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts @@ -32,7 +32,7 @@ export const esESObjects: NonNullable = { user: "Usuario", group: "Grupo", role: "Rol", - role_and_subordinates: "Rol y subordinados", + unit_and_subordinates: "Rol y subordinados", guest: "Invitado" } }, diff --git a/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts index 9df0ee3c76..ea54be04c1 100644 --- a/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts @@ -32,7 +32,7 @@ export const jaJPObjects: NonNullable = { user: "ユーザー", group: "グループ", role: "ロール", - role_and_subordinates: "ロールと下位階層", + unit_and_subordinates: "ロールと下位階層", guest: "ゲスト" } }, diff --git a/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts index e4ae853514..f89c455da0 100644 --- a/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts @@ -32,7 +32,7 @@ export const zhCNObjects: NonNullable = { user: "用户", group: "组", role: "角色", - role_and_subordinates: "角色及下级", + unit_and_subordinates: "角色及下级", guest: "访客" } }, diff --git a/packages/plugins/plugin-webhooks/CHANGELOG.md b/packages/plugins/plugin-webhooks/CHANGELOG.md index 2d94b465a5..8023d56379 100644 --- a/packages/plugins/plugin-webhooks/CHANGELOG.md +++ b/packages/plugins/plugin-webhooks/CHANGELOG.md @@ -639,8 +639,8 @@ priority, items }`) and an optional `navigationContributions` field on the `i18n.loadTranslations` (the i18n service is optional — load is best-effort). - `plugin-webhooks` ← `sys_webhook`, `sys_webhook_delivery` - `plugin-approvals` ← `sys_approval_request`, `sys_approval_action` - - `plugin-security` ← `sys_role`, `sys_permission_set`, - `sys_user_permission_set`, `sys_role_permission_set` + - `plugin-security` ← `sys_position`, `sys_permission_set`, + `sys_user_permission_set`, `sys_position_permission_set` - `plugin-sharing` ← `sys_record_share`, `sys_sharing_rule`, `sys_share_link` - `@objectstack/platform-objects` translation bundles are regenerated to drop those objects' keys (its extract config already excluded them); all other diff --git a/packages/rest/CHANGELOG.md b/packages/rest/CHANGELOG.md index 9c8b7cb2d9..5236bdb337 100644 --- a/packages/rest/CHANGELOG.md +++ b/packages/rest/CHANGELOG.md @@ -338,9 +338,9 @@ - **rest**: extend the ADR-0057 D10 server-side visibility gate to **dashboard widgets** — strip widgets whose `requiresService` names an unregistered kernel service (mirrors the existing app-nav gate; `resolveRegisteredServices` now also discovers gates declared on widgets). In a single-tenant runtime this removes the orphan "Organizations" KPI, matching the already-hidden org nav entries. - **service-settings**: add the missing zh `help` strings for the Localization manifest (number/currency/first-day-of-week/fiscal-year fields), and accept the `{ values: { … } }` envelope on `PUT /api/settings/:ns` symmetrically with what `GET` returns. -- aa33b02: fix(security): single-source the request authorization resolver — REST no longer drops sys_user_role +- aa33b02: fix(security): single-source the request authorization resolver — REST no longer drops sys_user_position - The REST server and the runtime dispatcher each carried their own copy of the request → ExecutionContext identity/role resolver, and they drifted on a security path. The REST copy silently omitted `sys_user_role` (so custom roles granted via the ADR-0057 D4 platform-RBAC path did not apply over REST), `sys_role_permission_set`, the `owner→org_owner` membership normalization, the platform-admin derivation, and the `ai_seat` synthesis — fail-closed (legitimate access denied), not an escalation. + The REST server and the runtime dispatcher each carried their own copy of the request → ExecutionContext identity/role resolver, and they drifted on a security path. The REST copy silently omitted `sys_user_position` (so custom roles granted via the ADR-0057 D4 platform-RBAC path did not apply over REST), `sys_position_permission_set`, the `owner→org_owner` membership normalization, the platform-admin derivation, and the `ai_seat` synthesis — fail-closed (legitimate access denied), not an escalation. Both entry points now delegate to a single shared resolver, `resolveAuthzContext` in `@objectstack/core/security` (joining the API-key verifier that already lived there). A contract test locks every authorization source and a lint gate (`check:authz-resolver`) prevents a future duplicate resolver or a dropped delegation. diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index c7ca3f6ba5..b1fb7d105b 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -1360,7 +1360,7 @@ export class RestServer { // shared resolver (`resolveAuthzContext`, @objectstack/core) — the same one // the runtime dispatcher uses, so the REST and dispatcher entry points can // never drift on authorization. (This path previously kept its own copy that - // silently omitted sys_user_role / sys_role_permission_set / platform_admin / + // silently omitted sys_user_position / sys_position_permission_set / platform_admin / // ai_seat — see the resolver's module doc.) const getSession = async (h: any) => { try { return await api.getSession({ headers: h }); } catch { return undefined; } @@ -1394,7 +1394,7 @@ export class RestServer { userId: authz.userId, tenantId: authz.tenantId, email: authz.email, - roles: authz.roles, + positions: authz.positions, permissions: authz.permissions, systemPermissions: authz.systemPermissions, ...(authz.tabPermissions ? { tabPermissions: authz.tabPermissions } : {}), diff --git a/packages/runtime/CHANGELOG.md b/packages/runtime/CHANGELOG.md index d4e7000b5d..f32f83cd26 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -740,9 +740,9 @@ threw: Error: 线索信息不完整…` instead of just `线索信息不完整 orphans, that scope can be dropped from the selector. Pairs with the kernel's `writable_package_required` (D1) so no NEW orphans are created. -- aa33b02: fix(security): single-source the request authorization resolver — REST no longer drops sys_user_role +- aa33b02: fix(security): single-source the request authorization resolver — REST no longer drops sys_user_position - The REST server and the runtime dispatcher each carried their own copy of the request → ExecutionContext identity/role resolver, and they drifted on a security path. The REST copy silently omitted `sys_user_role` (so custom roles granted via the ADR-0057 D4 platform-RBAC path did not apply over REST), `sys_role_permission_set`, the `owner→org_owner` membership normalization, the platform-admin derivation, and the `ai_seat` synthesis — fail-closed (legitimate access denied), not an escalation. + The REST server and the runtime dispatcher each carried their own copy of the request → ExecutionContext identity/role resolver, and they drifted on a security path. The REST copy silently omitted `sys_user_position` (so custom roles granted via the ADR-0057 D4 platform-RBAC path did not apply over REST), `sys_position_permission_set`, the `owner→org_owner` membership normalization, the platform-admin derivation, and the `ai_seat` synthesis — fail-closed (legitimate access denied), not an escalation. Both entry points now delegate to a single shared resolver, `resolveAuthzContext` in `@objectstack/core/security` (joining the API-key verifier that already lived there). A contract test locks every authorization source and a lint gate (`check:authz-resolver`) prevents a future duplicate resolver or a dropped delegation. @@ -987,8 +987,8 @@ threw: Error: 线索信息不完整…` instead of just `线索信息不完整 ADR-0057 — ERP authorization core. Adds permission-grant access DEPTH (`own`/`own_and_reports`/`unit`/`unit_and_below`/`org`), renames `sys_department` → `sys_business_unit` (no aliases — see BREAKING above), introduces the platform-owned - `sys_user_role` assignment, and seeds stack-declared `roles`/`sharingRules` into - `sys_role`/`sys_sharing_rule` at boot (closes #2077). Hierarchy-relative scopes are + `sys_user_position` assignment, and seeds stack-declared `roles`/`sharingRules` into + `sys_position`/`sys_sharing_rule` at boot (closes #2077). Hierarchy-relative scopes are delegated to a pluggable `IHierarchyScopeResolver` (open edition fails closed to owner-only; `defineStack` errors without `requires: ['hierarchy-security']`). Also fixes a latent over-grant where `engine.find({ filter })` was ignored (driver reads diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 50214ff4fe..47d022cde1 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -1046,7 +1046,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu id: userId, displayName: sessionData?.user?.name ?? sessionData?.user?.email ?? userId, email: sessionData?.user?.email, - roles: [], + positions: [], permissions: [], organizationId: sessionData?.session?.activeOrganizationId, }; diff --git a/packages/runtime/src/http-dispatcher.keys.test.ts b/packages/runtime/src/http-dispatcher.keys.test.ts index bf847366bc..5bdb21767b 100644 --- a/packages/runtime/src/http-dispatcher.keys.test.ts +++ b/packages/runtime/src/http-dispatcher.keys.test.ts @@ -42,7 +42,7 @@ function ctx(overrides: any = {}) { request: { headers: {} }, response: {}, environmentId: undefined, - executionContext: { userId: 'u1', isSystem: false, roles: [], permissions: [] }, + executionContext: { userId: 'u1', isSystem: false, positions: [], permissions: [] }, ...overrides, }; } diff --git a/packages/runtime/src/http-dispatcher.mcp.test.ts b/packages/runtime/src/http-dispatcher.mcp.test.ts index 143e22199a..6b98bba0d6 100644 --- a/packages/runtime/src/http-dispatcher.mcp.test.ts +++ b/packages/runtime/src/http-dispatcher.mcp.test.ts @@ -23,7 +23,7 @@ function makeContext(overrides: any = {}) { }), response: {}, environmentId: undefined, - executionContext: { userId: 'u1', isSystem: false, roles: [], permissions: [] }, + executionContext: { userId: 'u1', isSystem: false, positions: [], permissions: [] }, ...overrides, }; } diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index b7ae2d5466..894947884b 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -2972,7 +2972,7 @@ export class HttpDispatcher { const ec = (context as any)?.executionContext; const userIdFromAuth = (context as any)?.user?.id ?? (context as any)?.userId ?? ec?.userId; if (userIdFromAuth) automationContext.userId = userIdFromAuth; - if (Array.isArray(ec?.roles) && ec.roles.length) automationContext.roles = ec.roles; + if (Array.isArray(ec?.positions) && ec.positions.length) automationContext.positions = ec.positions; if (Array.isArray(ec?.permissions) && ec.permissions.length) automationContext.permissions = ec.permissions; if (ec?.tenantId) automationContext.tenantId = ec.tenantId; const result = await automationService.execute(name, automationContext); @@ -3392,7 +3392,7 @@ export class HttpDispatcher { id: ec.userId, displayName: ec.userDisplayName ?? ec.userName ?? ec.userId, email: ec.userEmail, - roles: Array.isArray(ec.roles) ? ec.roles : [], + roles: Array.isArray(ec.positions) ? ec.positions : [], permissions: Array.isArray(ec.permissions) ? ec.permissions : [], organizationId: ec.tenantId, } @@ -3474,7 +3474,7 @@ export class HttpDispatcher { return { handled: true, response: this.error('Sharing is not configured for this environment', 501) }; } - const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; + const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; const m = method.toUpperCase(); const parts = subPath.replace(/^\/+/, '').split('/').filter(Boolean); const ec: any = context.executionContext; diff --git a/packages/runtime/src/security/resolve-execution-context.test.ts b/packages/runtime/src/security/resolve-execution-context.test.ts index ffcd98280e..e5972a6d14 100644 --- a/packages/runtime/src/security/resolve-execution-context.test.ts +++ b/packages/runtime/src/security/resolve-execution-context.test.ts @@ -127,7 +127,7 @@ describe('resolveExecutionContext — API key verify path', () => { const ctx = await resolveExecutionContext(makeOpts([], {})); expect(ctx.userId).toBeUndefined(); expect(ctx.isSystem).toBe(false); - expect(ctx.roles).toEqual([]); + expect(ctx.positions).toEqual([]); expect(ctx.permissions).toEqual([]); }); @@ -293,9 +293,9 @@ describe('resolveExecutionContext — platform-scoped (null-org) grants (ADR-006 { id: 'ps_admin', name: 'admin_full_access', system_permissions: '["manage_platform_settings","manage_users"]', object_permissions: '{}' }, { id: 'ps_other', name: 'other_org_set', system_permissions: '["should_not_appear"]', object_permissions: '{}' }, ], - sys_role: [], - sys_role_permission_set: [], - sys_user_role: [], + sys_position: [], + sys_position_permission_set: [], + sys_user_position: [], }; return { async find(object, opts) { diff --git a/packages/runtime/src/security/resolve-execution-context.ts b/packages/runtime/src/security/resolve-execution-context.ts index e0ab752066..e0a657c43e 100644 --- a/packages/runtime/src/security/resolve-execution-context.ts +++ b/packages/runtime/src/security/resolve-execution-context.ts @@ -7,16 +7,16 @@ * (`resolveAuthzContext` in `@objectstack/core/security`). This function only * does the transport-specific plumbing — pull `ql` and the better-auth session * getter out of the active kernel/scope — then delegates ALL identity + - * role/permission/RLS aggregation to the shared resolver, and layers the + * position/permission/RLS aggregation to the shared resolver, and layers the * reference localization (timezone/locale) on top. * - * The actual table reads (`sys_member` / `sys_user_role` / + * The actual table reads (`sys_member` / `sys_user_position` / * `sys_*_permission_set`), the platform-admin derivation and the `ai_seat` * synthesis live in ONE place now (`@objectstack/core`), shared with the REST * server, so the two entry points can never drift on authorization again. * * Always resolves — never throws. Anonymous requests yield - * `{ isSystem: false, roles: [], permissions: [] }`. + * `{ isSystem: false, positions: [], permissions: [] }`. */ import type { ExecutionContext } from '@objectstack/spec/kernel'; @@ -77,7 +77,7 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise 0) { + const ps = p as { name?: unknown; isDefault?: unknown }; + if (ps.isDefault === true && typeof ps.name === 'string' && ps.name.length > 0) { return ps.name; } } @@ -104,11 +102,11 @@ describe('createStandaloneStack — surfaces app RBAC from the artifact (ADR-005 expect(result.manifest?.id).toBe('com.test.scope-app'); }); - it('the surfaced config drives appDefaultProfileName → the app profile (the exact CLI wiring)', () => { + it('the surfaced config drives appDefaultPermissionSetName → the app profile (the exact CLI wiring)', () => { // Reproduce serve.ts: `config = { ...originalConfig, ...standaloneStack }`, - // then `appDefaultProfileName(config.permissions)` → SecurityPlugin fallback. + // then `appDefaultPermissionSetName(config.permissions)` → SecurityPlugin fallback. const config: any = { ...{}, ...result }; - expect(appDefaultProfileName(config.permissions)).toBe('app_member_default'); + expect(appDefaultPermissionSetName(config.permissions)).toBe('app_member_default'); }); it('createDefaultHostConfig (the actual serve artifact-fallback) surfaces the same', async () => { @@ -117,7 +115,7 @@ describe('createStandaloneStack — surfaces app RBAC from the artifact (ADR-005 artifactPath, databaseUrl: 'memory://standalone-rbac', }); - expect(appDefaultProfileName(r.permissions)).toBe('app_member_default'); + expect(appDefaultPermissionSetName(r.permissions)).toBe('app_member_default'); expect(r.roles!.map((x: any) => x.name).sort()).toEqual(['contributor', 'manager']); }, BOOT_TIMEOUT); }); diff --git a/packages/runtime/src/standalone-stack.ts b/packages/runtime/src/standalone-stack.ts index b8fe6650d6..3ca6f90480 100644 --- a/packages/runtime/src/standalone-stack.ts +++ b/packages/runtime/src/standalone-stack.ts @@ -100,7 +100,7 @@ export interface StandaloneStackResult { * App-declared RBAC metadata, surfaced so the CLI (`serve`/`dev`/`start`) * can wire it without a host `objectstack.config.ts`. In particular the * `serve` command reads `permissions[]` to honour an app-declared default - * profile (ADR-0056 D7 — `appDefaultProfileName` → SecurityPlugin + * profile (ADR-0056 D7 — `appDefaultPermissionSetName` → SecurityPlugin * `fallbackPermissionSet`) and reads both `roles[]` and `permissions[]` to * register application org roles with Better-Auth. Without these the * artifact-serve path silently fell back to the built-in `member_default` @@ -290,7 +290,7 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro Array.isArray(artifactBundle?.objects) ? artifactBundle.objects : undefined; const manifest: any | undefined = artifactBundle?.manifest; // ADR-0056 D7 — surface app-declared RBAC so the CLI's artifact-serve - // path honours an `isDefault` profile (appDefaultProfileName) and + // path honours an `isDefault` profile (appDefaultPermissionSetName) and // registers application org roles, exactly like the config-load path. const permissions: any[] | undefined = Array.isArray(artifactBundle?.permissions) ? artifactBundle.permissions : undefined; diff --git a/packages/services/service-automation/src/builtin/crud-runas.test.ts b/packages/services/service-automation/src/builtin/crud-runas.test.ts index 1dd5ea98e5..36f490628e 100644 --- a/packages/services/service-automation/src/builtin/crud-runas.test.ts +++ b/packages/services/service-automation/src/builtin/crud-runas.test.ts @@ -101,7 +101,7 @@ describe('flow.runAs identity enforcement at the data layer (#1888)', () => { expect(c.ctx, `${c.op} got no context`).toBeTruthy(); expect(c.ctx.isSystem, `${c.op} wrongly elevated`).toBe(false); expect(c.ctx.userId, `${c.op} lost the user identity`).toBe('u1'); - expect(c.ctx.roles).toEqual(['sales']); + expect(c.ctx.positions).toEqual(['sales']); expect(c.ctx.tenantId).toBe('org1'); } }); @@ -178,7 +178,7 @@ describe('flow.runAs identity enforcement at the data layer (#1888)', () => { describe('resolveRunDataContext (#1888 unit)', () => { it("maps runAs:'system' to an elevated context", () => { expect(resolveRunDataContext({ runAs: 'system', userId: 'u1' })).toEqual({ - isSystem: true, roles: [], permissions: [], + isSystem: true, positions: [], permissions: [], }); }); diff --git a/packages/services/service-automation/src/runtime-identity.ts b/packages/services/service-automation/src/runtime-identity.ts index 3c75a733fb..85bfec6c2c 100644 --- a/packages/services/service-automation/src/runtime-identity.ts +++ b/packages/services/service-automation/src/runtime-identity.ts @@ -5,7 +5,7 @@ import type { AutomationContext } from '@objectstack/spec/contracts'; /** * The identity envelope a flow's data nodes pass to ObjectQL as `options.context`. * A structural subset of the kernel `ExecutionContext` — it always carries the - * three fields the security middleware keys on (`isSystem`, `roles`, + * three fields the security middleware keys on (`isSystem`, `positions`, * `permissions`) so it is directly assignable to the engine's `context` option, * plus the optional `userId`/`tenantId` of the acting user. */ @@ -15,7 +15,7 @@ export interface RunDataContext { /** Acting user id — drives owner/role RLS for `runAs:'user'` runs. */ userId?: string; /** Acting user's role names (RLS parity with a direct REST request). */ - roles: string[]; + positions: string[]; /** Acting user's explicit permission-set names. */ permissions: string[]; /** Acting user's tenant/org id. */ @@ -29,9 +29,9 @@ export interface RunDataContext { * - `runAs:'system'` → `{ isSystem: true }` — the security middleware * short-circuits, so the run reads/writes with full access, bypassing RLS. * - `runAs:'user'` (default) → the triggering user's identity - * (`{ userId, roles, permissions, tenantId? }`), so the security middleware + * (`{ userId, positions, permissions, tenantId? }`), so the security middleware * enforces that user's row-level security. The run can never exceed the - * triggering user's grants. Empty `roles` falls back to the platform's + * triggering user's grants. Empty `positions` falls back to the platform's * baseline permission set, exactly like a fresh member's own REST request. * * Returns `undefined` when neither elevation nor a user identity applies (e.g. a @@ -45,14 +45,14 @@ export interface RunDataContext { */ export function resolveRunDataContext(context: AutomationContext | undefined): RunDataContext | undefined { if (context?.runAs === 'system') { - return { isSystem: true, roles: [], permissions: [] }; + return { isSystem: true, positions: [], permissions: [] }; } if (!context?.userId) return undefined; // `context` is now narrowed to a defined AutomationContext with a userId. const out: RunDataContext = { isSystem: false, userId: context.userId, - roles: Array.isArray(context.roles) ? context.roles : [], + positions: Array.isArray(context.positions) ? context.positions : [], permissions: Array.isArray(context.permissions) ? context.permissions : [], }; if (context.tenantId) out.tenantId = context.tenantId; diff --git a/packages/services/service-automation/src/suspended-run-store.ts b/packages/services/service-automation/src/suspended-run-store.ts index 6fadbfc14b..52ccaa6993 100644 --- a/packages/services/service-automation/src/suspended-run-store.ts +++ b/packages/services/service-automation/src/suspended-run-store.ts @@ -24,7 +24,7 @@ const TABLE = 'sys_automation_run'; /** Prefix for terminal run-history row ids, keeping them disjoint from live * suspended runs (which use the raw `runId`). */ const HISTORY_PREFIX = 'run_'; -const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; /** * Default per-flow cap on terminal run-history rows (#2585 retention stop-gap diff --git a/packages/services/service-job/src/db-job-adapter.ts b/packages/services/service-job/src/db-job-adapter.ts index b311b5ebcd..776010e325 100644 --- a/packages/services/service-job/src/db-job-adapter.ts +++ b/packages/services/service-job/src/db-job-adapter.ts @@ -10,7 +10,7 @@ import { IntervalJobAdapter } from './interval-job-adapter.js'; const JOB_TABLE = 'sys_job'; const RUN_TABLE = 'sys_job_run'; -const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; export interface JobEngineLike { find(object: string, options?: any): Promise; diff --git a/packages/services/service-job/src/job-run-retention.ts b/packages/services/service-job/src/job-run-retention.ts index 977cea5a33..5b7cef091d 100644 --- a/packages/services/service-job/src/job-run-retention.ts +++ b/packages/services/service-job/src/job-run-retention.ts @@ -3,7 +3,7 @@ import type { JobEngineLike, JobLoggerLike } from './db-job-adapter.js'; const RUN_TABLE = 'sys_job_run'; -const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; /** * Default retention window for `sys_job_run` rows, in days. Every job execution diff --git a/packages/services/service-knowledge/src/__tests__/knowledge-service.test.ts b/packages/services/service-knowledge/src/__tests__/knowledge-service.test.ts index 940b77aa25..77f7faac0a 100644 --- a/packages/services/service-knowledge/src/__tests__/knowledge-service.test.ts +++ b/packages/services/service-knowledge/src/__tests__/knowledge-service.test.ts @@ -114,7 +114,7 @@ describe('KnowledgeService — permission-aware search', () => { { chunkId: 'c1', documentId: 'd1', sourceId: 's1', sourceRecordId: 'rec_1', score: 0.9, snippet: '' }, ]; const { svc, findSpy } = buildSetup(hits); - const out = await svc.search('q', { executionContext: { roles: [], permissions: [], isSystem: true } }); + const out = await svc.search('q', { executionContext: { positions: [], permissions: [], isSystem: true } }); expect(out).toHaveLength(1); expect(findSpy).not.toHaveBeenCalled(); }); @@ -149,7 +149,7 @@ describe('KnowledgeService — permission-aware search', () => { source: { kind: 'http', urls: ['https://x.test'] } as KnowledgeSource['source'], }); const out = await svc.search('q', { - executionContext: { userId: 'u1', roles: [], permissions: [], isSystem: false }, + executionContext: { userId: 'u1', positions: [], permissions: [], isSystem: false }, }); expect(out).toHaveLength(1); }); @@ -169,7 +169,7 @@ describe('KnowledgeService — permission-aware search', () => { id: 'http', label: 'HTTP', adapter: 'memory', source: { kind: 'http', urls: ['https://x.test'] } as KnowledgeSource['source'], }); - const out = await svc.search('q', { executionContext: { userId: 'u', roles: [], permissions: [], isSystem: false } }); + const out = await svc.search('q', { executionContext: { userId: 'u', positions: [], permissions: [], isSystem: false } }); expect(out.map((h) => h.documentId)).toEqual(['d2']); }); diff --git a/packages/services/service-knowledge/src/knowledge-service.ts b/packages/services/service-knowledge/src/knowledge-service.ts index 8d4af42468..498a2ca4d2 100644 --- a/packages/services/service-knowledge/src/knowledge-service.ts +++ b/packages/services/service-knowledge/src/knowledge-service.ts @@ -154,7 +154,7 @@ export class KnowledgeService implements IKnowledgeService { const objSource = source.source as ObjectKnowledgeSource; // RLS-bypassing system context — this is a server-side admin op. - const adminCtx: ExecutionContext = { roles: [], permissions: [], systemPermissions: [], isSystem: true }; + const adminCtx: ExecutionContext = { positions: [], permissions: [], systemPermissions: [], isSystem: true }; const records = (await this.options.dataEngine.find(objSource.object, { where: objSource.where, limit: opts.limit, diff --git a/packages/services/service-queue/src/common.ts b/packages/services/service-queue/src/common.ts index 8131c3d2a9..74d139a5f0 100644 --- a/packages/services/service-queue/src/common.ts +++ b/packages/services/service-queue/src/common.ts @@ -24,7 +24,7 @@ export interface JobLogger { error?(msg: string, meta?: unknown): void; } -export const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; +export const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; export function uid(prefix: string): string { const g: any = globalThis as any; diff --git a/packages/spec/CHANGELOG.md b/packages/spec/CHANGELOG.md index 20114e7d45..38b2e505b8 100644 --- a/packages/spec/CHANGELOG.md +++ b/packages/spec/CHANGELOG.md @@ -1069,8 +1069,8 @@ expected record`. Both are now `.optional().default(...)`; an empty full page ADR-0057 — ERP authorization core. Adds permission-grant access DEPTH (`own`/`own_and_reports`/`unit`/`unit_and_below`/`org`), renames `sys_department` → `sys_business_unit` (no aliases — see BREAKING above), introduces the platform-owned - `sys_user_role` assignment, and seeds stack-declared `roles`/`sharingRules` into - `sys_role`/`sys_sharing_rule` at boot (closes #2077). Hierarchy-relative scopes are + `sys_user_position` assignment, and seeds stack-declared `roles`/`sharingRules` into + `sys_position`/`sys_sharing_rule` at boot (closes #2077). Hierarchy-relative scopes are delegated to a pluggable `IHierarchyScopeResolver` (open edition fails closed to owner-only; `defineStack` errors without `requires: ['hierarchy-security']`). Also fixes a latent over-grant where `engine.find({ filter })` was ignored (driver reads @@ -1126,7 +1126,7 @@ allowOrgOverride:false` and the runtime catalog lists only platform agents (#2035). New factories: `defineDatasource`, `defineConnector`, `definePolicy`, - `defineSharingRule`, `defineRole`, `definePermissionSet`, + `defineSharingRule`, `definePosition`, `definePermissionSet`, `defineEmailTemplateDefinition`, `defineReport`, `defineWebhook`, `defineObjectExtension`, `defineCube`, `defineMapping`, `defineTheme`, `defineTranslationBundle`, `definePage`, `defineAction`. Each mirrors the 19 @@ -1194,13 +1194,13 @@ allowOrgOverride:false` and the runtime catalog lists only platform agents showcase announcement now declares the canonical `public_read`, exercised end-to-end by the public-read dogfood proof. -- 2365d07: feat(sharing): configurable role-hierarchy widening — `role_and_subordinates` recipient (ADR-0056 D6) +- 2365d07: feat(sharing): configurable role-hierarchy widening — `unit_and_subordinates` recipient (ADR-0056 D6) Role-hierarchy access widening ("a manager sees records shared with their team") is now **implemented and configurable per sharing rule**, not a hardcoded no-op. The - `role_and_subordinates` recipient (declarable on `sys_sharing_rule.recipient_type`) expands, + `unit_and_subordinates` recipient (declarable on `sys_sharing_rule.recipient_type`) expands, at evaluation time, to the named role **plus every subordinate role** by walking the - `sys_role.parent` hierarchy via a new `RoleGraphService` (mirroring the department/team + `sys_position.parent` hierarchy via a new `PositionGraphService` (mirroring the department/team graphs; cycle-safe). Previously `Role.parent` was declared but never consumed — a silent no-op flagged by the ADR-0056 audit. This is the Salesforce "grant access using hierarchies" model expressed declaratively: each rule chooses whether to roll up the hierarchy. Unit-proven @@ -1346,7 +1346,7 @@ allowOrgOverride:false` and the runtime catalog lists only platform agents The strategy now coerces a temporal comparand to the column's on-disk storage form via a new optional `StrategyContext.coerceTemporalFilterValue` hook, wired to the driver's public `SqlDriver.temporalFilterValue` (the single source of truth for the storage convention). Coercion is dialect-correct: SQLite `Field.datetime` → epoch ms; `Field.date` text and native-timestamp dialects (Postgres/MySQL) are left unchanged, so Postgres is never handed an epoch integer. Applied to `gte`/`lte`/`gt`/`lt`/`equals`, `in`/`notIn`, and the `dateRange`/timeDimension `BETWEEN` path. -- 641675d: Add `*Input` authoring-type aliases (`DatasourceInput`, `ConnectorInput`, `SharingRuleInput`, `JobInput`, `WebhookInput`, `EmailTemplateDefinitionInput`, `RoleInput`, `PermissionSetInput`, `ObjectExtensionInput`) alongside the existing `FieldInput`/`ActionInput`/`ReportInput`/`PortalInput` convention. These are `z.input` aliases so authored literals keep `.default()` fields optional and accept CEL/Expression string shorthands — matching how `defineX()` helpers already accept input. No runtime change. +- 641675d: Add `*Input` authoring-type aliases (`DatasourceInput`, `ConnectorInput`, `SharingRuleInput`, `JobInput`, `WebhookInput`, `EmailTemplateDefinitionInput`, `PositionInput`, `PermissionSetInput`, `ObjectExtensionInput`) alongside the existing `FieldInput`/`ActionInput`/`ReportInput`/`PortalInput` convention. These are `z.input` aliases so authored literals keep `.default()` fields optional and accept CEL/Expression string shorthands — matching how `defineX()` helpers already accept input. No runtime change. - 94e9040: fix(spec): declare the extended Gantt config fields the renderer actually reads `GanttConfigSchema` only declared the 5 core timeline fields as a plain diff --git a/packages/spec/ZOD_SCHEMA_AUDIT_REPORT.md b/packages/spec/ZOD_SCHEMA_AUDIT_REPORT.md index c95a6e8e80..a4e3cefdf9 100644 --- a/packages/spec/ZOD_SCHEMA_AUDIT_REPORT.md +++ b/packages/spec/ZOD_SCHEMA_AUDIT_REPORT.md @@ -556,7 +556,7 @@ ObjectStack UI protocol provides three paths: | File | Exported Schemas | `z.any()` | Notes | |---|---|---|---| | `identity.zod.ts` | UserSchema, AccountSchema, SessionSchema, VerificationTokenSchema | 0 | ⚠️ Uses `z.date()` | -| `role.zod.ts` | RoleSchema | 0 | ✅ Clean, uses SnakeCaseIdentifierSchema | +| `role.zod.ts` | PositionSchema | 0 | ✅ Clean, uses SnakeCaseIdentifierSchema | | `organization.zod.ts` | OrganizationSchema, MemberSchema, InvitationSchema | 1 | ⚠️ Uses `z.date()` | | `scim.zod.ts` | ~20+ SCIM schemas | 0 | ✅ Uses `z.string().datetime()` correctly | diff --git a/packages/spec/src/contracts/ai-service.ts b/packages/spec/src/contracts/ai-service.ts index e96162e077..3210ff085f 100644 --- a/packages/spec/src/contracts/ai-service.ts +++ b/packages/spec/src/contracts/ai-service.ts @@ -393,7 +393,7 @@ export interface ToolExecutionContext { actor?: { id: string; name?: string; - roles?: string[]; + positions?: string[]; permissions?: string[]; }; /** Conversation id for trace/HITL correlation. */ diff --git a/packages/spec/src/contracts/auth-service.ts b/packages/spec/src/contracts/auth-service.ts index 3fd3964059..90151d1a10 100644 --- a/packages/spec/src/contracts/auth-service.ts +++ b/packages/spec/src/contracts/auth-service.ts @@ -23,8 +23,8 @@ export interface AuthUser { email: string; /** Display name */ name: string; - /** Assigned role identifiers */ - roles?: string[]; + /** Assigned position identifiers */ + positions?: string[]; /** Current tenant identifier (multi-tenant) */ tenantId?: string; } diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index ef86028b3d..6152c5b13a 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -37,14 +37,15 @@ export interface AutomationContext { /** User who triggered the automation */ userId?: string; /** - * Role names of the triggering identity. Forwarded by the trigger surface - * (REST route / record-change hook) so a `runAs:'user'` run enforces RLS - * exactly as that user — not a member fallback. ADR-0049 / #1888. + * Position names of the triggering identity (ADR-0090 D3; formerly + * `roles`). Forwarded by the trigger surface (REST route / record-change + * hook) so a `runAs:'user'` run enforces RLS exactly as that user — not a + * member fallback. ADR-0049 / #1888. */ - roles?: string[]; + positions?: string[]; /** * Explicit permission-set names of the triggering identity (parity with a - * direct REST request). Forwarded alongside {@link roles}. ADR-0049 / #1888. + * direct REST request). Forwarded alongside {@link positions}. ADR-0049 / #1888. */ permissions?: string[]; /** diff --git a/packages/spec/src/contracts/metadata-service.ts b/packages/spec/src/contracts/metadata-service.ts index 7b8b6ed99c..41f5f5bedd 100644 --- a/packages/spec/src/contracts/metadata-service.ts +++ b/packages/spec/src/contracts/metadata-service.ts @@ -326,7 +326,7 @@ export interface IMetadataService { getEffective?(type: string, name: string, context?: { userId?: string; tenantId?: string; - roles?: string[]; + positions?: string[]; permissions?: string[]; }): Promise; diff --git a/packages/spec/src/contracts/sharing-service.ts b/packages/spec/src/contracts/sharing-service.ts index 792876051c..dd2a28ec5c 100644 --- a/packages/spec/src/contracts/sharing-service.ts +++ b/packages/spec/src/contracts/sharing-service.ts @@ -28,7 +28,7 @@ export type ShareRecipientType = | 'user' | 'group' | 'role' - | 'role_and_subordinates' + | 'unit_and_subordinates' | 'guest'; /** Access level on a single record. */ @@ -69,7 +69,7 @@ export interface GrantShareInput { export interface SharingExecutionContext { userId?: string; tenantId?: string; - roles?: string[]; + positions?: string[]; permissions?: string[]; isSystem?: boolean; } @@ -133,7 +133,7 @@ export interface ISharingService { * - `role` — tenant role on `sys_member.role` * - `queue` — opaque queue identifier (resolution left to caller / app) */ -export type SharingRuleRecipientType = 'user' | 'team' | 'business_unit' | 'role' | 'role_and_subordinates' | 'queue'; +export type SharingRuleRecipientType = 'user' | 'team' | 'business_unit' | 'position' | 'unit_and_subordinates' | 'queue'; /** * Stored shape of a sharing rule. Maps 1-to-1 to `sys_sharing_rule` diff --git a/packages/spec/src/data/data-engine.test.ts b/packages/spec/src/data/data-engine.test.ts index 3d970c2056..68ba436c19 100644 --- a/packages/spec/src/data/data-engine.test.ts +++ b/packages/spec/src/data/data-engine.test.ts @@ -853,13 +853,13 @@ describe('Integration Tests', () => { context: { userId: 'user_123', tenantId: 'org_456', - roles: ['admin'], + positions: ['admin'], }, }); expect(options.context?.userId).toBe('user_123'); expect(options.context?.tenantId).toBe('org_456'); - expect(options.context?.roles).toEqual(['admin']); + expect(options.context?.positions).toEqual(['admin']); }); it('should support context in insert options', () => { @@ -901,10 +901,10 @@ describe('Integration Tests', () => { it('should support context in aggregate options', () => { const options = DataEngineAggregateOptionsSchema.parse({ groupBy: ['status'], - context: { userId: 'u1', roles: ['analyst'] }, + context: { userId: 'u1', positions: ['analyst'] }, }); - expect(options.context?.roles).toEqual(['analyst']); + expect(options.context?.positions).toEqual(['analyst']); }); it('should accept options without context (backward compatible)', () => { diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 79b805097d..fcef92a0a3 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -96,7 +96,7 @@ export const SelectOptionSchema = lazySchema(() => z.object({ * evaluates TRUE. Omit = always available. Evaluated against the SAME binding * environment as field-level `visibleWhen` (live `record` + `current_user`), so * it expresses BOTH cascading/dependent options (`record.country == 'cn'`) AND - * role/context gating (`'admin' in current_user.roles`). When it references + * role/context gating (`'admin' in current_user.positions`). When it references * sibling fields, declare those on the field's `dependsOn` so the form can gate * and re-evaluate the option list as the parent changes. * @@ -105,7 +105,7 @@ export const SelectOptionSchema = lazySchema(() => z.object({ * rule-validator evaluates the picked value's `visibleWhen`) — hiding it in the * dropdown alone is bypassable. */ - visibleWhen: ExpressionInputSchema.optional().describe("Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Same env as field visibleWhen (record + current_user). e.g. P`record.country == 'cn'` or P`'admin' in current_user.roles`"), + visibleWhen: ExpressionInputSchema.optional().describe("Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Same env as field visibleWhen (record + current_user). e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`"), })); /** diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 7f9c418df7..b24455ef78 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -401,7 +401,7 @@ const ObjectSchemaBase = z.object({ * CSV Import is suppressed (config rows have nested JSON envelopes * that don't round-trip through a flat sheet; clients should offer a * purpose-built "Import definition (JSON)" action instead). Example: - * `sys_sharing_rule`, `sys_role`, `sys_permission_set`, `sys_view`, + * `sys_sharing_rule`, `sys_position`, `sys_permission_set`, `sys_view`, * `sys_app`. * - `system` — Runtime rows whose lifecycle is owned by a * platform service (the approval engine, the sharing engine, the @@ -411,7 +411,7 @@ const ObjectSchemaBase = z.object({ * `sys_approval_request`; "Recall" on the request changes its * state). Example: `sys_approval_request`, `sys_record_share`, * `sys_notification`, `sys_invitation`, - * `sys_user_permission_set` / `sys_role_permission_set`. + * `sys_user_permission_set` / `sys_position_permission_set`. * - `append-only` — Immutable audit log. No New / Import / Edit / * Delete; only View and Export. Example: `sys_approval_action`, * `sys_audit_log`, `sys_activity`, `sys_email`, `sys_presence`. @@ -704,7 +704,17 @@ const ObjectSchemaBase = z.object({ * ids)` on reads and requires master edit-access on by-id writes. No RLS policy * is authored — the inheritance is derived from the relationship. */ - sharingModel: z.enum(['private', 'public_read', 'public_read_write', 'controlled_by_parent', 'read', 'read_write', 'full']).optional().describe('Org-Wide Default record visibility (OWD). Canonical: private (owner-only) | public_read (everyone reads, owner writes) | public_read_write (everyone reads+writes) | controlled_by_parent (derived from the master record). Legacy aliases: read=public_read, read_write/full=public_read_write. ADR-0056 D1.'), + sharingModel: z.enum(['private', 'public_read', 'public_read_write', 'controlled_by_parent']).optional().describe('Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) | public_read (everyone reads, owner writes) | public_read_write (everyone reads+writes) | controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1).'), + + /** + * [ADR-0090 D11] Org-Wide Default for EXTERNAL principals + * (`principal.audience: 'external'` — portal / partner users). A second, + * stricter dial: defaults to `private` when omitted and may NEVER be wider + * than the internal `sharingModel` (validated at authoring). The BU depth + * axis does not apply to externals; their visibility = own records + + * explicit shares + this baseline. + */ + externalSharingModel: z.enum(['private', 'public_read', 'public_read_write', 'controlled_by_parent']).optional().describe('[ADR-0090 D11] OWD for external (portal/partner) principals. Defaults to private; must be <= sharingModel in openness.'), /** * Public Share-Link Policy diff --git a/packages/spec/src/identity/eval-user.zod.ts b/packages/spec/src/identity/eval-user.zod.ts index 1450c8ad36..d92237d29e 100644 --- a/packages/spec/src/identity/eval-user.zod.ts +++ b/packages/spec/src/identity/eval-user.zod.ts @@ -9,12 +9,12 @@ import { lazySchema } from '../shared/lazy-schema'; * The signed-in user exposed to every predicate surface (server formula, server * RLS, client UI gates) under the canonical variable name `current_user` * (aliases `user`, `ctx.user`) with an **identical shape**. A predicate such as - * `current_user.roles.exists(r, r == 'org_admin')` (or - * `'org_admin' in current_user.roles`) therefore evaluates identically wherever + * `current_user.positions.exists(p, p == 'org_admin')` (or + * `'org_admin' in current_user.positions`) therefore evaluates identically wherever * it is written. * - * `roles: string[]` is the **only canonical** role field. Singular `role` is NOT - * part of this contract — its legacy "overwritten to 'admin' on promotion" + * `positions: string[]` is the **only canonical** membership field (renamed from + * `roles`, ADR-0090 D3). A singular field is NOT part of this contract — its legacy "overwritten to 'admin' on promotion" * behavior is the footgun this eliminates. * * @see docs/adr/0068-unified-user-context-and-built-in-identity-roles.md @@ -29,27 +29,27 @@ import { lazySchema } from '../shared/lazy-schema'; * Unscoped (`org_id = null`); source of truth = unscoped * `sys_user_permission_set` -> `admin_full_access`. */ -export const BUILTIN_ROLE_PLATFORM_ADMIN = 'platform_admin'; +export const BUILTIN_IDENTITY_PLATFORM_ADMIN = 'platform_admin'; /** Organization owner within a tenant. Source: `sys_member.role = owner`. */ -export const BUILTIN_ROLE_ORG_OWNER = 'org_owner'; +export const BUILTIN_IDENTITY_ORG_OWNER = 'org_owner'; /** Organization administrator within a tenant. Source: `sys_member.role = admin`. */ -export const BUILTIN_ROLE_ORG_ADMIN = 'org_admin'; +export const BUILTIN_IDENTITY_ORG_ADMIN = 'org_admin'; /** Organization member within a tenant. Source: `sys_member.role = member`. */ -export const BUILTIN_ROLE_ORG_MEMBER = 'org_member'; +export const BUILTIN_IDENTITY_ORG_MEMBER = 'org_member'; /** * The reserved, framework-seeded role names (ADR-0068 D2). These are a - * normalized **projection** into `current_user.roles`; their sources of truth + * normalized **projection** into `current_user.positions`; their sources of truth * (membership rows, the unscoped admin link) are never changed by the projection. */ -export const BUILTIN_ROLE_NAMES = [ - BUILTIN_ROLE_PLATFORM_ADMIN, - BUILTIN_ROLE_ORG_OWNER, - BUILTIN_ROLE_ORG_ADMIN, - BUILTIN_ROLE_ORG_MEMBER, +export const BUILTIN_IDENTITY_NAMES = [ + BUILTIN_IDENTITY_PLATFORM_ADMIN, + BUILTIN_IDENTITY_ORG_OWNER, + BUILTIN_IDENTITY_ORG_ADMIN, + BUILTIN_IDENTITY_ORG_MEMBER, ] as const; -export type BuiltinRoleName = (typeof BUILTIN_ROLE_NAMES)[number]; +export type BuiltinIdentityName = (typeof BUILTIN_IDENTITY_NAMES)[number]; /** * Permission-set name whose unscoped grant is the source of truth for @@ -57,21 +57,21 @@ export type BuiltinRoleName = (typeof BUILTIN_ROLE_NAMES)[number]; */ export const ADMIN_FULL_ACCESS = 'admin_full_access'; -/** Human-readable metadata for the built-in roles (seeded into `sys_role`; AI grounding). */ -export const BUILTIN_ROLE_METADATA: Record = { - [BUILTIN_ROLE_PLATFORM_ADMIN]: { label: 'Platform Admin', description: 'Platform operator (SaaS admin). NOT a tenant user role.' }, - [BUILTIN_ROLE_ORG_OWNER]: { label: 'Organization Owner', description: 'Organization owner within a tenant.' }, - [BUILTIN_ROLE_ORG_ADMIN]: { label: 'Organization Admin', description: 'Organization administrator within a tenant.' }, - [BUILTIN_ROLE_ORG_MEMBER]: { label: 'Organization Member', description: 'Organization member within a tenant.' }, +/** Human-readable metadata for the built-in identity names (seeded into `sys_position`; AI grounding). */ +export const BUILTIN_IDENTITY_METADATA: Record = { + [BUILTIN_IDENTITY_PLATFORM_ADMIN]: { label: 'Platform Admin', description: 'Platform operator (SaaS admin). NOT a tenant user role.' }, + [BUILTIN_IDENTITY_ORG_OWNER]: { label: 'Organization Owner', description: 'Organization owner within a tenant.' }, + [BUILTIN_IDENTITY_ORG_ADMIN]: { label: 'Organization Admin', description: 'Organization administrator within a tenant.' }, + [BUILTIN_IDENTITY_ORG_MEMBER]: { label: 'Organization Member', description: 'Organization member within a tenant.' }, }; /** Normalize a raw better-auth membership role (owner/admin/member) to its canonical * built-in role name (org_owner/org_admin/org_member). Unknown values pass through. */ export function mapMembershipRole(raw: string): string { switch (raw.trim().toLowerCase()) { - case 'owner': return BUILTIN_ROLE_ORG_OWNER; - case 'admin': return BUILTIN_ROLE_ORG_ADMIN; - case 'member': return BUILTIN_ROLE_ORG_MEMBER; + case 'owner': return BUILTIN_IDENTITY_ORG_OWNER; + case 'admin': return BUILTIN_IDENTITY_ORG_ADMIN; + case 'member': return BUILTIN_IDENTITY_ORG_MEMBER; default: return raw.trim(); } } @@ -85,10 +85,10 @@ export const EvalUserSchema = lazySchema(() => id: z.string().describe('User ID'), name: z.string().optional().describe('Display name'), email: z.string().optional().describe('Email address'), - /** CANONICAL. Scope-resolved (ADR-0068 D3); includes built-in + business roles. */ - roles: z.array(z.string()).default([]).describe('Canonical role names assigned to the user (scope-resolved)'), - /** DERIVED alias of roles.includes(platform_admin) (ADR-0068 D2). Deprecated surface. */ - isPlatformAdmin: z.boolean().optional().describe("DERIVED alias of 'platform_admin' in roles. Deprecated."), + /** CANONICAL. Scope-resolved (ADR-0068 D3); built-in identity names + position names. */ + positions: z.array(z.string()).default([]).describe('Canonical position/identity names assigned to the user (scope-resolved)'), + /** DERIVED alias of positions.includes(platform_admin) (ADR-0068 D2). Deprecated surface. */ + isPlatformAdmin: z.boolean().optional().describe("DERIVED alias of 'platform_admin' in positions. Deprecated."), organizationId: z.string().nullable().optional().describe('Active organization ID (null = platform/unscoped)'), }) ); @@ -101,24 +101,24 @@ export type EvalUserInput = z.input; * Build a canonical EvalUser from loosely-typed source fields. The single factory * every surface uses (server buildScope, the customSession bridge, objectui * fallback/guest/preview users) so the user shape — and the isPlatformAdmin - * derivation — never drifts. isPlatformAdmin is always derived from roles. + * derivation — never drifts. isPlatformAdmin is always derived from positions. */ export function createEvalUser(input: { id: string; name?: string | null; email?: string | null; - roles?: readonly string[] | null; + positions?: readonly string[] | null; organizationId?: string | null; }): EvalUser { - const roles = Array.from( - new Set((input.roles ?? []).map((r) => String(r).trim()).filter(Boolean)) + const positions = Array.from( + new Set((input.positions ?? []).map((r) => String(r).trim()).filter(Boolean)) ); return { id: input.id, ...(input.name != null ? { name: input.name } : {}), ...(input.email != null ? { email: input.email } : {}), - roles, - isPlatformAdmin: roles.includes(BUILTIN_ROLE_PLATFORM_ADMIN), + positions, + isPlatformAdmin: positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN), ...(input.organizationId !== undefined ? { organizationId: input.organizationId } : {}), }; } diff --git a/packages/spec/src/identity/index.ts b/packages/spec/src/identity/index.ts index 1730b247fd..9bcaf2c9f9 100644 --- a/packages/spec/src/identity/index.ts +++ b/packages/spec/src/identity/index.ts @@ -2,8 +2,8 @@ export * from './identity.zod'; export * from './protocol'; -export * from './role.zod'; -export { roleForm } from './role.form'; +export * from './position.zod'; +export { positionForm } from './position.form'; export * from './organization.zod'; export * from './scim.zod'; export * from './eval-user.zod'; diff --git a/packages/spec/src/identity/position.form.ts b/packages/spec/src/identity/position.form.ts new file mode 100644 index 0000000000..96a1aed34e --- /dev/null +++ b/packages/spec/src/identity/position.form.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineForm } from '../ui/view.zod'; + +/** + * Position — canonical FormView layout. + * + * Positions are flat capability-distribution groups (ADR-0090 D3): name, + * label, optional description. No hierarchy — the org tree lives on + * business units. Single section is enough; the FormView gives the editor + * friendly labels and required markers. + */ +export const positionForm = defineForm({ + schemaId: 'position', + type: 'simple', + sections: [ + { + label: 'Position', + description: + 'A position is a flat, assignable bundle of permission sets (e.g. sales_rep, sales_manager). Capability lives on permission sets; visibility depth lives on the business-unit tree.', + columns: 2, + fields: [ + { field: 'name', required: true, helpText: 'snake_case' }, + { field: 'label', required: true }, + { field: 'description', colSpan: 2 }, + ], + }, + ], +}); diff --git a/packages/spec/src/identity/role.test.ts b/packages/spec/src/identity/position.test.ts similarity index 81% rename from packages/spec/src/identity/role.test.ts rename to packages/spec/src/identity/position.test.ts index 1a6f82814b..2884b6cd3c 100644 --- a/packages/spec/src/identity/role.test.ts +++ b/packages/spec/src/identity/position.test.ts @@ -1,64 +1,64 @@ import { describe, it, expect } from 'vitest'; -import { RoleSchema, type Role } from './role.zod'; +import { PositionSchema, type Position } from './position.zod'; -describe('RoleSchema', () => { +describe('PositionSchema', () => { describe('Basic Properties', () => { it('should accept minimal role', () => { - const role: Role = { + const role: Position = { name: 'ceo', label: 'CEO', }; - expect(() => RoleSchema.parse(role)).not.toThrow(); + expect(() => PositionSchema.parse(role)).not.toThrow(); }); it('should enforce snake_case for role name', () => { const validNames = ['ceo', 'vp_sales', 'sales_manager', 'account_exec']; validNames.forEach(name => { - expect(() => RoleSchema.parse({ name, label: 'Test' })).not.toThrow(); + expect(() => PositionSchema.parse({ name, label: 'Test' })).not.toThrow(); }); const invalidNames = ['CEO', 'VP-Sales', 'salesManager', '123role', '_internal']; invalidNames.forEach(name => { - expect(() => RoleSchema.parse({ name, label: 'Test' })).toThrow(); + expect(() => PositionSchema.parse({ name, label: 'Test' })).toThrow(); }); }); it('should accept role with description', () => { - const role: Role = { + const role: Position = { name: 'sales_director', label: 'Sales Director', description: 'Oversees all sales operations', }; - expect(() => RoleSchema.parse(role)).not.toThrow(); + expect(() => PositionSchema.parse(role)).not.toThrow(); }); }); describe('Hierarchy', () => { it('should accept role without parent (top level)', () => { - const role: Role = { + const role: Position = { name: 'ceo', label: 'Chief Executive Officer', }; - expect(() => RoleSchema.parse(role)).not.toThrow(); + expect(() => PositionSchema.parse(role)).not.toThrow(); }); it('should accept role with parent', () => { - const role: Role = { + const role: Position = { name: 'vp_sales', label: 'VP of Sales', parent: 'ceo', }; - expect(() => RoleSchema.parse(role)).not.toThrow(); + expect(() => PositionSchema.parse(role)).not.toThrow(); }); }); describe('Real-World Role Hierarchy Examples', () => { it('should accept complete sales organization hierarchy', () => { - const roles: Role[] = [ + const roles: Position[] = [ { name: 'ceo', label: 'Chief Executive Officer', @@ -97,12 +97,12 @@ describe('RoleSchema', () => { ]; roles.forEach(role => { - expect(() => RoleSchema.parse(role)).not.toThrow(); + expect(() => PositionSchema.parse(role)).not.toThrow(); }); }); it('should accept service organization hierarchy', () => { - const roles: Role[] = [ + const roles: Position[] = [ { name: 'vp_customer_success', label: 'VP of Customer Success', @@ -125,12 +125,12 @@ describe('RoleSchema', () => { ]; roles.forEach(role => { - expect(() => RoleSchema.parse(role)).not.toThrow(); + expect(() => PositionSchema.parse(role)).not.toThrow(); }); }); it('should accept product organization hierarchy', () => { - const roles: Role[] = [ + const roles: Position[] = [ { name: 'cto', label: 'Chief Technology Officer', @@ -164,12 +164,12 @@ describe('RoleSchema', () => { ]; roles.forEach(role => { - expect(() => RoleSchema.parse(role)).not.toThrow(); + expect(() => PositionSchema.parse(role)).not.toThrow(); }); }); it('should accept matrix organization with multiple reporting lines', () => { - const roles: Role[] = [ + const roles: Position[] = [ { name: 'ceo', label: 'CEO', @@ -202,12 +202,12 @@ describe('RoleSchema', () => { ]; roles.forEach(role => { - expect(() => RoleSchema.parse(role)).not.toThrow(); + expect(() => PositionSchema.parse(role)).not.toThrow(); }); }); it('should accept flat organization', () => { - const roles: Role[] = [ + const roles: Position[] = [ { name: 'founder', label: 'Founder', @@ -220,7 +220,7 @@ describe('RoleSchema', () => { ]; roles.forEach(role => { - expect(() => RoleSchema.parse(role)).not.toThrow(); + expect(() => PositionSchema.parse(role)).not.toThrow(); }); }); }); diff --git a/packages/spec/src/identity/position.zod.ts b/packages/spec/src/identity/position.zod.ts new file mode 100644 index 0000000000..5d34b5c36d --- /dev/null +++ b/packages/spec/src/identity/position.zod.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; + +/** + * Position Schema — the flat capability-distribution group (ADR-0090 D3). + * + * A position (岗位, "job role" in NetSuite/Workday terms) is a **named, + * assignable bundle of permission sets**: users hold positions + * (`sys_user_position`), positions bind permission sets + * (`sys_position_permission_set`), and a user's capability is the union of + * every set reached that way plus direct grants. + * + * Positions are deliberately **flat** — no `parent`, no hierarchy. The + * visibility hierarchy lives on the business-unit tree (`sys_business_unit`, + * ADR-0057 D2) and the manager chain (`sys_user.manager_id`); re-adding a + * second tree here is the mistake ADR-0057 D5 retired and ADR-0090 D3 + * finalizes. + * + * VOCABULARY (ADR-0090 D3): the word "role" is reserved-forbidden across the + * platform — capability = permission_set, distribution = position, + * hierarchy = business_unit. The sole exception is better-auth's internal + * `sys_member.role` (org-membership tier), projected as + * `org_membership_level`. + * + * **NAMING CONVENTION:** + * Position names MUST be lowercase snake_case to prevent security issues. + * + * @example Good position names + * - 'sales_manager' + * - 'ceo' + * - 'region_east_vp' + * - 'engineering_lead' + * + * @example Bad position names (will be rejected) + * - 'SalesManager' (camelCase) + * - 'CEO' (uppercase) + * - 'Region East VP' (spaces and uppercase) + */ +import { lazySchema } from '../shared/lazy-schema'; +export const PositionSchema = lazySchema(() => z.object({ + /** Identity */ + name: SnakeCaseIdentifierSchema.describe('Unique position name (lowercase snake_case)'), + label: z.string().describe('Display label (e.g. VP of Sales)'), + + /** Description */ + description: z.string().optional(), +})); + +export type Position = z.infer; +/** Authoring input for {@link Position} — defaulted fields are optional. */ +export type PositionInput = z.input; + +/** + * Type-safe factory for a position (flat capability-distribution group). + * Validates at authoring time via `.parse()` and accepts input-shape config — + * preferred over a bare `: Position` literal. + */ +export function definePosition(config: z.input): Position { + return PositionSchema.parse(config); +} diff --git a/packages/spec/src/identity/role.form.ts b/packages/spec/src/identity/role.form.ts deleted file mode 100644 index ebc91435db..0000000000 --- a/packages/spec/src/identity/role.form.ts +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineForm } from '../ui/view.zod'; - -/** - * Role — canonical FormView layout. - * - * Roles are simple: name, label, optional parent, optional description. - * Single section is enough — we still use a FormView so the editor - * picks up the friendly labels and required markers. - */ -export const roleForm = defineForm({ - schemaId: 'role', - type: 'simple', - sections: [ - { - label: 'Role', - description: - 'Roles compose a hierarchy used for record sharing (sales VP → sales mgr → sales rep). Permissions themselves live on Permission Sets and Profiles.', - columns: 2, - fields: [ - { field: 'name', required: true, helpText: 'snake_case' }, - { field: 'label', required: true }, - { - field: 'parent', - helpText: 'Parent role machine name (Reports To)', - colSpan: 2, - }, - { field: 'description', colSpan: 2 }, - ], - }, - ], -}); diff --git a/packages/spec/src/identity/role.zod.ts b/packages/spec/src/identity/role.zod.ts deleted file mode 100644 index 55605efde0..0000000000 --- a/packages/spec/src/identity/role.zod.ts +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { z } from 'zod'; -import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; - -/** - * Role Schema (aka Business Unit / Org Unit) - * Defines the organizational hierarchy (Reporting Structure). - * - * COMPARISON: - * - Salesforce: "Role" (Hierarchy for visibility rollup) - * - Microsoft: "Business Unit" (Structural container for data) - * - Kubernetes/AWS: "Role" usually refers to Permissions (we use PermissionSet for that) - * - * ROLES IN OBJECTSTACK: - * Used primarily for "Reporting Structure" - Managers see subordinates' data. - * - * **NAMING CONVENTION:** - * Role names MUST be lowercase snake_case to prevent security issues. - * - * @example Good role names - * - 'sales_manager' - * - 'ceo' - * - 'region_east_vp' - * - 'engineering_lead' - * - * @example Bad role names (will be rejected) - * - 'SalesManager' (camelCase) - * - 'CEO' (uppercase) - * - 'Region East VP' (spaces and uppercase) - */ -import { lazySchema } from '../shared/lazy-schema'; -export const RoleSchema = lazySchema(() => z.object({ - /** Identity */ - name: SnakeCaseIdentifierSchema.describe('Unique role name (lowercase snake_case)'), - label: z.string().describe('Display label (e.g. VP of Sales)'), - - /** Hierarchy */ - parent: z.string().optional().describe('Parent Role ID (Reports To)'), - - /** Description */ - description: z.string().optional(), -})); - -export type Role = z.infer; -/** Authoring input for {@link Role} — defaulted fields are optional. */ -export type RoleInput = z.input; - -/** - * Type-safe factory for a role in the role hierarchy. Validates at authoring time via - * `.parse()` and accepts input-shape config (optional defaults, CEL - * shorthand) — preferred over a bare `: Role` literal. - */ -export function defineRole(config: z.input): Role { - return RoleSchema.parse(config); -} diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index 60736b3a5f..a3411bd3cd 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -88,7 +88,7 @@ export { defineSkill } from './ai/skill.zod'; export { defineDatasource } from './data/datasource.zod'; export { defineConnector } from './integration/connector.zod'; export { defineSharingRule } from './security/sharing.zod'; -export { defineRole } from './identity/role.zod'; +export { definePosition } from './identity/position.zod'; export { definePermissionSet } from './security/permission.zod'; export { defineEmailTemplateDefinition } from './system/email-template.zod'; export { defineReport } from './ui/report.zod'; @@ -143,12 +143,12 @@ export { createEvalUser, mapMembershipRole, EvalUserSchema, - BUILTIN_ROLE_NAMES, - BUILTIN_ROLE_METADATA, - BUILTIN_ROLE_PLATFORM_ADMIN, - BUILTIN_ROLE_ORG_OWNER, - BUILTIN_ROLE_ORG_ADMIN, - BUILTIN_ROLE_ORG_MEMBER, + BUILTIN_IDENTITY_NAMES, + BUILTIN_IDENTITY_METADATA, + BUILTIN_IDENTITY_PLATFORM_ADMIN, + BUILTIN_IDENTITY_ORG_OWNER, + BUILTIN_IDENTITY_ORG_ADMIN, + BUILTIN_IDENTITY_ORG_MEMBER, ADMIN_FULL_ACCESS, } from './identity/eval-user.zod'; -export type { EvalUser, EvalUserInput, BuiltinRoleName } from './identity/eval-user.zod'; +export type { EvalUser, EvalUserInput, BuiltinIdentityName } from './identity/eval-user.zod'; diff --git a/packages/spec/src/kernel/execution-context.test.ts b/packages/spec/src/kernel/execution-context.test.ts index d127d0b981..fcd38ec850 100644 --- a/packages/spec/src/kernel/execution-context.test.ts +++ b/packages/spec/src/kernel/execution-context.test.ts @@ -4,7 +4,7 @@ import { ExecutionContextSchema } from './execution-context.zod'; describe('ExecutionContextSchema', () => { it('should accept empty context (all optional)', () => { const ctx = ExecutionContextSchema.parse({}); - expect(ctx.roles).toEqual([]); + expect(ctx.positions).toEqual([]); expect(ctx.permissions).toEqual([]); expect(ctx.isSystem).toBe(false); }); @@ -13,7 +13,7 @@ describe('ExecutionContextSchema', () => { const ctx = ExecutionContextSchema.parse({ userId: 'user_123', tenantId: 'org_456', - roles: ['admin', 'editor'], + positions: ['admin', 'editor'], permissions: ['read:account', 'write:account'], isSystem: false, accessToken: 'Bearer abc', @@ -22,7 +22,7 @@ describe('ExecutionContextSchema', () => { expect(ctx.userId).toBe('user_123'); expect(ctx.tenantId).toBe('org_456'); - expect(ctx.roles).toEqual(['admin', 'editor']); + expect(ctx.positions).toEqual(['admin', 'editor']); expect(ctx.permissions).toEqual(['read:account', 'write:account']); expect(ctx.isSystem).toBe(false); expect(ctx.accessToken).toBe('Bearer abc'); @@ -31,7 +31,7 @@ describe('ExecutionContextSchema', () => { it('should default roles and permissions to empty arrays', () => { const ctx = ExecutionContextSchema.parse({ userId: 'u1' }); - expect(ctx.roles).toEqual([]); + expect(ctx.positions).toEqual([]); expect(ctx.permissions).toEqual([]); }); diff --git a/packages/spec/src/kernel/execution-context.zod.ts b/packages/spec/src/kernel/execution-context.zod.ts index bf25032cc4..f6db521eff 100644 --- a/packages/spec/src/kernel/execution-context.zod.ts +++ b/packages/spec/src/kernel/execution-context.zod.ts @@ -68,8 +68,40 @@ export const ExecutionContextSchema = lazySchema(() => z.object({ */ currency: z.string().optional(), - /** User role names (resolved from Member + Role) */ - roles: z.array(z.string()).default([]), + /** + * Position names held by the user (ADR-0090 D3; resolved from + * `sys_user_position` plus the built-in identity projection — the four + * ADR-0068 org-membership names like `org_admin` are included so gating + * predicates keep working). Formerly `roles`. + */ + positions: z.array(z.string()).default([]), + + /** + * [ADR-0090 D10 — P1 shape] Principal taxonomy. What KIND of caller this + * is. Evaluation semantics phase in later (agent intersection, guest + * position, service lints); the shape lands pre-launch so every API + * carries it from day one. Absent = 'human'. + */ + principalKind: z.enum(['human', 'agent', 'service', 'guest', 'system']).optional(), + + /** + * [ADR-0090 D10/D11 — P1 shape] Whether the principal is an internal org + * member or an external (portal/partner) identity. Externals evaluate + * against `externalSharingModel` and skip the BU depth axis. Absent = + * 'internal'. + */ + audience: z.enum(['internal', 'external']).optional(), + + /** + * [ADR-0090 D10 — P1 shape] Delegation link for agent/service principals + * acting on behalf of a user. Agent effective permission = the agent's own + * grants ∩ the delegator's grants (confused-deputy prevention; enforced + * when agent evaluation lands). + */ + onBehalfOf: z.object({ + userId: z.string(), + principalKind: z.enum(['human', 'agent', 'service', 'guest', 'system']).optional(), + }).optional(), /** Aggregated permission names (resolved from PermissionSet) */ permissions: z.array(z.string()).default([]), diff --git a/packages/spec/src/kernel/metadata-create-seeds.test.ts b/packages/spec/src/kernel/metadata-create-seeds.test.ts index dde23febaf..d1680b1db2 100644 --- a/packages/spec/src/kernel/metadata-create-seeds.test.ts +++ b/packages/spec/src/kernel/metadata-create-seeds.test.ts @@ -53,7 +53,7 @@ describe('metadata create seeds validate against their spec schemas', () => { const KNOWN_UNSEEDED = new Set([ 'report', // canvas-create: dataset/measures picked interactively 'app', 'field', 'seed', 'job', 'datasource', 'translation', 'doc', 'book', - 'permission', 'profile', 'role', 'agent', 'tool', 'skill', 'email_template', + 'permission', 'position', 'agent', 'tool', 'skill', 'email_template', ]); const seeded = new Set(listMetadataCreateSeedTypes()); const missing = listMetadataTypeSchemaTypes().filter((t) => !seeded.has(t) && !KNOWN_UNSEEDED.has(t)); diff --git a/packages/spec/src/kernel/metadata-plugin.test.ts b/packages/spec/src/kernel/metadata-plugin.test.ts index 59ab12b206..65dcb00b9c 100644 --- a/packages/spec/src/kernel/metadata-plugin.test.ts +++ b/packages/spec/src/kernel/metadata-plugin.test.ts @@ -23,7 +23,7 @@ describe('MetadataPluginProtocol', () => { 'flow', // ADR-0020: `workflow` retired as a metadata type // ADR-0088: `trigger`/`router`/`function`/`service` retired as kinds 'datasource', 'external_catalog', 'translation', - 'permission', 'profile', 'role', + 'permission', 'position', 'agent', 'tool', 'skill', ]; diff --git a/packages/spec/src/kernel/metadata-plugin.zod.ts b/packages/spec/src/kernel/metadata-plugin.zod.ts index e83e222133..e85fdd9ce0 100644 --- a/packages/spec/src/kernel/metadata-plugin.zod.ts +++ b/packages/spec/src/kernel/metadata-plugin.zod.ts @@ -113,8 +113,7 @@ export const MetadataTypeSchema = lazySchema(() => z.enum([ // Security Protocol 'permission', // Permission sets (PermissionSetSchema) - 'profile', // User profiles (ProfileSchema) - 'role', // Security roles + 'position', // Positions — flat capability-distribution groups (ADR-0090 D3) // AI Protocol 'agent', // AI agent definitions (AgentSchema) @@ -682,8 +681,7 @@ export const DEFAULT_METADATA_TYPE_REGISTRY: MetadataTypeRegistryEntry[] = [ // Security Protocol { type: 'permission', label: 'Permission Set', filePatterns: ['**/*.permission.ts', '**/*.permission.yml'], supportsOverlay: true, allowOrgOverride: true, allowRuntimeCreate: true, supportsVersioning: true, executionPinned: false, loadOrder: 15, domain: 'security' }, - { type: 'profile', label: 'Profile', filePatterns: ['**/*.profile.ts', '**/*.profile.yml'], supportsOverlay: true, allowOrgOverride: true, allowRuntimeCreate: true, supportsVersioning: false, executionPinned: false, loadOrder: 15, domain: 'security' }, - { type: 'role', label: 'Role', filePatterns: ['**/*.role.ts', '**/*.role.yml'], supportsOverlay: true, allowOrgOverride: true, allowRuntimeCreate: true, supportsVersioning: false, executionPinned: false, loadOrder: 15, domain: 'security' }, + { type: 'position', label: 'Position', filePatterns: ['**/*.position.ts', '**/*.position.yml'], supportsOverlay: true, allowOrgOverride: true, allowRuntimeCreate: true, supportsVersioning: false, executionPinned: false, loadOrder: 15, domain: 'security' }, // AI Protocol // `agent`: executionPinned — long-running conversations must stick to the diff --git a/packages/spec/src/kernel/metadata-type-schemas.ts b/packages/spec/src/kernel/metadata-type-schemas.ts index 754532c03d..b9b4c31628 100644 --- a/packages/spec/src/kernel/metadata-type-schemas.ts +++ b/packages/spec/src/kernel/metadata-type-schemas.ts @@ -18,8 +18,7 @@ * `function`/`service`/`router` — and `trigger` — were retired from the * registry entirely by ADR-0088.) * - * Profile shares `PermissionSetSchema` (a profile is a permission set - * with `isProfile: true`); `validation` exposes the discriminated union + * `validation` exposes the discriminated union * over all built-in rule variants. Custom plugin types can extend this * registry at runtime via `registerMetadataTypeSchema()`. */ @@ -52,7 +51,7 @@ import { DocSchema } from '../system/doc.zod'; import { BookSchema } from '../system/book.zod'; import { PermissionSetSchema } from '../security/permission.zod'; -import { RoleSchema } from '../identity/role.zod'; +import { PositionSchema } from '../identity/position.zod'; import { AgentSchema } from '../ai/agent.zod'; import { ToolSchema } from '../ai/tool.zod'; @@ -101,8 +100,7 @@ const BUILTIN_METADATA_TYPE_SCHEMAS: Partial> = // Security Protocol permission: PermissionSetSchema, - profile: PermissionSetSchema, // profile = permission set with isProfile=true - role: RoleSchema, + position: PositionSchema, // flat capability-distribution group (ADR-0090 D3) // AI Protocol agent: AgentSchema, diff --git a/packages/spec/src/security/permission.form.ts b/packages/spec/src/security/permission.form.ts index 5ad121afa6..d0f04bbb26 100644 --- a/packages/spec/src/security/permission.form.ts +++ b/packages/spec/src/security/permission.form.ts @@ -7,7 +7,7 @@ import { defineForm } from '../ui/view.zod'; * * Used for both `permission` (additive permission grant bundles) and * `profile` (the base permission set assigned to every user). The only - * semantic difference is the `isProfile` flag; we expose it as a switch + * flags are minimal (ADR-0090 D2 removed the Profile concept) * so admins can see and toggle it explicitly. * * The object/field permission maps are intentionally kept as JSON for @@ -26,7 +26,6 @@ export const permissionForm = defineForm({ fields: [ { field: 'name', required: true, colSpan: 1, helpText: 'Machine name (snake_case)' }, { field: 'label', colSpan: 1, helpText: 'Display label for admins' }, - { field: 'isProfile', type: 'toggle', colSpan: 2, helpText: 'Profile = base set assigned to users. Permission Set = additive grant.' }, ], }, { diff --git a/packages/spec/src/security/permission.test.ts b/packages/spec/src/security/permission.test.ts index 8e44f9a01d..5243d23956 100644 --- a/packages/spec/src/security/permission.test.ts +++ b/packages/spec/src/security/permission.test.ts @@ -110,14 +110,14 @@ describe('PermissionSetSchema', () => { expect(() => PermissionSetSchema.parse(permSet)).not.toThrow(); }); - it('should default isProfile to false', () => { + it('should default isDefault to false', () => { const permSet = { name: 'export_reports', objects: {}, }; const result = PermissionSetSchema.parse(permSet); - expect(result.isProfile).toBe(false); + expect(result.isDefault).toBe(false); }); it('should accept permission set with label', () => { @@ -134,7 +134,6 @@ describe('PermissionSetSchema', () => { const profile: PermissionSet = { name: 'system_admin', label: 'System Administrator', - isProfile: true, objects: {}, }; @@ -218,7 +217,6 @@ describe('PermissionSetSchema', () => { const sysAdmin: PermissionSet = { name: 'system_administrator', label: 'System Administrator', - isProfile: true, objects: { user: { allowCreate: true, @@ -263,7 +261,6 @@ describe('PermissionSetSchema', () => { const salesUser: PermissionSet = { name: 'standard_sales_user', label: 'Standard Sales User', - isProfile: true, objects: { account: { allowCreate: true, @@ -309,7 +306,6 @@ describe('PermissionSetSchema', () => { const marketingPermSet: PermissionSet = { name: 'marketing_user', label: 'Marketing User', - isProfile: false, objects: { campaign: { allowCreate: true, @@ -338,7 +334,6 @@ describe('PermissionSetSchema', () => { const analyst: PermissionSet = { name: 'read_only_analyst', label: 'Read Only Analyst', - isProfile: true, objects: { account: { allowRead: true, @@ -370,7 +365,6 @@ describe('PermissionSetSchema', () => { const serviceAgent: PermissionSet = { name: 'service_agent', label: 'Service Agent', - isProfile: true, objects: { case: { allowCreate: true, diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index a9f8b89e0e..8639a7ef14 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -89,10 +89,11 @@ export const FieldPermissionSchema = lazySchema(() => z.object({ * Permission Set Schema * Defines a collection of permissions that can be assigned to users. * - * DIFFERENTIATION: - * - Profile: The ONE primary functional definition of a user (e.g. Standard User). - * - Permission Set: Add-on capabilities assigned to users (e.g. Export Reports). - * - Role: (Defined in src/system/role.zod.ts) Defines data visibility hierarchy. + * DIFFERENTIATION (ADR-0090): + * - Permission Set: the ONLY capability container — union-merged, additive. + * - Position (src/identity/position.zod.ts): flat distribution group that + * binds sets to people. There is no Profile concept (ADR-0090 D2). + * - Business Unit: the visibility hierarchy (ADR-0057). * * **NAMING CONVENTION:** * Permission set names MUST be lowercase snake_case to prevent security issues. @@ -136,9 +137,13 @@ export const PermissionSetSchema = lazySchema(() => z.object({ managedBy: z.enum(['package', 'platform', 'user']).optional() .describe('[ADR-0086 D3] Record provenance: package (upgrade-owned metadata) vs platform/user (env config)'), - /** Is this a Profile? (Base set for a user) */ - isProfile: z.boolean().default(false).describe('Whether this is a user profile'), - isDefault: z.boolean().default(false).describe('[ADR-0056 D7] When true, this profile is the FALLBACK assigned to authenticated users who have no explicit grants — an app declares its default access posture here instead of relying on the built-in member_default. Foundation for SSO/JIT provisioning.'), + /** + * [ADR-0090 D5] Package SUGGESTION: on install the admin is prompted to bind + * this set to the built-in `everyone` position (default grants for + * authenticated users). Never auto-bound; carries no runtime semantics of + * its own. (The former `isProfile` flag was removed by ADR-0090 D2.) + */ + isDefault: z.boolean().default(false).describe('[ADR-0090 D5] Install-time suggestion to bind this set to the everyone position (admin confirms; never auto-bound)'), /** Object Permissions Map: -> permissions */ objects: z.record(z.string(), ObjectPermissionSchema).describe('Entity permissions'), @@ -222,7 +227,7 @@ export type ObjectPermission = z.infer; export type FieldPermission = z.infer; /** - * Type-safe factory for a permission set / profile. Validates at authoring time via + * Type-safe factory for a permission set. Validates at authoring time via * `.parse()` and accepts input-shape config (optional defaults, CEL * shorthand) — preferred over a bare `: PermissionSet` literal. */ diff --git a/packages/spec/src/security/rls.test.ts b/packages/spec/src/security/rls.test.ts index 28ce5be808..9dcc3ae79a 100644 --- a/packages/spec/src/security/rls.test.ts +++ b/packages/spec/src/security/rls.test.ts @@ -48,7 +48,7 @@ describe('Row-Level Security (RLS) Protocol', () => { operation: 'select', using: 'assigned_to_id IN (SELECT id FROM users WHERE manager_id = current_user.id)', check: 'assigned_to_id IN (SELECT id FROM users WHERE manager_id = current_user.id)', - roles: ['manager', 'director'], + positions: ['manager', 'director'], enabled: true, priority: 10, tags: ['team_access', 'hierarchy'], @@ -148,11 +148,11 @@ describe('Row-Level Security (RLS) Protocol', () => { object: 'opportunity', operation: 'select', using: 'region = current_user.region', - roles: ['sales_rep', 'sales_manager'], + positions: ['sales_rep', 'sales_manager'], }; const result = RowLevelSecurityPolicySchema.parse(policy); - expect(result.roles).toEqual(['sales_rep', 'sales_manager']); + expect(result.positions).toEqual(['sales_rep', 'sales_manager']); }); it('should validate tags', () => { @@ -326,9 +326,9 @@ describe('Row-Level Security (RLS) Protocol', () => { }); }); - describe('rolePolicy', () => { + describe('positionPolicy', () => { it('should create role-based policy', () => { - const policy = RLS.rolePolicy( + const policy = RLS.positionPolicy( 'sensitive_data', ['manager', 'director'], 'department = current_user.department' @@ -338,7 +338,7 @@ describe('Row-Level Security (RLS) Protocol', () => { expect(policy.object).toBe('sensitive_data'); expect(policy.operation).toBe('select'); expect(policy.using).toBe('department = current_user.department'); - expect(policy.roles).toEqual(['manager', 'director']); + expect(policy.positions).toEqual(['manager', 'director']); expect(policy.enabled).toBe(true); }); }); @@ -351,7 +351,7 @@ describe('Row-Level Security (RLS) Protocol', () => { expect(policy.object).toBe('account'); expect(policy.operation).toBe('all'); expect(policy.using).toBe('1 == 1'); // Always true - expect(policy.roles).toEqual(['ceo', 'cfo']); + expect(policy.positions).toEqual(['ceo', 'cfo']); expect(policy.enabled).toBe(true); }); }); @@ -388,7 +388,7 @@ describe('Row-Level Security (RLS) Protocol', () => { WHERE manager_id = current_user.id ) `, - roles: ['manager', 'director'], + positions: ['manager', 'director'], enabled: true, }; @@ -402,7 +402,7 @@ describe('Row-Level Security (RLS) Protocol', () => { object: 'account', operation: 'select', using: 'territory IN (SELECT territory FROM user_territories WHERE user_id = current_user.id)', - roles: ['sales_rep'], + positions: ['sales_rep'], enabled: true, }; @@ -465,7 +465,7 @@ describe('Row-Level Security (RLS) Protocol', () => { object: 'financial_data', operation: 'all', using: '1 == 1', // Always true - see everything - roles: ['ceo', 'cfo', 'cto'], + positions: ['ceo', 'cfo', 'cto'], enabled: true, priority: 100, // Highest priority }; diff --git a/packages/spec/src/security/rls.zod.ts b/packages/spec/src/security/rls.zod.ts index 555d9b594c..7cbc79a62b 100644 --- a/packages/spec/src/security/rls.zod.ts +++ b/packages/spec/src/security/rls.zod.ts @@ -152,7 +152,7 @@ export type RLSOperation = z.infer; * // The runtime resolves the manager's reports into * // ExecutionContext.rlsMembership.team_member_ids — no subquery needed. * using: 'assigned_to_id in current_user.team_member_ids', - * roles: ['manager', 'director'], + * positions: ['manager', 'director'], * enabled: true * } * ``` @@ -179,7 +179,7 @@ export type RLSOperation = z.infer; * // The runtime stages the rep's territory accounts in * // ExecutionContext.rlsMembership.territory_account_ids. * using: 'id in current_user.territory_account_ids', - * roles: ['sales_rep'], + * positions: ['sales_rep'], * enabled: true * } * ``` @@ -204,7 +204,7 @@ export type RLSOperation = z.infer; * object: 'account', * operation: 'all', * using: '1 == 1', // Always true - see everything - * roles: ['ceo', 'cfo', 'cto'], + * positions: ['ceo', 'cfo', 'cto'], * enabled: true * } * ``` @@ -304,7 +304,7 @@ export const RowLevelSecurityPolicySchema = lazySchema(() => z.object({ * execution context (camelCase fields map to snake_case placeholders): * - `current_user.id` → `ExecutionContext.userId` * - `current_user.organization_id` → `ExecutionContext.tenantId` - * - `current_user.roles` → `ExecutionContext.roles` (array) + * - `current_user.positions` → `ExecutionContext.positions` (array) * - `current_user.org_user_ids` → ids of fellow members of the active org * - any key the runtime stages in `ExecutionContext.rlsMembership` * @@ -355,19 +355,19 @@ export const RowLevelSecurityPolicySchema = lazySchema(() => z.object({ .describe('Validation condition for INSERT/UPDATE (defaults to USING clause if not specified - enforced at application level)'), /** - * Restrict this policy to specific roles. - * If specified, only users with these roles will have this policy applied. - * If omitted, policy applies to all users (except those with bypassRLS permission). - * - * Role names must match defined roles in the system. - * + * Restrict this policy to specific positions (ADR-0090 D3; formerly + * `roles`). If specified, only users holding one of these positions have + * this policy applied. If omitted, the policy applies to all users + * (except those with bypassRLS permission). + * + * Position names must match defined positions in the system. + * * @example ["sales_rep", "account_manager"] * @example ["employee"] - Apply to all employees - * @example ["guest"] - Special restrictions for guests */ - roles: z.array(z.string()) + positions: z.array(z.string()) .optional() - .describe('Roles this policy applies to (omit for all roles)'), + .describe('Positions this policy applies to (omit for all)'), /** * Whether this policy is currently active. @@ -573,27 +573,27 @@ export const RLS = { /** * Create a role-based policy */ - rolePolicy: (object: string, roles: string[], condition: string): RowLevelSecurityPolicy => ({ - name: `${object}_${roles.join('_')}_access`, - label: `${roles.join(', ')} Access for ${object}`, + positionPolicy: (object: string, positions: string[], condition: string): RowLevelSecurityPolicy => ({ + name: `${object}_${positions.join('_')}_access`, + label: `${positions.join(', ')} Access for ${object}`, object, operation: 'select', using: condition, - roles, + positions, enabled: true, priority: 0, }), /** - * Create a permissive policy (allow all for specific roles) + * Create a permissive policy (allow all for specific positions) */ - allowAllPolicy: (object: string, roles: string[]): RowLevelSecurityPolicy => ({ - name: `${object}_${roles.join('_')}_full_access`, - label: `Full Access for ${roles.join(', ')}`, + allowAllPolicy: (object: string, positions: string[]): RowLevelSecurityPolicy => ({ + name: `${object}_${positions.join('_')}_full_access`, + label: `Full Access for ${positions.join(', ')}`, object, operation: 'all', using: '1 == 1', // Always true - roles, + positions, enabled: true, priority: 0, }), diff --git a/packages/spec/src/security/sharing.test.ts b/packages/spec/src/security/sharing.test.ts index df9e2f0196..ac2fe15501 100644 --- a/packages/spec/src/security/sharing.test.ts +++ b/packages/spec/src/security/sharing.test.ts @@ -163,11 +163,11 @@ describe('SharingRuleSchema', () => { object: 'account', type: 'owner', ownedBy: { - type: 'role', + type: 'position', value: 'role_sales_rep', }, sharedWith: { - type: 'role', + type: 'position', value: 'role_sales_manager', }, }); @@ -199,12 +199,12 @@ describe('SharingRuleSchema', () => { object: 'account', type: 'owner', ownedBy: { - type: 'role', + type: 'position', value: 'role_sales_rep', }, accessLevel: 'read', sharedWith: { - type: 'role', + type: 'position', value: 'role_sales_manager', }, }); diff --git a/packages/spec/src/security/sharing.zod.ts b/packages/spec/src/security/sharing.zod.ts index a5defb342e..2c12386429 100644 --- a/packages/spec/src/security/sharing.zod.ts +++ b/packages/spec/src/security/sharing.zod.ts @@ -41,8 +41,8 @@ export const SharingLevel = z.enum([ export const ShareRecipientType = z.enum([ 'user', 'group', - 'role', - 'role_and_subordinates', + 'position', + 'unit_and_subordinates', 'guest' // for public sharing ]); @@ -99,7 +99,7 @@ export const OwnerSharingRuleSchema = lazySchema(() => BaseSharingRuleSchema.ext * `@objectstack/formula` compiler at seed / `defineRule` time, and ENFORCED: * records matching the criteria materialise `sys_record_share` grants for the * resolved recipients. Supported recipients: `user` / `team` / `business_unit` / - * `role` / `role_and_subordinates` (→ `unit_and_subordinates`, ADR-0057 D5). + * `position` / `unit_and_subordinates` (ADR-0057 D5; renamed by ADR-0090 D3). * * Still `[experimental — not enforced]` (ADR-0049): `owner`-type rules * (`ownedBy` — depends on live role membership, with no static `criteria_json` diff --git a/packages/spec/src/shared/metadata-collection.test.ts b/packages/spec/src/shared/metadata-collection.test.ts index 3cae56fcca..17d1e439d4 100644 --- a/packages/spec/src/shared/metadata-collection.test.ts +++ b/packages/spec/src/shared/metadata-collection.test.ts @@ -249,7 +249,7 @@ describe('MAP_SUPPORTED_FIELDS', () => { expect(MAP_SUPPORTED_FIELDS).toContain('actions'); expect(MAP_SUPPORTED_FIELDS).toContain('themes'); expect(MAP_SUPPORTED_FIELDS).toContain('flows'); - expect(MAP_SUPPORTED_FIELDS).toContain('roles'); + expect(MAP_SUPPORTED_FIELDS).toContain('positions'); expect(MAP_SUPPORTED_FIELDS).toContain('permissions'); expect(MAP_SUPPORTED_FIELDS).toContain('datasources'); expect(MAP_SUPPORTED_FIELDS).toContain('connectors'); diff --git a/packages/spec/src/shared/metadata-collection.zod.ts b/packages/spec/src/shared/metadata-collection.zod.ts index 378beaa8de..cc0c589e86 100644 --- a/packages/spec/src/shared/metadata-collection.zod.ts +++ b/packages/spec/src/shared/metadata-collection.zod.ts @@ -79,7 +79,7 @@ export const MAP_SUPPORTED_FIELDS = [ 'themes', 'flows', 'jobs', - 'roles', + 'positions', 'permissions', 'sharingRules', 'apis', @@ -116,9 +116,8 @@ export const PLURAL_TO_SINGULAR: Record = { themes: 'theme', flows: 'flow', jobs: 'job', - roles: 'role', + positions: 'position', permissions: 'permission', - profiles: 'profile', sharingRules: 'sharing_rule', apis: 'api', webhooks: 'webhook', diff --git a/packages/spec/src/stack.test.ts b/packages/spec/src/stack.test.ts index 27988477d0..e0e8a61408 100644 --- a/packages/spec/src/stack.test.ts +++ b/packages/spec/src/stack.test.ts @@ -494,7 +494,6 @@ describe('defineStack', () => { permissions: [ { name: 'guest_portal', - isProfile: true, // BUG: the object is `crm_lead`, not the short `lead` — the short name // exists nowhere, so this grant silently applies to nothing (and the // anonymous permission path denies the write). Must fail loudly. @@ -514,7 +513,6 @@ describe('defineStack', () => { permissions: [ { name: 'guest_portal', - isProfile: true, objects: { crm_lead: { allowCreate: true } }, }, ], @@ -775,19 +773,19 @@ describe('defineStack - Map Format Support', () => { expect(result.dashboards![0].name).toBe('sales_overview'); }); - it('should support map format for roles', () => { + it('should support map format for positions', () => { const config: ObjectStackDefinitionInput = { manifest: baseManifest, - roles: { + positions: { admin: { label: 'Administrator' }, user: { label: 'Standard User' }, }, }; const result = defineStack(config); - expect(result.roles).toHaveLength(2); - expect(result.roles![0].name).toBe('admin'); - expect(result.roles![1].name).toBe('user'); + expect(result.positions).toHaveLength(2); + expect(result.positions![0].name).toBe('admin'); + expect(result.positions![1].name).toBe('user'); }); it('should support map format for hooks', () => { diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index ee19829939..9ca5e3ff19 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -30,7 +30,7 @@ import { FlowSchema } from './automation/flow.zod'; import { JobSchema } from './system/job.zod'; // Security Protocol -import { RoleSchema } from './identity/role.zod'; +import { PositionSchema } from './identity/position.zod'; import { PermissionSetSchema } from './security/permission.zod'; import { SharingRuleSchema } from './security/sharing.zod'; @@ -232,8 +232,8 @@ export const ObjectStackDefinitionSchema = lazySchema(() => z.object({ /** * ObjectGuard: Security Layer */ - roles: z.array(RoleSchema).optional().describe('User Roles hierarchy'), - permissions: z.array(PermissionSetSchema).optional().describe('Permission Sets and Profiles'), + positions: z.array(PositionSchema).optional().describe('Positions — flat capability-distribution groups (ADR-0090 D3)'), + permissions: z.array(PermissionSetSchema).optional().describe('Permission Sets'), sharingRules: z.array(SharingRuleSchema).optional().describe('Record Sharing Rules'), /** @@ -1025,7 +1025,7 @@ const CONCAT_ARRAY_FIELDS = [ 'actions', 'themes', 'flows', - 'roles', + 'positions', 'permissions', 'sharingRules', 'apis', diff --git a/packages/spec/src/system/constants/system-names.test.ts b/packages/spec/src/system/constants/system-names.test.ts index bfaf14e095..76d51374b9 100644 --- a/packages/spec/src/system/constants/system-names.test.ts +++ b/packages/spec/src/system/constants/system-names.test.ts @@ -29,7 +29,7 @@ describe('SystemObjectName', () => { expect(SystemObjectName.DEVICE_CODE).toBe('sys_device_code'); expect(SystemObjectName.JWKS).toBe('sys_jwks'); expect(SystemObjectName.USER_PREFERENCE).toBe('sys_user_preference'); - expect(SystemObjectName.ROLE).toBe('sys_role'); + expect(SystemObjectName.POSITION).toBe('sys_position'); expect(SystemObjectName.PERMISSION_SET).toBe('sys_permission_set'); expect(SystemObjectName.AUDIT_LOG).toBe('sys_audit_log'); expect(SystemObjectName.METADATA).toBe('sys_metadata'); @@ -43,7 +43,7 @@ describe('SystemObjectName', () => { expect(names).toContain('sys_organization'); expect(names).toContain('sys_team'); expect(names).toContain('sys_team_member'); - expect(names).toContain('sys_role'); + expect(names).toContain('sys_position'); expect(names).toContain('sys_audit_log'); expect(names).toContain('sys_presence'); }); @@ -62,7 +62,7 @@ describe('SystemObjectName', () => { expect(keys).toContain('API_KEY'); expect(keys).toContain('TWO_FACTOR'); expect(keys).toContain('USER_PREFERENCE'); - expect(keys).toContain('ROLE'); + expect(keys).toContain('POSITION'); expect(keys).toContain('PERMISSION_SET'); expect(keys).toContain('AUDIT_LOG'); expect(keys).toContain('METADATA'); diff --git a/packages/spec/src/system/constants/system-names.ts b/packages/spec/src/system/constants/system-names.ts index 77af026c9b..616954484c 100644 --- a/packages/spec/src/system/constants/system-names.ts +++ b/packages/spec/src/system/constants/system-names.ts @@ -56,8 +56,8 @@ export const SystemObjectName = { JWKS: 'sys_jwks', /** Authentication: user preferences (theme, locale, etc.) */ USER_PREFERENCE: 'sys_user_preference', - /** Security: role definition for RBAC */ - ROLE: 'sys_role', + /** Security: position definition (flat capability-distribution group, ADR-0090 D3) */ + POSITION: 'sys_position', /** Security: permission set grouping */ PERMISSION_SET: 'sys_permission_set', /** Audit: system audit log */ diff --git a/packages/spec/src/system/metadata-form-registry.ts b/packages/spec/src/system/metadata-form-registry.ts index fb3114fb0c..dcdc2784ea 100644 --- a/packages/spec/src/system/metadata-form-registry.ts +++ b/packages/spec/src/system/metadata-form-registry.ts @@ -42,7 +42,7 @@ import { pageForm, reportForm, } from '../ui'; -import { roleForm } from '../identity'; +import { positionForm } from '../identity'; import { permissionForm } from '../security'; import { agentForm, toolForm, skillForm } from '../ai'; import { flowForm } from '../automation'; @@ -72,7 +72,7 @@ export const METADATA_FORM_REGISTRY: Readonly> = Object view: viewForm, app: appForm, dashboard: dashboardForm, - role: roleForm, + role: positionForm, action: actionForm, page: pageForm, agent: agentForm, diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index c513cedb12..0d46399492 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -46,7 +46,7 @@ const BaseNavItemSchema = z.object({ * Formula expression returning boolean. * e.g. "user.is_admin || user.department == 'sales'" */ - visible: ExpressionInputSchema.optional().describe('Visibility predicate (CEL). e.g. P`\'org_admin\' in current_user.roles`'), + visible: ExpressionInputSchema.optional().describe('Visibility predicate (CEL). e.g. P`\'org_admin\' in current_user.positions`'), /** Permissions required to see/access this navigation item */ requiredPermissions: z.array(z.string()).optional().describe('Permissions required to access this item'), diff --git a/packages/triggers/trigger-record-change/src/record-change-trigger.ts b/packages/triggers/trigger-record-change/src/record-change-trigger.ts index 0645399b65..d2b7de3d6f 100644 --- a/packages/triggers/trigger-record-change/src/record-change-trigger.ts +++ b/packages/triggers/trigger-record-change/src/record-change-trigger.ts @@ -189,7 +189,7 @@ export class RecordChangeTrigger implements FlowTrigger { { ...(inputDoc ?? {}), ...after } : inputDoc ?? (previous && typeof previous === 'object' ? previous : {}); - const session = (ctx.session ?? {}) as { userId?: string; tenantId?: string; roles?: string[] }; + const session = (ctx.session ?? {}) as { userId?: string; tenantId?: string; positions?: string[] }; return { record, @@ -200,7 +200,7 @@ export class RecordChangeTrigger implements FlowTrigger { // Forward the writer's roles/tenant so a `runAs:'user'` flow enforces // RLS exactly as the user who made the change, not a member fallback // (#1888). The engine elevates only for `runAs:'system'`. - ...(Array.isArray(session.roles) && session.roles.length ? { roles: session.roles } : {}), + ...(Array.isArray(session.positions) && session.positions.length ? { positions: session.positions } : {}), ...(session.tenantId ? { tenantId: session.tenantId } : {}), // Expose the record as params too, so flows with named `isInput` // variables matching record fields get them seeded. From 21eab0e08c6e17b4a07353072f81e97a66785155 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 02:33:27 +0000 Subject: [PATCH 2/9] fix(liveness,contract): position ledger rename + externalSharingModel classification + downstream fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Liveness: GOVERNED kind role→position; liveness/role.json→position.json (flat, parent dropped); permission ledger rowLevelSecurity.positions; object ledger gains externalSharingModel as 'authorable' with the P1 shape-only rationale and the #2696 pointer. Downstream-contract sharing fixture moves to the position recipient. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H --- .../src/additional-domains.fixtures.ts | 2 +- packages/spec/liveness/object.json | 4 ++++ packages/spec/liveness/permission.json | 4 ++-- packages/spec/liveness/position.json | 19 +++++++++++++++ packages/spec/liveness/role.json | 24 ------------------- .../spec/scripts/liveness/check-liveness.mts | 2 +- 6 files changed, 27 insertions(+), 28 deletions(-) create mode 100644 packages/spec/liveness/position.json delete mode 100644 packages/spec/liveness/role.json diff --git a/packages/downstream-contract/src/additional-domains.fixtures.ts b/packages/downstream-contract/src/additional-domains.fixtures.ts index 8389c7fcda..e1c0adcbff 100644 --- a/packages/downstream-contract/src/additional-domains.fixtures.ts +++ b/packages/downstream-contract/src/additional-domains.fixtures.ts @@ -50,7 +50,7 @@ export const DcSharingRule: SharingRuleInput = { object: 'dc_account', condition: 'record.stage == "customer"', accessLevel: 'read', - sharedWith: { type: 'role', value: 'dc_manager' }, + sharedWith: { type: 'position', value: 'dc_manager' }, active: true, }; diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index 38e6e5fc97..207ffe4a82 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -248,6 +248,10 @@ "searchableFields": { "status": "live", "note": "objectql `$search` executor (ADR-0061: expandSearchToFilter in engine.find) + objectui list/lookup/command-palette; canonical searchable-field source." + }, + "externalSharingModel": { + "status": "authorable", + "note": "[ADR-0090 D11] P1 lands the SPEC SHAPE only (validated external<=internal at authoring). Runtime consumption (audience-aware evaluator branch) is scheduled with the principal-taxonomy semantics phase; tracked on the ADR-0090 tracking issue (#2696)." } } } diff --git a/packages/spec/liveness/permission.json b/packages/spec/liveness/permission.json index ec550ae7ac..0b6d6efc06 100644 --- a/packages/spec/liveness/permission.json +++ b/packages/spec/liveness/permission.json @@ -139,10 +139,10 @@ "status": "live", "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts" }, - "roles": { + "positions": { "status": "live", "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts", - "note": "flat match — no subordinate rollup." + "note": "flat match — no subordinate rollup (ADR-0090 D3 rename)." }, "enabled": { "status": "live", diff --git a/packages/spec/liveness/position.json b/packages/spec/liveness/position.json new file mode 100644 index 0000000000..db78c344ff --- /dev/null +++ b/packages/spec/liveness/position.json @@ -0,0 +1,19 @@ +{ + "type": "position", + "_note": "PositionSchema (ADR-0090 D3; formerly RoleSchema). Flat capability-distribution group.", + "props": { + "name": { + "status": "live", + "evidence": "packages/core/src/security/resolve-authz-context.ts:210", + "note": "sys_position.name keys position-bound permission-set resolution and ctx.positions." + }, + "label": { + "status": "live", + "note": "display (admin nav/forms, security-plugin.ts)." + }, + "description": { + "status": "live", + "note": "display." + } + } +} diff --git a/packages/spec/liveness/role.json b/packages/spec/liveness/role.json deleted file mode 100644 index 7e161e23e0..0000000000 --- a/packages/spec/liveness/role.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "type": "role", - "_note": "RoleSchema. Seeded from docs/audits/2026-06-security-identity-property-liveness.md.", - "props": { - "name": { - "status": "live", - "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:113", - "note": "sys_role.name reused as a permission-set name." - }, - "label": { - "status": "live", - "note": "display (admin nav/forms, security-plugin.ts:153)." - }, - "description": { - "status": "live", - "note": "display." - }, - "parent": { - "status": "live", - "evidence": "objectui: packages/app-shell/src/views/metadata-admin/previews/RolePreview.tsx:30 (const parent = d.parent)", - "note": "LIVE via objectui metadata-admin authoring UI — the 2026-06 audit missed objectui; verified by reading the file." - } - } -} diff --git a/packages/spec/scripts/liveness/check-liveness.mts b/packages/spec/scripts/liveness/check-liveness.mts index fdf20b514c..04e6d0867d 100644 --- a/packages/spec/scripts/liveness/check-liveness.mts +++ b/packages/spec/scripts/liveness/check-liveness.mts @@ -53,7 +53,7 @@ const repoRoot = resolve(specRoot, '../..'); const ledgerRoot = join(specRoot, 'liveness'); // Governed metadata types, rolled out highest-frequency / highest-risk first. -const GOVERNED = ['object', 'field', 'flow', 'action', 'hook', 'permission', 'role', 'agent', 'tool', 'skill', 'dataset', 'page']; +const GOVERNED = ['object', 'field', 'flow', 'action', 'hook', 'permission', 'position', 'agent', 'tool', 'skill', 'dataset', 'page']; // ADR-0010 provenance/lock overlay fields — system-stamped, on every type; auto-live. const FRAMEWORK_FIELDS = new Set([ From 14db71bd80f05645613daceb9abf404ef3189198 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 02:37:25 +0000 Subject: [PATCH 3/9] fix(examples): showcase stack positions property, coverage kind, position identifiers Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H --- examples/app-crm/src/security/sales-positions.ts | 1 - examples/app-showcase/objectstack.config.ts | 4 ++-- examples/app-showcase/src/coverage.ts | 7 +++---- examples/app-showcase/src/security/index.ts | 8 ++++---- examples/app-showcase/test/seed.test.ts | 2 +- 5 files changed, 10 insertions(+), 12 deletions(-) diff --git a/examples/app-crm/src/security/sales-positions.ts b/examples/app-crm/src/security/sales-positions.ts index 08000817c6..d410219d15 100644 --- a/examples/app-crm/src/security/sales-positions.ts +++ b/examples/app-crm/src/security/sales-positions.ts @@ -16,7 +16,6 @@ export const SalesManagerPosition = definePosition({ name: 'sales_manager', label: 'Sales Manager', description: 'Manages a team of sales reps.', - parent: 'sales_rep', }); /** Referenced by the Discount Approval second step. */ diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index 27c901909c..d7c23afbf4 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -31,7 +31,7 @@ import { allEmails } from './src/system/emails/index.js'; import { allBooks } from './src/system/books/index.js'; import { allApis } from './src/system/apis/index.js'; import { - allRoles, + allPositions, allPermissionSets, allSharingRules, } from './src/security/index.js'; @@ -192,7 +192,7 @@ export default defineStack({ webhooks: allWebhooks, // Security - roles: allRoles, + positions: allPositions, permissions: allPermissionSets, sharingRules: allSharingRules, diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts index 36d605a6e1..b450f35881 100644 --- a/examples/app-showcase/src/coverage.ts +++ b/examples/app-showcase/src/coverage.ts @@ -119,13 +119,12 @@ export const KIND_COVERAGE: Record = { }, // ── security ── - permission: { status: 'demonstrated', files: ['src/security/index.ts'] }, - profile: { + permission: { status: 'demonstrated', files: ['src/security/index.ts'], - notes: 'MemberDefault — the isDefault permission set (ADR-0090 D5 suggestion flag).', + notes: 'Includes MemberDefault — the isDefault permission set (ADR-0090 D5 suggestion flag).', }, - role: { status: 'demonstrated', files: ['src/security/index.ts'] }, + position: { status: 'demonstrated', files: ['src/security/index.ts'] }, // ── ai ── agent: { diff --git a/examples/app-showcase/src/security/index.ts b/examples/app-showcase/src/security/index.ts index d8cceff104..01bf192e01 100644 --- a/examples/app-showcase/src/security/index.ts +++ b/examples/app-showcase/src/security/index.ts @@ -10,20 +10,20 @@ */ // ── Roles (hierarchy) ────────────────────────────────────────────────────── -export const ContributorRole = { +export const ContributorPosition = { name: 'contributor', label: 'Contributor', description: 'Works tasks on their own projects.', }; -export const ManagerRole = { +export const ManagerPosition = { name: 'manager', label: 'Project Manager', description: 'Manages projects and the contributors on them.', parent: 'contributor', }; -export const ExecRole = { +export const ExecPosition = { name: 'exec', label: 'Executive', description: 'Read-all visibility for reporting.', @@ -171,6 +171,6 @@ export const ContributorTaskSharingRule = { }; -export const allRoles = [ContributorRole, ManagerRole, ExecRole]; +export const allPositions = [ContributorPosition, ManagerPosition, ExecPosition]; export const allPermissionSets = [ContributorPermissionSet, MemberDefaultProfile]; export const allSharingRules = [RedProjectSharingRule, HighValueRedProjectRule, ContributorTaskSharingRule]; diff --git a/examples/app-showcase/test/seed.test.ts b/examples/app-showcase/test/seed.test.ts index f5709cd524..4973155d85 100644 --- a/examples/app-showcase/test/seed.test.ts +++ b/examples/app-showcase/test/seed.test.ts @@ -25,7 +25,7 @@ describe('showcase stack', () => { // leaving 3 dataset-bound analytics reports. expect((stack.reports ?? []).length).toBe(3); expect((stack.flows ?? []).length).toBeGreaterThan(0); - expect((stack.roles ?? []).length).toBe(3); + expect((stack.positions ?? []).length).toBe(3); expect((stack.agents ?? []).length).toBe(0); // AI agents are an enterprise (service-ai) feature; the open showcase ships none }); }); From b2bcae1b51d71241cffb76863100cdf283a3e8eb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 03:04:06 +0000 Subject: [PATCH 4/9] =?UTF-8?q?fix(runtime,sharing,examples):=20app-plugin?= =?UTF-8?q?=20position=20sections,=20runtime=20recipient=20enum,=20D1=20gr?= =?UTF-8?q?andfather=20stamps=20=E2=80=94=20dogfood=2039/39=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app-plugin: stack section registration positions→'position' kind (the seeding gap: declared positions never reached the registry); standalone artifact surface roles→positions; CLI diff/stats keys. - sys_sharing_rule.recipient_type runtime enum role→position (seeding inserted 'position' and was rejected by the stale enum), description updated to the BU-subtree semantics of unit_and_subordinates. - ADR-0090 D1 grandfather stamps: explicit `sharingModel: 'public_read_write'` on showcase/dogfood fixture objects whose isolation is RLS-owned or intentionally public (they carry an auto-injected owner_id, so the new unset⇒private default would owner-filter them). - dogfood expectations updated to v2 semantics (recipient 'position'; appDefaultPermissionSetName is isDefault-only). - service-datasource test: explicit casts for Response.json() unknowns (latent, surfaced by the affected-package typecheck). Full dogfood suite 39 files / 191 tests green; repo-wide typecheck clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H --- .../app-showcase/src/data/objects/account.object.ts | 4 ++++ .../src/data/objects/business-unit.object.ts | 4 ++++ .../src/data/objects/category.object.ts | 4 ++++ .../src/data/objects/external/customer.object.ts | 4 ++++ .../src/data/objects/external/order.object.ts | 4 ++++ .../src/data/objects/field-zoo.object.ts | 4 ++++ .../app-showcase/src/data/objects/invoice.object.ts | 4 ++++ .../src/data/objects/preference.object.ts | 4 ++++ .../app-showcase/src/data/objects/project.object.ts | 4 ++++ .../src/data/objects/semantic-zoo.object.ts | 4 ++++ .../app-showcase/src/data/objects/task.object.ts | 4 ++++ .../app-showcase/src/data/objects/team.object.ts | 4 ++++ packages/cli/src/commands/diff.ts | 2 +- packages/dogfood/test/fixtures/cbp-fixture.ts | 2 ++ .../dogfood/test/fixtures/flow-runas-fixture.ts | 3 +++ .../dogfood/test/fixtures/flow-touch-fixture.ts | 6 ++++++ packages/dogfood/test/fixtures/rls-owner-fixture.ts | 3 +++ ...howcase-declarative-rbac-seeding.dogfood.test.ts | 2 +- .../showcase-scope-depth-fallback.dogfood.test.ts | 2 +- .../src/objects/sys-sharing-rule.object.ts | 4 ++-- packages/runtime/src/app-plugin.ts | 13 +++++++------ packages/runtime/src/standalone-stack.ts | 12 ++++++------ .../src/__tests__/admin-routes.test.ts | 4 ++-- 23 files changed, 82 insertions(+), 19 deletions(-) diff --git a/examples/app-showcase/src/data/objects/account.object.ts b/examples/app-showcase/src/data/objects/account.object.ts index eb5f326096..a930a391c8 100644 --- a/examples/app-showcase/src/data/objects/account.object.ts +++ b/examples/app-showcase/src/data/objects/account.object.ts @@ -14,6 +14,10 @@ import { P } from '@objectstack/spec'; */ export const Account = ObjectSchema.create({ name: 'showcase_account', + // [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: 'Account', pluralLabel: 'Accounts', icon: 'building', diff --git a/examples/app-showcase/src/data/objects/business-unit.object.ts b/examples/app-showcase/src/data/objects/business-unit.object.ts index f5446600c0..520ee8229c 100644 --- a/examples/app-showcase/src/data/objects/business-unit.object.ts +++ b/examples/app-showcase/src/data/objects/business-unit.object.ts @@ -10,6 +10,10 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; */ export const BusinessUnit = ObjectSchema.create({ name: 'showcase_business_unit', + // [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: 'Business Unit', pluralLabel: 'Business Units', icon: 'network', diff --git a/examples/app-showcase/src/data/objects/category.object.ts b/examples/app-showcase/src/data/objects/category.object.ts index d36038a0bf..c6fcf6230b 100644 --- a/examples/app-showcase/src/data/objects/category.object.ts +++ b/examples/app-showcase/src/data/objects/category.object.ts @@ -8,6 +8,10 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; */ export const Category = ObjectSchema.create({ name: 'showcase_category', + // [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: 'Category', pluralLabel: 'Categories', icon: 'list-tree', diff --git a/examples/app-showcase/src/data/objects/external/customer.object.ts b/examples/app-showcase/src/data/objects/external/customer.object.ts index 182766799d..428765ccf3 100644 --- a/examples/app-showcase/src/data/objects/external/customer.object.ts +++ b/examples/app-showcase/src/data/objects/external/customer.object.ts @@ -11,6 +11,10 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; */ export const ExternalCustomer = ObjectSchema.create({ name: 'showcase_ext_customer', + // [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: 'External Customer', pluralLabel: 'External Customers', icon: 'database', diff --git a/examples/app-showcase/src/data/objects/external/order.object.ts b/examples/app-showcase/src/data/objects/external/order.object.ts index e8e5f521ed..893f61c17a 100644 --- a/examples/app-showcase/src/data/objects/external/order.object.ts +++ b/examples/app-showcase/src/data/objects/external/order.object.ts @@ -9,6 +9,10 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; */ export const ExternalOrder = ObjectSchema.create({ name: 'showcase_ext_order', + // [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: 'External Order', pluralLabel: 'External Orders', icon: 'shopping-cart', diff --git a/examples/app-showcase/src/data/objects/field-zoo.object.ts b/examples/app-showcase/src/data/objects/field-zoo.object.ts index c0c5471ded..0461628472 100644 --- a/examples/app-showcase/src/data/objects/field-zoo.object.ts +++ b/examples/app-showcase/src/data/objects/field-zoo.object.ts @@ -19,6 +19,10 @@ import { cel } from '@objectstack/spec'; */ export const FieldZoo = ObjectSchema.create({ name: 'showcase_field_zoo', + // [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: 'Field Zoo', pluralLabel: 'Field Zoo', icon: 'shapes', diff --git a/examples/app-showcase/src/data/objects/invoice.object.ts b/examples/app-showcase/src/data/objects/invoice.object.ts index d8c18e68cb..ffb6dc96c5 100644 --- a/examples/app-showcase/src/data/objects/invoice.object.ts +++ b/examples/app-showcase/src/data/objects/invoice.object.ts @@ -12,6 +12,8 @@ import { cel, P } from '@objectstack/spec'; */ export const Product = ObjectSchema.create({ name: 'showcase_product', + // [ADR-0090 D1] grandfather stamp: public demo catalog data. + sharingModel: 'public_read_write', label: 'Product', pluralLabel: 'Products', icon: 'package', @@ -37,6 +39,8 @@ export const Product = ObjectSchema.create({ */ export const Invoice = ObjectSchema.create({ name: 'showcase_invoice', + // [ADR-0090 D1] grandfather stamp: isolation is RLS-owned (owner == current_user.email); lines derive via controlled_by_parent. + sharingModel: 'public_read_write', label: 'Invoice', pluralLabel: 'Invoices', icon: 'receipt', diff --git a/examples/app-showcase/src/data/objects/preference.object.ts b/examples/app-showcase/src/data/objects/preference.object.ts index 0ad7d13c89..ea8923219a 100644 --- a/examples/app-showcase/src/data/objects/preference.object.ts +++ b/examples/app-showcase/src/data/objects/preference.object.ts @@ -9,6 +9,10 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; */ export const Preference = ObjectSchema.create({ name: 'showcase_preference', + // [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: 'Setting', pluralLabel: 'Settings', icon: 'settings', diff --git a/examples/app-showcase/src/data/objects/project.object.ts b/examples/app-showcase/src/data/objects/project.object.ts index 46ee2d4e00..07d5ce2874 100644 --- a/examples/app-showcase/src/data/objects/project.object.ts +++ b/examples/app-showcase/src/data/objects/project.object.ts @@ -10,6 +10,10 @@ import { cel, P } from '@objectstack/spec'; */ export const Project = ObjectSchema.create({ name: 'showcase_project', + // [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', pluralLabel: 'Projects', icon: 'folder-kanban', 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 ac56728afd..cd769160cb 100644 --- a/examples/app-showcase/src/data/objects/semantic-zoo.object.ts +++ b/examples/app-showcase/src/data/objects/semantic-zoo.object.ts @@ -22,6 +22,10 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; export const SemanticZoo = ObjectSchema.create({ name: 'showcase_semantic_zoo', + // [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', pluralLabel: 'Semantic Zoos', icon: 'flask-conical', diff --git a/examples/app-showcase/src/data/objects/task.object.ts b/examples/app-showcase/src/data/objects/task.object.ts index 6ead30125f..e9af404cbf 100644 --- a/examples/app-showcase/src/data/objects/task.object.ts +++ b/examples/app-showcase/src/data/objects/task.object.ts @@ -17,6 +17,10 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; */ export const Task = ObjectSchema.create({ name: 'showcase_task', + // [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: 'Task', pluralLabel: 'Tasks', icon: 'check-square', diff --git a/examples/app-showcase/src/data/objects/team.object.ts b/examples/app-showcase/src/data/objects/team.object.ts index 47a9c8fc4a..1d2f7614a0 100644 --- a/examples/app-showcase/src/data/objects/team.object.ts +++ b/examples/app-showcase/src/data/objects/team.object.ts @@ -9,6 +9,10 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; */ export const Team = ObjectSchema.create({ name: 'showcase_team', + // [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: 'Team', pluralLabel: 'Teams', icon: 'users', diff --git a/packages/cli/src/commands/diff.ts b/packages/cli/src/commands/diff.ts index 492f243810..31719111d2 100644 --- a/packages/cli/src/commands/diff.ts +++ b/packages/cli/src/commands/diff.ts @@ -213,7 +213,7 @@ export default class Diff extends Command { { key: 'actions', label: 'actions' }, { key: 'workflows', label: 'workflows' }, { key: 'apis', label: 'apis' }, - { key: 'roles', label: 'roles' }, + { key: 'positions', label: 'positions' }, ]; for (const cat of simpleCats) { diff --git a/packages/dogfood/test/fixtures/cbp-fixture.ts b/packages/dogfood/test/fixtures/cbp-fixture.ts index 8db84df7d9..769b88e793 100644 --- a/packages/dogfood/test/fixtures/cbp-fixture.ts +++ b/packages/dogfood/test/fixtures/cbp-fixture.ts @@ -21,6 +21,8 @@ import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plug /** MASTER — owner-scoped account. */ export const CbpAccount = ObjectSchema.create({ name: 'cbp_account', + // [ADR-0090 D1] grandfather stamp: master isolation under test is RLS-owned; the detail derives via controlled_by_parent. + sharingModel: 'public_read_write', label: 'CBP Account', pluralLabel: 'CBP Accounts', fields: { diff --git a/packages/dogfood/test/fixtures/flow-runas-fixture.ts b/packages/dogfood/test/fixtures/flow-runas-fixture.ts index 03a7ec88a5..877504088e 100644 --- a/packages/dogfood/test/fixtures/flow-runas-fixture.ts +++ b/packages/dogfood/test/fixtures/flow-runas-fixture.ts @@ -35,6 +35,9 @@ import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plug /** The one object under test: an owner-scoped note (isolated via `created_by`). */ export const RunAsNote = ObjectSchema.create({ name: 'runas_note', + // [ADR-0090 D1] grandfather stamp: this fixture's gate under test is + // permission-set RLS / flow scoping, not owner-sharing. + sharingModel: 'public_read_write', label: 'RunAs Note', pluralLabel: 'RunAs Notes', fields: { diff --git a/packages/dogfood/test/fixtures/flow-touch-fixture.ts b/packages/dogfood/test/fixtures/flow-touch-fixture.ts index 2b68c7a2bd..eb237a138b 100644 --- a/packages/dogfood/test/fixtures/flow-touch-fixture.ts +++ b/packages/dogfood/test/fixtures/flow-touch-fixture.ts @@ -25,6 +25,9 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; /** The one object under test: a note the flow stamps as processed. */ export const FlowNote = ObjectSchema.create({ name: 'flow_note', + // [ADR-0090 D1] grandfather stamp: this fixture's gate under test is + // permission-set RLS / flow scoping, not owner-sharing. + sharingModel: 'public_read_write', label: 'Flow Note', pluralLabel: 'Flow Notes', fields: { @@ -41,6 +44,9 @@ export const FlowNote = ObjectSchema.create({ */ export const flowTouch = { name: 'flow_touch', + // [ADR-0090 D1] grandfather stamp: this fixture's gate under test is + // permission-set RLS / flow scoping, not owner-sharing. + sharingModel: 'public_read_write', label: 'Flow Touch', type: 'autolaunched', variables: [{ name: 'noteId', type: 'text', isInput: true }], diff --git a/packages/dogfood/test/fixtures/rls-owner-fixture.ts b/packages/dogfood/test/fixtures/rls-owner-fixture.ts index 372c6c390c..9bc05a0b41 100644 --- a/packages/dogfood/test/fixtures/rls-owner-fixture.ts +++ b/packages/dogfood/test/fixtures/rls-owner-fixture.ts @@ -35,6 +35,9 @@ import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plug /** The one object under test: a private note, owner-scoped via `created_by`. */ export const RlsNote = ObjectSchema.create({ name: 'rls_note', + // [ADR-0090 D1] grandfather stamp: this fixture's gate under test is + // permission-set RLS / flow scoping, not owner-sharing. + sharingModel: 'public_read_write', label: 'RLS Note', pluralLabel: 'RLS Notes', fields: { diff --git a/packages/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts b/packages/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts index 11a7bbed42..53f62ba2f9 100644 --- a/packages/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts +++ b/packages/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts @@ -35,7 +35,7 @@ describe('showcase: declarative RBAC seeding (ADR-0057 D6 / #2077)', () => { const red = (rules ?? []).find((r: any) => r.name === 'share_red_projects_with_execs'); expect(red, 'criteria rule seeded (was count = 0)').toBeTruthy(); expect(red.object_name).toBe('showcase_project'); - expect(red.recipient_type).toBe('role'); + expect(red.recipient_type).toBe('position'); expect(red.recipient_id).toBe('exec'); // condition "record.health == 'red'" → JSON FilterCondition { health: 'red' } const criteria = JSON.parse(red.criteria_json); diff --git a/packages/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts b/packages/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts index ab5a1bce07..f30950e0c8 100644 --- a/packages/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts +++ b/packages/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts @@ -137,7 +137,7 @@ describe('app-default-permission-set via fallbackPermissionSet (ADR-0056 D7 / AD it('appDefaultPermissionSetName picks the isDefault profile name (the CLI helper)', () => { expect(appDefaultPermissionSetName([DEFAULT_PROFILE_METADATA])).toBe(PROFILE_NAME); // an add-on (isDefault absent) is never chosen as the default - expect(appDefaultPermissionSetName([{ name: 'addon', isDefault: true }])).toBeUndefined(); + expect(appDefaultPermissionSetName([{ name: 'addon', isDefault: true }])).toBe('addon'); }); }); diff --git a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts index 11ecf5d9f8..26688c7504 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts @@ -132,7 +132,7 @@ export const SysSharingRule = ObjectSchema.create({ }), recipient_type: Field.select( - ['user', 'team', 'business_unit', 'role', 'unit_and_subordinates', 'queue'], + ['user', 'team', 'business_unit', 'position', 'unit_and_subordinates', 'queue'], { label: 'Recipient Type', required: true, @@ -146,7 +146,7 @@ export const SysSharingRule = ObjectSchema.create({ label: 'Recipient', required: true, maxLength: 200, - description: 'department id / team id / role name / queue name / user id depending on recipient_type', + description: 'business-unit id / team id / position name / queue name / user id depending on recipient_type', group: 'Recipient', }), diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index ef9912de06..55018cd4c1 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -66,7 +66,7 @@ export class AppPlugin implements Plugin { const APP_CATEGORY_KEYS = [ 'objects', 'views', 'apps', 'pages', 'dashboards', 'reports', 'flows', 'workflows', 'triggers', 'agents', 'tools', 'skills', - 'actions', 'permissions', 'roles', 'profiles', 'translations', + 'actions', 'permissions', 'positions', 'translations', 'sharingRules', 'ragPipelines', 'data', 'emailTemplates', 'docs', 'books', ]; @@ -399,10 +399,11 @@ export class AppPlugin implements Plugin { throw err; } - // [ADR-0057 / #2077] Surface stack-declared SECURITY metadata (roles, - // permission sets, sharing rules, policies) in the metadata registry so - // the boot seeders (plugin-security / plugin-sharing) and runtime - // resolvers can read them via `list('role'|'permission'|'sharing_rule')`. + // [ADR-0057 / #2077] Surface stack-declared SECURITY metadata + // (positions, permission sets, sharing rules, policies) in the + // metadata registry so the boot seeders (plugin-security / + // plugin-sharing) and runtime resolvers can read them via + // `list('position'|'permission'|'sharing_rule')`. // Without this, bootStack's metadata service holds only objects (the // artifact loader that registers these runs only in compiled serve.ts), // leaving the declarations decorative. @@ -415,7 +416,7 @@ export class AppPlugin implements Plugin { ? { ...this.bundle.manifest, ...this.bundle } : this.bundle; const SECURITY_FIELDS: Array<[string, string]> = [ - ['roles', 'role'], + ['positions', 'position'], ['permissions', 'permission'], ['sharingRules', 'sharing_rule'], ['policies', 'policy'], diff --git a/packages/runtime/src/standalone-stack.ts b/packages/runtime/src/standalone-stack.ts index 3ca6f90480..ce260f8752 100644 --- a/packages/runtime/src/standalone-stack.ts +++ b/packages/runtime/src/standalone-stack.ts @@ -101,14 +101,14 @@ export interface StandaloneStackResult { * can wire it without a host `objectstack.config.ts`. In particular the * `serve` command reads `permissions[]` to honour an app-declared default * profile (ADR-0056 D7 — `appDefaultPermissionSetName` → SecurityPlugin - * `fallbackPermissionSet`) and reads both `roles[]` and `permissions[]` to + * `fallbackPermissionSet`) and reads both `positions[]` and `permissions[]` to * register application org roles with Better-Auth. Without these the * artifact-serve path silently fell back to the built-in `member_default` * (owner-only), so an `isDefault` profile declared purely in app metadata * was ignored under `objectstack dev`. */ permissions?: any[]; - roles?: any[]; + positions?: any[]; } type ResolvedDriverKind = 'memory' | 'postgres' | 'mongodb' | 'sqlite' | 'sqlite-wasm'; @@ -291,11 +291,11 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro const manifest: any | undefined = artifactBundle?.manifest; // ADR-0056 D7 — surface app-declared RBAC so the CLI's artifact-serve // path honours an `isDefault` profile (appDefaultPermissionSetName) and - // registers application org roles, exactly like the config-load path. + // registers application org names, exactly like the config-load path. const permissions: any[] | undefined = Array.isArray(artifactBundle?.permissions) ? artifactBundle.permissions : undefined; - const roles: any[] | undefined = - Array.isArray(artifactBundle?.roles) ? artifactBundle.roles : undefined; + const positions: any[] | undefined = + Array.isArray(artifactBundle?.positions) ? artifactBundle.positions : undefined; return { plugins, @@ -307,6 +307,6 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro ...(objects ? { objects } : {}), ...(manifest ? { manifest } : {}), ...(permissions ? { permissions } : {}), - ...(roles ? { roles } : {}), + ...(positions ? { positions } : {}), }; } diff --git a/packages/services/service-datasource/src/__tests__/admin-routes.test.ts b/packages/services/service-datasource/src/__tests__/admin-routes.test.ts index 91265762c9..71a36d5377 100644 --- a/packages/services/service-datasource/src/__tests__/admin-routes.test.ts +++ b/packages/services/service-datasource/src/__tests__/admin-routes.test.ts @@ -72,7 +72,7 @@ describe('registerDatasourceAdminRoutes (real HonoHttpServer)', () => { const ok = await app.fetch(json('/api/v1/datasources/demo_ext/object-draft', { method: 'POST', body: JSON.stringify({ table: 'customers' }) })); expect(ok.status).toBe(200); - expect((await ok.json()).draft.name).toBe('customers'); + expect(((await ok.json()) as any).draft.name).toBe('customers'); expect(generateObjectDraft).toHaveBeenCalledWith('demo_ext', 'customers', {}); }); @@ -85,7 +85,7 @@ describe('registerDatasourceAdminRoutes (real HonoHttpServer)', () => { const app = mount({ getDatasource }); const ok = await app.fetch(json('/api/v1/datasources/demo_ext')); expect(ok.status).toBe(200); - expect((await ok.json()).datasource.config.filename).toBe('/tmp/x.db'); + expect(((await ok.json()) as any).datasource.config.filename).toBe('/tmp/x.db'); const missing = await app.fetch(json('/api/v1/datasources/nope')); expect(missing.status).toBe(404); expect(getDatasource).toHaveBeenCalledWith('demo_ext'); From 7df59bb93e68e9dad8c649adb5554677ac0244b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 03:15:06 +0000 Subject: [PATCH 5/9] fix(spec,automation,core): regenerate API-surface snapshot for the position rename; runas/authz test fixtures to v2 field names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec export surface intentionally changed (RoleSchema→PositionSchema, definePosition, ShareRecipientType 'position', ExecutionContext.positions, principal-taxonomy fields, externalSharingModel) — snapshot regenerated per the gate's documented flow. Test fixtures updated: sys_user_position rows use the 'position' field, sys_position_permission_set uses position_id, automation trigger identity uses positions[]. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H --- .../security/resolve-authz-context.test.ts | 6 +-- .../src/builtin/crud-runas.test.ts | 6 +-- packages/spec/api-surface-signatures.json | 2 +- packages/spec/api-surface.json | 40 +++++++++---------- 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/packages/core/src/security/resolve-authz-context.test.ts b/packages/core/src/security/resolve-authz-context.test.ts index c8270e3935..b21a3379a0 100644 --- a/packages/core/src/security/resolve-authz-context.test.ts +++ b/packages/core/src/security/resolve-authz-context.test.ts @@ -34,7 +34,7 @@ describe('resolveAuthzContext — single source of truth', () => { const ql = makeQl({ sys_user: [{ id: 'u1', email: 'ada@x.com' }], sys_member: [], - sys_user_position: [{ user_id: 'u1', role: 'contributor', organization_id: null }], + sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: null }], sys_user_permission_set: [], }); const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); @@ -56,10 +56,10 @@ describe('resolveAuthzContext — single source of truth', () => { const ql = makeQl({ sys_user: [{ id: 'u1' }], sys_member: [], - sys_user_position: [{ user_id: 'u1', role: 'contributor', organization_id: null }], + sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: null }], sys_user_permission_set: [], sys_position: [{ id: 'r1', name: 'contributor' }], - sys_position_permission_set: [{ role_id: 'r1', permission_set_id: 'ps1' }], + sys_position_permission_set: [{ position_id: 'r1', permission_set_id: 'ps1' }], sys_permission_set: [{ id: 'ps1', name: 'contributor_ps', system_permissions: ['cap_x'] }], }); const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') }); diff --git a/packages/services/service-automation/src/builtin/crud-runas.test.ts b/packages/services/service-automation/src/builtin/crud-runas.test.ts index 36f490628e..78c49ff574 100644 --- a/packages/services/service-automation/src/builtin/crud-runas.test.ts +++ b/packages/services/service-automation/src/builtin/crud-runas.test.ts @@ -94,7 +94,7 @@ describe('flow.runAs identity enforcement at the data layer (#1888)', () => { registerCrudNodes(engine, ctxWith(data)); engine.registerFlow('usr', allOpsFlow('usr', 'user')); - const res = await engine.execute('usr', { userId: 'u1', roles: ['sales'], tenantId: 'org1' }); + const res = await engine.execute('usr', { userId: 'u1', positions: ['sales'], tenantId: 'org1' }); expect(res.success).toBe(true); for (const c of calls) { @@ -183,8 +183,8 @@ describe('resolveRunDataContext (#1888 unit)', () => { }); it("maps runAs:'user' to the triggering user's identity", () => { - expect(resolveRunDataContext({ runAs: 'user', userId: 'u1', roles: ['r'], tenantId: 't' })).toEqual({ - isSystem: false, userId: 'u1', roles: ['r'], permissions: [], tenantId: 't', + expect(resolveRunDataContext({ runAs: 'user', userId: 'u1', positions: ['r'], tenantId: 't' })).toEqual({ + isSystem: false, userId: 'u1', positions: ['r'], permissions: [], tenantId: 't', }); }); diff --git a/packages/spec/api-surface-signatures.json b/packages/spec/api-surface-signatures.json index bca6990b5d..d4aff2c55c 100644 --- a/packages/spec/api-surface-signatures.json +++ b/packages/spec/api-surface-signatures.json @@ -14,8 +14,8 @@ "defineObjectExtension": "sha256:5286253308912bb1", "definePage": "sha256:e80363c256809a11", "definePermissionSet": "sha256:67b188ae181994cd", + "definePosition": "sha256:a29bcf5084d08518", "defineReport": "sha256:f7e85ba0996a5824", - "defineRole": "sha256:40b8e11786c4ecbb", "defineSharingRule": "sha256:03347645bbda2880", "defineSkill": "sha256:4476c343f35b1521", "defineStack": "sha256:4d36d9603c011c44", diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 3e2d109c31..86aa1d9d13 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -2,13 +2,13 @@ ".": [ "ADMIN_FULL_ACCESS (const)", "Agent (type)", - "BUILTIN_ROLE_METADATA (const)", - "BUILTIN_ROLE_NAMES (const)", - "BUILTIN_ROLE_ORG_ADMIN (const)", - "BUILTIN_ROLE_ORG_MEMBER (const)", - "BUILTIN_ROLE_ORG_OWNER (const)", - "BUILTIN_ROLE_PLATFORM_ADMIN (const)", - "BuiltinRoleName (type)", + "BUILTIN_IDENTITY_METADATA (const)", + "BUILTIN_IDENTITY_NAMES (const)", + "BUILTIN_IDENTITY_ORG_ADMIN (const)", + "BUILTIN_IDENTITY_ORG_MEMBER (const)", + "BUILTIN_IDENTITY_ORG_OWNER (const)", + "BUILTIN_IDENTITY_PLATFORM_ADMIN (const)", + "BuiltinIdentityName (type)", "ComposeStacksOptions (type)", "ComposeStacksOptionsSchema (const)", "ConflictStrategy (type)", @@ -76,8 +76,8 @@ "defineObjectExtension (function)", "definePage (function)", "definePermissionSet (function)", + "definePosition (function)", "defineReport (function)", - "defineRole (function)", "defineSharingRule (function)", "defineSkill (function)", "defineStack (function)", @@ -4174,13 +4174,13 @@ "AuthError (interface)", "AuthHeaders (interface)", "AuthResponse (interface)", - "BUILTIN_ROLE_METADATA (const)", - "BUILTIN_ROLE_NAMES (const)", - "BUILTIN_ROLE_ORG_ADMIN (const)", - "BUILTIN_ROLE_ORG_MEMBER (const)", - "BUILTIN_ROLE_ORG_OWNER (const)", - "BUILTIN_ROLE_PLATFORM_ADMIN (const)", - "BuiltinRoleName (type)", + "BUILTIN_IDENTITY_METADATA (const)", + "BUILTIN_IDENTITY_NAMES (const)", + "BUILTIN_IDENTITY_ORG_ADMIN (const)", + "BUILTIN_IDENTITY_ORG_MEMBER (const)", + "BUILTIN_IDENTITY_ORG_OWNER (const)", + "BUILTIN_IDENTITY_PLATFORM_ADMIN (const)", + "BuiltinIdentityName (type)", "EvalUser (type)", "EvalUserInput (type)", "EvalUserSchema (const)", @@ -4191,9 +4191,9 @@ "MemberSchema (const)", "Organization (type)", "OrganizationSchema (const)", - "Role (type)", - "RoleInput (type)", - "RoleSchema (const)", + "Position (type)", + "PositionInput (type)", + "PositionSchema (const)", "SCIM (const)", "SCIMAddress (type)", "SCIMAddressSchema (const)", @@ -4241,9 +4241,9 @@ "VerificationToken (type)", "VerificationTokenSchema (const)", "createEvalUser (function)", - "defineRole (function)", + "definePosition (function)", "mapMembershipRole (function)", - "roleForm (const)" + "positionForm (const)" ], "./shared": [ "AggregationFunction (type)", From 6b467f556afd66fa75062fd337c7d0362f73951b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 03:15:30 +0000 Subject: [PATCH 6/9] chore(changeset): P1 breaking wave is a MAJOR bump per the API-surface gate Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H --- .changeset/adr-0090-p1-breaking-wave.md | 28 ++++++++++++------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/.changeset/adr-0090-p1-breaking-wave.md b/.changeset/adr-0090-p1-breaking-wave.md index 6297dd1aba..ce0c2b64bc 100644 --- a/.changeset/adr-0090-p1-breaking-wave.md +++ b/.changeset/adr-0090-p1-breaking-wave.md @@ -1,18 +1,18 @@ --- -'@objectstack/spec': minor -'@objectstack/core': minor -'@objectstack/runtime': minor -'@objectstack/objectql': minor -'@objectstack/formula': minor -'@objectstack/rest': minor -'@objectstack/cli': minor -'@objectstack/plugin-security': minor -'@objectstack/plugin-sharing': minor -'@objectstack/plugin-auth': minor -'@objectstack/service-automation': minor -'@objectstack/trigger-record-change': minor -'@objectstack/platform-objects': minor -'@objectstack/metadata': minor +'@objectstack/spec': major +'@objectstack/core': major +'@objectstack/runtime': major +'@objectstack/objectql': major +'@objectstack/formula': major +'@objectstack/rest': major +'@objectstack/cli': major +'@objectstack/plugin-security': major +'@objectstack/plugin-sharing': major +'@objectstack/plugin-auth': major +'@objectstack/service-automation': major +'@objectstack/trigger-record-change': major +'@objectstack/platform-objects': major +'@objectstack/metadata': major --- ADR-0090 P1 breaking wave — permission model v2 concept convergence. From 4db201bc681ca5b72d25ef473802f4c2986cc8f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 03:21:47 +0000 Subject: [PATCH 7/9] test(plugin-security): RLSCompiler fixtures to current_user.positions (v2 vocabulary) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H --- .../src/security-plugin.test.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index 3c9a7115b3..a7550ac21e 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -1682,10 +1682,10 @@ describe('RLSCompiler', () => { it('should compile IN expression with array property', () => { const compiler = new RLSCompiler(); - const policy: any = { object: 'project', operation: 'select', using: 'id IN (current_user.roles)' }; - const ctx: any = { userId: 'u1', tenantId: 't1', roles: ['role-a', 'role-b'] }; + const policy: any = { object: 'project', operation: 'select', using: 'id IN (current_user.positions)' }; + const ctx: any = { userId: 'u1', tenantId: 't1', positions: ['pos-a', 'pos-b'] }; const filter = compiler.compileFilter([policy], ctx); - expect(filter).toEqual({ id: { $in: ['role-a', 'role-b'] } }); + expect(filter).toEqual({ id: { $in: ['pos-a', 'pos-b'] } }); }); it('should compile IN expression against pre-resolved org_user_ids', () => { @@ -1834,19 +1834,19 @@ describe('RLSCompiler', () => { }); it('should not let a rlsMembership key clobber a named context field', () => { - // roles is a first-class field; a hostile/misconfigured membership bag - // must not override it. The named `roles` (['real-role']) wins; the - // policy compiles against it, not the injected ['spoofed']. + // positions is a first-class field; a hostile/misconfigured membership bag + // must not override it. The named `positions` (['real-position']) wins; + // the policy compiles against it, not the injected ['spoofed']. const compiler = new RLSCompiler(); - const policy: any = { object: 'x', operation: 'select', using: 'role_id IN (current_user.roles)' }; + const policy: any = { object: 'x', operation: 'select', using: 'position_id IN (current_user.positions)' }; const ctx: any = { userId: 'u1', tenantId: 'org-1', - roles: ['real-role'], - rlsMembership: { roles: ['spoofed'] }, + positions: ['real-position'], + rlsMembership: { positions: ['spoofed'] }, }; const filter = compiler.compileFilter([policy], ctx); - expect(filter).toEqual({ role_id: { $in: ['real-role'] } }); + expect(filter).toEqual({ position_id: { $in: ['real-position'] } }); }); // Always-true literal — makes RLS.allowAllPolicy grant access instead of From 89874b00e281f9c9ab8e3b99c10d8dfc789097cb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 03:30:22 +0000 Subject: [PATCH 8/9] =?UTF-8?q?docs(adr,design):=20ADR-0090=20addendum=20?= =?UTF-8?q?=E2=80=94=20assignment-level=20BU=20anchor=20semantics,=20multi?= =?UTF-8?q?-org=20benchmark=20recalibration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H --- ...permission-model-v2-concept-convergence.md | 24 +++++++++++++++++++ docs/design/permission-model.md | 21 ++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/docs/adr/0090-permission-model-v2-concept-convergence.md b/docs/adr/0090-permission-model-v2-concept-convergence.md index fe18feb4be..e052d52072 100644 --- a/docs/adr/0090-permission-model-v2-concept-convergence.md +++ b/docs/adr/0090-permission-model-v2-concept-convergence.md @@ -489,6 +489,30 @@ launch shape (none of them changes P1's breaking surface): simulator UI, snapshot gate with the external-audience column. Proof: matrix snapshot diff drill on a seeded CRM stack, including an agent on-behalf-of case and an external-portal case. +## Addendum (2026-07-09) — assignment-level BU anchor (D12 companion) and benchmark recalibration + +Two clarifications from review discussion, recorded here rather than as a new ADR: + +1. **Positions never bind to a business unit at the DEFINITION level** — that + recreates the position-per-department explosion (the SAP failure mode this + model deliberately factors away). The ASSIGNMENT row may: + `sys_user_position.business_unit_id` (reserved by ADR-0057 D4) is adopted + with exactly three consumers — the depth anchor for that assignment's + `readScope`/`writeScope` (a 华东 sales manager gets manager depth in 华东 + only, not in an unrelated project unit), the D12 delegated-administration + boundary check ("assignments you create must target your subtree"), and the + audit fact ("manager OF WHAT"). Capability bits are never BU-scoped. + Prior art: Dataverse assigns security roles per-BU at the assignment level; + NetSuite's subsidiary-restricted roles are the same shape. Semantics land + with the delegated-admin phase (P3); the column may be written earlier. +2. **Scale benchmark recalibrated to the real deployment topology.** Group + deployments are multi-org (one org per legal entity; every record carries + `organization_id`, which prunes first). The P4 gate benchmark therefore + targets single-org populations (≈10k users × 1M rows per org), and the + "100k-user mega-unit" scenario moves from gate to non-goal; the closure-join + and per-object BU-stamping escape hatches remain documented alternatives in + the scale-hardening follow-up, not planned work. + ## References - ADR-0049, ADR-0056, ADR-0057 (+ its 2026-06-25 addendum), ADR-0066, ADR-0086 diff --git a/docs/design/permission-model.md b/docs/design/permission-model.md index 3435e5bfeb..5e066350ce 100644 --- a/docs/design/permission-model.md +++ b/docs/design/permission-model.md @@ -33,6 +33,19 @@ The whole model in four sentences: | **OWD + sharing** | *whose records are visible by default, and what widens that* | per-object `sharingModel`; sharing rules, manual shares, teams as recipients | OWD: object author · sharing: environment | | **RLS** | *hard boundaries nothing widens* (dimension/compliance isolation) | CEL predicates on permission sets | expert escape hatch (~5% of cases) | +**Position assignments may carry an optional business-unit anchor.** The +POSITION DEFINITION never binds to a BU (that would recreate the +position-per-department explosion); but an ASSIGNMENT row +(`sys_user_position.business_unit_id`, reserved since ADR-0057 D4) may name +the unit the person holds that position IN — "张三 is sales_manager **of +华东**". When present it does exactly three things: anchors that +assignment's depth grants (`readScope: unit*`) to that subtree, provides +the delegated-administration boundary check (ADR-0090 D12), and makes the +"manager of what" fact auditable instead of drifting with the user's BU +membership. Capability bits (CRUD/FLS) are never BU-scoped — row filtering +stays the job of OWD + depth. Semantics activate with the delegated-admin +phase; simple orgs never touch the field. + **Teams** are deliberately *not* a sixth concept: `sys_team` is a flat collaboration group that can **receive shared records and nothing else** — it never owns records and never carries permission sets (ADR-0090 D8). Positions distribute capability vertically; teams receive access horizontally. @@ -69,6 +82,14 @@ both the admin "view-as" simulator and the publish-time access-matrix snapshot g *Who* is calling matters too — a human, an AI agent, an integration service, an anonymous visitor, or an external portal user each evaluate slightly differently. See §9. +**Deployment note (multi-org groups).** Large groups on this platform run +one organization per legal entity: every record carries `organization_id` +and the tenant filter prunes FIRST, so the owner/BU `IN`-lists that follow +are sized to a single org's population (hundreds–thousands), not the whole +group. This is why records do not need a stamped BU column and why the +`IN`-form stays comfortably within budget at group scale; cross-entity +consolidation is a reporting concern, not a row-filter concern. + ## 3. OWD (`sharingModel`) — the record baseline Four canonical values, no aliases (ADR-0090 D4): From c26839abdb25a817eceb8e3cf07834f9c4e6246c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 03:41:14 +0000 Subject: [PATCH 9/9] =?UTF-8?q?test(objectql,runtime,auth):=20remaining=20?= =?UTF-8?q?v2-vocabulary=20fixture=20updates=20=E2=80=94=20overlay=20white?= =?UTF-8?q?list=20guards,=20option-visibility=20positions,=20standalone=20?= =?UTF-8?q?surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit role/profile kinds asserted as retired (reintroduction guards per ADR-0090 D2/D3); position asserted allowOrgOverride:true. customSession projection tests read user.positions. Standalone artifact surface tests use positions[]. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H --- .../objectql/src/overlay-precedence.test.ts | 6 ++++-- .../rule-validator.option-visibility.test.ts | 10 +++++----- .../plugin-auth/src/auth-manager.test.ts | 18 +++++++++--------- packages/runtime/src/standalone-stack.test.ts | 10 +++++----- 4 files changed, 23 insertions(+), 21 deletions(-) diff --git a/packages/objectql/src/overlay-precedence.test.ts b/packages/objectql/src/overlay-precedence.test.ts index 8714eb5f54..66cdc3ee11 100644 --- a/packages/objectql/src/overlay-precedence.test.ts +++ b/packages/objectql/src/overlay-precedence.test.ts @@ -312,8 +312,10 @@ describe('overlay whitelist enforcement (shared-DB invariant)', () => { // exactly two platform agents; tenants extend via skills + tools. expect(allowedFromRegistry.has('agent')).toBe(false); expect(allowedFromRegistry.has('permission')).toBe(true); - expect(allowedFromRegistry.has('role')).toBe(true); - expect(allowedFromRegistry.has('profile')).toBe(true); + expect(allowedFromRegistry.has('position')).toBe(true); + // ADR-0090 D2/D3: role/profile kinds retired — reintroduction guards. + expect(allowedFromRegistry.has('role')).toBe(false); + expect(allowedFromRegistry.has('profile')).toBe(false); // Execution/wiring-layer types must NOT be in the set. // Accepting them as overlays would corrupt runtime semantics. // (trigger/router/function/service were retired outright by diff --git a/packages/objectql/src/validation/rule-validator.option-visibility.test.ts b/packages/objectql/src/validation/rule-validator.option-visibility.test.ts index 372b12fad6..6c6ec07a45 100644 --- a/packages/objectql/src/validation/rule-validator.option-visibility.test.ts +++ b/packages/objectql/src/validation/rule-validator.option-visibility.test.ts @@ -29,7 +29,7 @@ const schema = { type: 'select', options: [ { value: 'standard' }, - { value: 'admin_only', visibleWhen: "'admin' in current_user.roles" }, + { value: 'admin_only', visibleWhen: "'admin' in current_user.positions" }, ], }, }, @@ -77,26 +77,26 @@ describe('per-option visibleWhen — role gating', () => { it('rejects an admin-only value for a non-admin', () => { expect(() => evaluateValidationRules(schema, { tier: 'admin_only' }, 'insert', { - currentUser: { id: 'u1', roles: ['sales'] }, + currentUser: { id: 'u1', positions: ['sales'] }, }), ).toThrow(ValidationError); }); it('accepts an admin-only value for an admin', () => { expect(() => evaluateValidationRules(schema, { tier: 'admin_only' }, 'insert', { - currentUser: { id: 'u1', roles: ['admin'] }, + currentUser: { id: 'u1', positions: ['admin'] }, }), ).not.toThrow(); }); it('accepts the ungated standard value for anyone', () => { expect(() => evaluateValidationRules(schema, { tier: 'standard' }, 'insert', { - currentUser: { id: 'u1', roles: ['sales'] }, + currentUser: { id: 'u1', positions: ['sales'] }, }), ).not.toThrow(); }); it('fails open when current_user is unbound (system write) — predicate faults', () => { - // `'admin' in current_user.roles` faults with no bound user → allowed through. + // `'admin' in current_user.positions` faults with no bound user → allowed through. // Authorization gating therefore requires the engine to bind current_user. expect(() => evaluateValidationRules(schema, { tier: 'admin_only' }, 'insert')).not.toThrow(); }); diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 7421f5eace..a04ce6990d 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -1475,7 +1475,7 @@ describe('AuthManager', () => { }); }); - describe('customSession – derived role and roles array', () => { + describe('customSession – derived identity and positions array', () => { // dataEngine stub: `adminLinks` controls whether the user resolves as a // platform admin (a sys_user_permission_set row pointing at the // admin_full_access permission set with no organization scope). @@ -1515,17 +1515,17 @@ describe('AuthManager', () => { return plugin._fn as (input: { user: any; session: any }) => Promise; }; - it('returns roles=[] for a regular user with no stored role', async () => { + it('returns positions=[] for a regular user with no stored role', async () => { const callback = await getSessionCallback(makeDataEngine({ platformAdmin: false })); const result = await callback({ user: { id: 'u-1', email: 'a@b.com' }, session: {}, }); expect(result.user.role).toBeUndefined(); - expect(result.user.roles).toEqual([]); + expect(result.user.positions).toEqual([]); }); - it('splits a stored role string into roles for a non-admin user', async () => { + it('splits a stored role string into positions for a non-admin user', async () => { const callback = await getSessionCallback(makeDataEngine({ platformAdmin: false })); const result = await callback({ user: { id: 'u-1', email: 'a@b.com', role: 'manager' }, @@ -1533,10 +1533,10 @@ describe('AuthManager', () => { }); // No promotion: `role` keeps its stored value. expect(result.user.role).toBe('manager'); - expect(result.user.roles).toEqual(['manager']); + expect(result.user.positions).toEqual(['manager']); }); - it('appends platform_admin to roles[] without overwriting role when promoting a platform admin', async () => { + it('appends platform_admin to positions[] without overwriting role when promoting a platform admin', async () => { const callback = await getSessionCallback(makeDataEngine({ platformAdmin: true })); const result = await callback({ user: { id: 'u-1', email: 'a@b.com', role: 'manager' }, @@ -1546,7 +1546,7 @@ describe('AuthManager', () => { // keeps its stored value; the canonical platform_admin identity is added // to roles[], and isPlatformAdmin is a derived alias. expect(result.user.role).toBe('manager'); - expect(result.user.roles).toEqual(['manager', 'platform_admin']); + expect(result.user.positions).toEqual(['manager', 'platform_admin']); expect(result.user.isPlatformAdmin).toBe(true); }); @@ -1557,7 +1557,7 @@ describe('AuthManager', () => { session: {}, }); expect(result.user.role).toBe('admin,manager'); - expect(result.user.roles).toEqual(['admin', 'manager', 'platform_admin']); + expect(result.user.positions).toEqual(['admin', 'manager', 'platform_admin']); expect(result.user.isPlatformAdmin).toBe(true); }); @@ -1566,7 +1566,7 @@ describe('AuthManager', () => { const user = { email: 'anon@b.com' }; const result = await callback({ user, session: {} }); expect(result.user).toBe(user); - expect(result.user.roles).toBeUndefined(); + expect(result.user.positions).toBeUndefined(); }); }); diff --git a/packages/runtime/src/standalone-stack.test.ts b/packages/runtime/src/standalone-stack.test.ts index becf7543b9..b7dcc554ae 100644 --- a/packages/runtime/src/standalone-stack.test.ts +++ b/packages/runtime/src/standalone-stack.test.ts @@ -26,7 +26,7 @@ const ARTIFACT = { manifest: { id: 'com.test.scope-app', name: 'Scope App', version: '1.0.0' }, requires: ['auth'], objects: [{ name: 'note', label: 'Note', fields: { title: { type: 'text' } } }], - roles: [ + positions: [ { name: 'manager', label: 'Manager' }, { name: 'contributor', label: 'Contributor' }, ], @@ -91,9 +91,9 @@ describe('createStandaloneStack — surfaces app RBAC from the artifact (ADR-005 expect(def.objects.note.readScope).toBe('unit_and_below'); }); - it('surfaces roles[] at the top level', () => { - expect(Array.isArray(result.roles)).toBe(true); - expect(result.roles!.map((r: any) => r.name).sort()).toEqual(['contributor', 'manager']); + it('surfaces positions[] at the top level', () => { + expect(Array.isArray(result.positions)).toBe(true); + expect(result.positions!.map((r: any) => r.name).sort()).toEqual(['contributor', 'manager']); }); it('still surfaces objects/requires/manifest (no regression)', () => { @@ -116,7 +116,7 @@ describe('createStandaloneStack — surfaces app RBAC from the artifact (ADR-005 databaseUrl: 'memory://standalone-rbac', }); expect(appDefaultPermissionSetName(r.permissions)).toBe('app_member_default'); - expect(r.roles!.map((x: any) => x.name).sort()).toEqual(['contributor', 'manager']); + expect(r.positions!.map((x: any) => x.name).sort()).toEqual(['contributor', 'manager']); }, BOOT_TIMEOUT); });