Skip to content

Commit 6d83431

Browse files
os-zhuangclaude
andauthored
feat(security)!: ADR-0090 P1 breaking wave — position rename, profile removal, secure OWD default, principal/external spec shapes (#2697)
* feat(security)!: ADR-0090 P1 breaking wave — position rename, profile removal, secure OWD default, principal/external spec shapes 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H * fix(liveness,contract): position ledger rename + externalSharingModel classification + downstream fixture 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H * fix(examples): showcase stack positions property, coverage kind, position identifiers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H * fix(runtime,sharing,examples): app-plugin position sections, runtime recipient enum, D1 grandfather stamps — dogfood 39/39 green - 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H * fix(spec,automation,core): regenerate API-surface snapshot for the position rename; runas/authz test fixtures to v2 field names 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H * chore(changeset): P1 breaking wave is a MAJOR bump per the API-surface gate Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H * test(plugin-security): RLSCompiler fixtures to current_user.positions (v2 vocabulary) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H * docs(adr,design): ADR-0090 addendum — assignment-level BU anchor semantics, multi-org benchmark recalibration Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H * test(objectql,runtime,auth): remaining v2-vocabulary fixture updates — overlay whitelist guards, option-visibility positions, standalone surface 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d8b6304 commit 6d83431

195 files changed

Lines changed: 1498 additions & 1326 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
'@objectstack/spec': major
3+
'@objectstack/core': major
4+
'@objectstack/runtime': major
5+
'@objectstack/objectql': major
6+
'@objectstack/formula': major
7+
'@objectstack/rest': major
8+
'@objectstack/cli': major
9+
'@objectstack/plugin-security': major
10+
'@objectstack/plugin-sharing': major
11+
'@objectstack/plugin-auth': major
12+
'@objectstack/service-automation': major
13+
'@objectstack/trigger-record-change': major
14+
'@objectstack/platform-objects': major
15+
'@objectstack/metadata': major
16+
---
17+
18+
ADR-0090 P1 breaking wave — permission model v2 concept convergence.
19+
20+
Pre-launch one-step renames and secure defaults (no compatibility aliases, per
21+
ADR-0090 D3/D4 superseding ADR-0057 D5/D7's alias discipline):
22+
23+
- `sys_role``sys_position`, `sys_user_role``sys_user_position` (field
24+
`role``position`), `sys_role_permission_set``sys_position_permission_set`
25+
(field `role_id``position_id`); `RoleSchema`/`defineRole`
26+
`PositionSchema`/`definePosition` with **no `parent`** (positions are flat;
27+
hierarchy lives on the business-unit tree).
28+
- `ExecutionContext.roles[]``positions[]`; the EvalUser/CEL contract
29+
`current_user.roles``current_user.positions` (formula validators updated);
30+
stack property `roles:``positions:`; metadata kinds `role`/`profile`
31+
`position` (profile kind removed).
32+
- `isProfile` removed from `PermissionSetSchema` (ADR-0090 D2); `isDefault`
33+
narrows to an install-time suggestion; `appDefaultProfileName`
34+
`appDefaultPermissionSetName` (isDefault-only).
35+
- OWD enum drops legacy aliases `read`/`read_write`/`full`; new optional
36+
`externalSharingModel` (external dial, `private` default) lands as P1 spec
37+
shape (ADR-0090 D11).
38+
- **Secure default (D1)**: a custom object with an owner field and NO
39+
`sharingModel` now resolves `private` (was: fully public). System objects
40+
keep their explicit posture. Unrecognised stored values fail closed.
41+
- ExecutionContext gains the P1 principal-taxonomy shape (D10):
42+
`principalKind` / `audience` / `onBehalfOf` (optional, semantics phase in
43+
later).
44+
- Sharing recipients: `role``position` (expanded via `sys_user_position`
45+
∪ the better-auth membership transition source); `role_and_subordinates`
46+
removed — `unit_and_subordinates` now expands the business-unit subtree
47+
(finishes ADR-0057 D5's re-homing).

docs/adr/0090-permission-model-v2-concept-convergence.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,30 @@ launch shape (none of them changes P1's breaking surface):
489489
simulator UI, snapshot gate with the external-audience column. Proof: matrix snapshot diff drill
490490
on a seeded CRM stack, including an agent on-behalf-of case and an external-portal case.
491491

492+
## Addendum (2026-07-09) — assignment-level BU anchor (D12 companion) and benchmark recalibration
493+
494+
Two clarifications from review discussion, recorded here rather than as a new ADR:
495+
496+
1. **Positions never bind to a business unit at the DEFINITION level** — that
497+
recreates the position-per-department explosion (the SAP failure mode this
498+
model deliberately factors away). The ASSIGNMENT row may:
499+
`sys_user_position.business_unit_id` (reserved by ADR-0057 D4) is adopted
500+
with exactly three consumers — the depth anchor for that assignment's
501+
`readScope`/`writeScope` (a 华东 sales manager gets manager depth in 华东
502+
only, not in an unrelated project unit), the D12 delegated-administration
503+
boundary check ("assignments you create must target your subtree"), and the
504+
audit fact ("manager OF WHAT"). Capability bits are never BU-scoped.
505+
Prior art: Dataverse assigns security roles per-BU at the assignment level;
506+
NetSuite's subsidiary-restricted roles are the same shape. Semantics land
507+
with the delegated-admin phase (P3); the column may be written earlier.
508+
2. **Scale benchmark recalibrated to the real deployment topology.** Group
509+
deployments are multi-org (one org per legal entity; every record carries
510+
`organization_id`, which prunes first). The P4 gate benchmark therefore
511+
targets single-org populations (≈10k users × 1M rows per org), and the
512+
"100k-user mega-unit" scenario moves from gate to non-goal; the closure-join
513+
and per-object BU-stamping escape hatches remain documented alternatives in
514+
the scale-hardening follow-up, not planned work.
515+
492516
## References
493517

494518
- ADR-0049, ADR-0056, ADR-0057 (+ its 2026-06-25 addendum), ADR-0066, ADR-0086

docs/design/permission-model.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,19 @@ The whole model in four sentences:
3333
| **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 |
3434
| **RLS** | *hard boundaries nothing widens* (dimension/compliance isolation) | CEL predicates on permission sets | expert escape hatch (~5% of cases) |
3535

36+
**Position assignments may carry an optional business-unit anchor.** The
37+
POSITION DEFINITION never binds to a BU (that would recreate the
38+
position-per-department explosion); but an ASSIGNMENT row
39+
(`sys_user_position.business_unit_id`, reserved since ADR-0057 D4) may name
40+
the unit the person holds that position IN — "张三 is sales_manager **of
41+
华东**". When present it does exactly three things: anchors that
42+
assignment's depth grants (`readScope: unit*`) to that subtree, provides
43+
the delegated-administration boundary check (ADR-0090 D12), and makes the
44+
"manager of what" fact auditable instead of drifting with the user's BU
45+
membership. Capability bits (CRUD/FLS) are never BU-scoped — row filtering
46+
stays the job of OWD + depth. Semantics activate with the delegated-admin
47+
phase; simple orgs never touch the field.
48+
3649
**Teams** are deliberately *not* a sixth concept: `sys_team` is a flat collaboration group that can
3750
**receive shared records and nothing else** — it never owns records and never carries permission
3851
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
6982
*Who* is calling matters too — a human, an AI agent, an integration service, an anonymous
7083
visitor, or an external portal user each evaluate slightly differently. See §9.
7184

85+
**Deployment note (multi-org groups).** Large groups on this platform run
86+
one organization per legal entity: every record carries `organization_id`
87+
and the tenant filter prunes FIRST, so the owner/BU `IN`-lists that follow
88+
are sized to a single org's population (hundreds–thousands), not the whole
89+
group. This is why records do not need a stamped BU column and why the
90+
`IN`-form stays comfortably within budget at group scale; cross-entity
91+
consolidation is a reporting concern, not a row-filter concern.
92+
7293
## 3. OWD (`sharingModel`) — the record baseline
7394

7495
Four canonical values, no aliases (ADR-0090 D4):

examples/app-crm/objectstack.config.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ import * as actions from './src/actions/index.js';
1212
import { allHooks } from './src/hooks/index.js';
1313
import { allFlows } from './src/flows/index.js';
1414
import {
15-
SalesRepRole,
16-
SalesManagerRole,
17-
FinanceApproverRole,
15+
SalesRepPosition,
16+
SalesManagerPosition,
17+
FinanceApproverPosition,
1818
SalesUserPermissionSet,
1919
GuestPortalProfile,
2020
HighValueOpportunitySharingRule,
@@ -89,7 +89,7 @@ export default defineStack({
8989
flows: allFlows,
9090

9191
// Security
92-
roles: [SalesRepRole, SalesManagerRole, FinanceApproverRole],
92+
positions: [SalesRepPosition, SalesManagerPosition, FinanceApproverPosition],
9393
permissions: [SalesUserPermissionSet, GuestPortalProfile],
9494
sharingRules: [
9595
HighValueOpportunitySharingRule,

examples/app-crm/src/security/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
export {
4-
SalesRepRole,
5-
SalesManagerRole,
6-
FinanceApproverRole,
4+
SalesRepPosition,
5+
SalesManagerPosition,
6+
FinanceApproverPosition,
77
SalesUserPermissionSet,
88
GuestPortalProfile,
9-
} from './sales-roles.js';
9+
} from './sales-positions.js';
1010

1111
export {
1212
HighValueOpportunitySharingRule,

examples/app-crm/src/security/sales-roles.ts renamed to examples/app-crm/src/security/sales-positions.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,25 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { defineRole } from '@objectstack/spec/identity';
3+
import { definePosition } from '@objectstack/spec/identity';
44
import { definePermissionSet } from '@objectstack/spec/security';
55

66
/**
77
* Example roles — a small sales hierarchy.
88
*/
9-
export const SalesRepRole = defineRole({
9+
export const SalesRepPosition = definePosition({
1010
name: 'sales_rep',
1111
label: 'Sales Representative',
1212
description: 'Front-line sales representative.',
1313
});
1414

15-
export const SalesManagerRole = defineRole({
15+
export const SalesManagerPosition = definePosition({
1616
name: 'sales_manager',
1717
label: 'Sales Manager',
1818
description: 'Manages a team of sales reps.',
19-
parent: 'sales_rep',
2019
});
2120

2221
/** Referenced by the Discount Approval second step. */
23-
export const FinanceApproverRole = defineRole({
22+
export const FinanceApproverPosition = definePosition({
2423
name: 'finance_approver',
2524
label: 'Finance Approver',
2625
description: 'Finance team member authorised to approve discounts above 30%.',
@@ -34,7 +33,6 @@ export const FinanceApproverRole = defineRole({
3433
export const SalesUserPermissionSet = definePermissionSet({
3534
name: 'crm_sales_user',
3635
label: 'CRM Sales User',
37-
isProfile: false,
3836
objects: {
3937
crm_account: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
4038
crm_contact: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
@@ -55,7 +53,6 @@ export const SalesUserPermissionSet = definePermissionSet({
5553
export const GuestPortalProfile = definePermissionSet({
5654
name: 'guest_portal',
5755
label: 'Guest (Public Forms)',
58-
isProfile: true,
5956
objects: {
6057
crm_lead: { allowRead: false, allowCreate: true, allowEdit: false, allowDelete: false },
6158
},

examples/app-crm/src/security/sharing-rules.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const HighValueOpportunitySharingRule = defineSharingRule({
1515
condition: 'record.amount > 100000',
1616
accessLevel: 'edit',
1717
sharedWith: {
18-
type: 'role',
18+
type: 'position',
1919
value: 'sales_manager',
2020
},
2121
active: true,
@@ -32,12 +32,12 @@ export const RepLeadSharingRule = defineSharingRule({
3232
description: "Share each rep's leads with the Sales Manager role for coaching visibility.",
3333
object: 'crm_lead',
3434
ownedBy: {
35-
type: 'role',
35+
type: 'position',
3636
value: 'sales_rep',
3737
},
3838
accessLevel: 'read',
3939
sharedWith: {
40-
type: 'role',
40+
type: 'position',
4141
value: 'sales_manager',
4242
},
4343
active: true,
@@ -56,7 +56,7 @@ export const WonDealActivitySharingRule = defineSharingRule({
5656
condition: "record.status == 'completed'",
5757
accessLevel: 'read',
5858
sharedWith: {
59-
type: 'role',
59+
type: 'position',
6060
value: 'sales_rep',
6161
},
6262
active: true,

examples/app-showcase/objectstack.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import { allEmails } from './src/system/emails/index.js';
3131
import { allBooks } from './src/system/books/index.js';
3232
import { allApis } from './src/system/apis/index.js';
3333
import {
34-
allRoles,
34+
allPositions,
3535
allPermissionSets,
3636
allSharingRules,
3737
} from './src/security/index.js';
@@ -192,7 +192,7 @@ export default defineStack({
192192
webhooks: allWebhooks,
193193

194194
// Security
195-
roles: allRoles,
195+
positions: allPositions,
196196
permissions: allPermissionSets,
197197
sharingRules: allSharingRules,
198198

examples/app-showcase/src/coverage.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -119,13 +119,12 @@ export const KIND_COVERAGE: Record<MetadataType, KindCoverage> = {
119119
},
120120

121121
// ── security ──
122-
permission: { status: 'demonstrated', files: ['src/security/index.ts'] },
123-
profile: {
122+
permission: {
124123
status: 'demonstrated',
125124
files: ['src/security/index.ts'],
126-
notes: 'MemberDefaultProfile — a permission set with isProfile: true (ADR-0056 D7).',
125+
notes: 'Includes MemberDefault — the isDefault permission set (ADR-0090 D5 suggestion flag).',
127126
},
128-
role: { status: 'demonstrated', files: ['src/security/index.ts'] },
127+
position: { status: 'demonstrated', files: ['src/security/index.ts'] },
129128

130129
// ── ai ──
131130
agent: {

examples/app-showcase/src/data/objects/account.object.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ import { P } from '@objectstack/spec';
1414
*/
1515
export const Account = ObjectSchema.create({
1616
name: 'showcase_account',
17+
// [ADR-0090 D1] Explicit grandfather stamp: record isolation for this demo
18+
// object is RLS-owned / intentionally public; without this the new secure
19+
// default (unset OWD => private) would owner-filter it.
20+
sharingModel: 'public_read_write',
1721
label: 'Account',
1822
pluralLabel: 'Accounts',
1923
icon: 'building',

0 commit comments

Comments
 (0)