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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .changeset/adr-0090-p1-breaking-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
'@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.

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).
24 changes: 24 additions & 0 deletions docs/adr/0090-permission-model-v2-concept-convergence.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions docs/design/permission-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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):
Expand Down
8 changes: 4 additions & 4 deletions examples/app-crm/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -89,7 +89,7 @@ export default defineStack({
flows: allFlows,

// Security
roles: [SalesRepRole, SalesManagerRole, FinanceApproverRole],
positions: [SalesRepPosition, SalesManagerPosition, FinanceApproverPosition],
permissions: [SalesUserPermissionSet, GuestPortalProfile],
sharingRules: [
HighValueOpportunitySharingRule,
Expand Down
8 changes: 4 additions & 4 deletions examples/app-crm/src/security/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,25 @@
// 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.',
parent: 'sales_rep',
});

/** 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%.',
Expand All @@ -34,7 +33,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 },
Expand All @@ -55,7 +53,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 },
},
Expand Down
8 changes: 4 additions & 4 deletions examples/app-crm/src/security/sharing-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const HighValueOpportunitySharingRule = defineSharingRule({
condition: 'record.amount > 100000',
accessLevel: 'edit',
sharedWith: {
type: 'role',
type: 'position',
value: 'sales_manager',
},
active: true,
Expand All @@ -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,
Expand All @@ -56,7 +56,7 @@ export const WonDealActivitySharingRule = defineSharingRule({
condition: "record.status == 'completed'",
accessLevel: 'read',
sharedWith: {
type: 'role',
type: 'position',
value: 'sales_rep',
},
active: true,
Expand Down
4 changes: 2 additions & 2 deletions examples/app-showcase/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -192,7 +192,7 @@ export default defineStack({
webhooks: allWebhooks,

// Security
roles: allRoles,
positions: allPositions,
permissions: allPermissionSets,
sharingRules: allSharingRules,

Expand Down
7 changes: 3 additions & 4 deletions examples/app-showcase/src/coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,12 @@ export const KIND_COVERAGE: Record<MetadataType, KindCoverage> = {
},

// ── security ──
permission: { status: 'demonstrated', files: ['src/security/index.ts'] },
profile: {
permission: {
status: 'demonstrated',
files: ['src/security/index.ts'],
notes: 'MemberDefaultProfile — a permission set with isProfile: true (ADR-0056 D7).',
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: {
Expand Down
4 changes: 4 additions & 0 deletions examples/app-showcase/src/data/objects/account.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions examples/app-showcase/src/data/objects/category.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions examples/app-showcase/src/data/objects/field-zoo.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions examples/app-showcase/src/data/objects/invoice.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions examples/app-showcase/src/data/objects/preference.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions examples/app-showcase/src/data/objects/project.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions examples/app-showcase/src/data/objects/semantic-zoo.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions examples/app-showcase/src/data/objects/task.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading