Skip to content

Commit db01bdb

Browse files
committed
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
1 parent d8b6304 commit db01bdb

165 files changed

Lines changed: 1280 additions & 1215 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': minor
3+
'@objectstack/core': minor
4+
'@objectstack/runtime': minor
5+
'@objectstack/objectql': minor
6+
'@objectstack/formula': minor
7+
'@objectstack/rest': minor
8+
'@objectstack/cli': minor
9+
'@objectstack/plugin-security': minor
10+
'@objectstack/plugin-sharing': minor
11+
'@objectstack/plugin-auth': minor
12+
'@objectstack/service-automation': minor
13+
'@objectstack/trigger-record-change': minor
14+
'@objectstack/platform-objects': minor
15+
'@objectstack/metadata': minor
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).

‎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 & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,26 @@
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.',
1919
parent: 'sales_rep',
2020
});
2121

2222
/** Referenced by the Discount Approval second step. */
23-
export const FinanceApproverRole = defineRole({
23+
export const FinanceApproverPosition = definePosition({
2424
name: 'finance_approver',
2525
label: 'Finance Approver',
2626
description: 'Finance team member authorised to approve discounts above 30%.',
@@ -34,7 +34,6 @@ export const FinanceApproverRole = defineRole({
3434
export const SalesUserPermissionSet = definePermissionSet({
3535
name: 'crm_sales_user',
3636
label: 'CRM Sales User',
37-
isProfile: false,
3837
objects: {
3938
crm_account: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
4039
crm_contact: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
@@ -55,7 +54,6 @@ export const SalesUserPermissionSet = definePermissionSet({
5554
export const GuestPortalProfile = definePermissionSet({
5655
name: 'guest_portal',
5756
label: 'Guest (Public Forms)',
58-
isProfile: true,
5957
objects: {
6058
crm_lead: { allowRead: false, allowCreate: true, allowEdit: false, allowDelete: false },
6159
},

‎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/src/coverage.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ export const KIND_COVERAGE: Record<MetadataType, KindCoverage> = {
123123
profile: {
124124
status: 'demonstrated',
125125
files: ['src/security/index.ts'],
126-
notes: 'MemberDefaultProfile — a permission set with isProfile: true (ADR-0056 D7).',
126+
notes: 'MemberDefault — the isDefault permission set (ADR-0090 D5 suggestion flag).',
127127
},
128128
role: { status: 'demonstrated', files: ['src/security/index.ts'] },
129129

‎examples/app-showcase/src/security/index.ts‎

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ export const ContributorPermissionSet = {
3535
name: 'showcase_contributor',
3636
label: 'Showcase Contributor',
3737
description: 'Standard access for contributors, with budget fields hidden and row-level scoping to own records.',
38-
isProfile: false,
3938
objects: {
4039
showcase_project: { allowRead: true, allowCreate: false, allowEdit: true, allowDelete: false },
4140
showcase_task: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
@@ -61,7 +60,7 @@ export const ContributorPermissionSet = {
6160
object: 'showcase_task',
6261
operation: 'select' as const,
6362
using: "assignee == current_user.email",
64-
roles: ['contributor'],
63+
positions: ['contributor'],
6564
enabled: true,
6665
priority: 10,
6766
},
@@ -76,7 +75,7 @@ export const ContributorPermissionSet = {
7675
object: 'showcase_invoice',
7776
operation: 'select' as const,
7877
using: "owner == current_user.email",
79-
roles: ['contributor'],
78+
positions: ['contributor'],
8079
enabled: true,
8180
priority: 10,
8281
},
@@ -92,7 +91,7 @@ export const ContributorPermissionSet = {
9291
object: 'showcase_invoice',
9392
operation: 'update' as const,
9493
check: "owner == current_user.email",
95-
roles: ['contributor'],
94+
positions: ['contributor'],
9695
enabled: true,
9796
priority: 10,
9897
},
@@ -114,7 +113,6 @@ export const MemberDefaultProfile = {
114113
name: 'showcase_member_default',
115114
label: 'Showcase Member (Default)',
116115
description: 'App-declared default profile for new sign-ups — read-mostly baseline (ADR-0056 D7).',
117-
isProfile: true,
118116
isDefault: true,
119117
objects: {
120118
showcase_account: { allowRead: true },
@@ -136,7 +134,7 @@ export const RedProjectSharingRule = {
136134
object: 'showcase_project',
137135
condition: "record.health == 'red'",
138136
accessLevel: 'read' as const,
139-
sharedWith: { type: 'role' as const, value: 'exec' },
137+
sharedWith: { type: 'position' as const, value: 'exec' },
140138
active: true,
141139
};
142140

@@ -155,7 +153,7 @@ export const HighValueRedProjectRule = {
155153
object: 'showcase_project',
156154
condition: "record.health == 'red' && record.budget > 100000",
157155
accessLevel: 'read' as const,
158-
sharedWith: { type: 'role' as const, value: 'manager' },
156+
sharedWith: { type: 'position' as const, value: 'manager' },
159157
active: true,
160158
};
161159

@@ -166,9 +164,9 @@ export const ContributorTaskSharingRule = {
166164
label: "Contributor Tasks → Manager",
167165
description: "Share each contributor's tasks with managers for oversight.",
168166
object: 'showcase_task',
169-
ownedBy: { type: 'role' as const, value: 'contributor' },
167+
ownedBy: { type: 'position' as const, value: 'contributor' },
170168
accessLevel: 'read' as const,
171-
sharedWith: { type: 'role' as const, value: 'manager' },
169+
sharedWith: { type: 'position' as const, value: 'manager' },
172170
active: true,
173171
};
174172

‎examples/app-todo/test/mcp-actions.e2e.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ function mcpRequest(body: unknown): Request {
101101
});
102102

103103
// The acting user — an authenticated, non-system principal.
104-
const user = { userId: 'user_1', roles: [], permissions: [], systemPermissions: [] };
104+
const user = { userId: 'user_1', positions: [], permissions: [], systemPermissions: [] };
105105
const bridge = bridgeFor(user);
106106

107107
// ── Step 1 — the MCP server advertises the action tools ───────────
@@ -151,15 +151,15 @@ function mcpRequest(body: unknown): Request {
151151
? o
152152
: { ...o, actions: o.actions.map((a: any) => (a.name === 'clone_task' ? { ...a, requiredPermissions: ['todo_admin'] } : a)) },
153153
);
154-
const denyBridge = bridgeFor({ userId: 'user_2', roles: [], permissions: [], systemPermissions: [] }, gatedObjects);
154+
const denyBridge = bridgeFor({ userId: 'user_2', positions: [], permissions: [], systemPermissions: [] }, gatedObjects);
155155
const denied = await callMcp(denyBridge, toolsCall(5, 'run_action', { actionName: 'clone_task', recordId: taskId }));
156156
check(denied.result?.isError === true, 'run_action denied when the caller lacks the capability');
157157
check(/requires capability/i.test(denied.result?.content?.[0]?.text ?? ''), 'denial cites the missing capability');
158158
// …and list_actions hides what the caller may not run.
159159
const denyList = JSON.parse((await callMcp(denyBridge, toolsCall(6, 'list_actions', {}))).result.content[0].text).actions as any[];
160160
check(!denyList.some((a) => a.name === 'clone_task'), 'list_actions hides the gated action from a non-holder');
161161

162-
const allowBridge = bridgeFor({ userId: 'admin_1', roles: [], permissions: [], systemPermissions: ['todo_admin'] }, gatedObjects);
162+
const allowBridge = bridgeFor({ userId: 'admin_1', positions: [], permissions: [], systemPermissions: ['todo_admin'] }, gatedObjects);
163163
const allowed = await callMcp(allowBridge, toolsCall(7, 'run_action', { actionName: 'clone_task', recordId: taskId }));
164164
check(allowed.result?.isError !== true, 'run_action allowed for a holder of the capability');
165165
const allowList = JSON.parse((await callMcp(allowBridge, toolsCall(8, 'list_actions', {}))).result.content[0].text).actions as any[];

‎packages/cli/src/commands/serve.ts‎

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1192,13 +1192,12 @@ export default class Serve extends Command {
11921192

11931193
// Collect application-defined org roles from the stack so
11941194
// Better-Auth's organization plugin accepts invitations to
1195-
// those roles (otherwise it 400s with `ROLE_NOT_FOUND`).
1196-
// Sources:
1197-
// - top-level `roles[]` (role hierarchy entries)
1198-
// - `permissions[]` PermissionSets where `isProfile === true`
1199-
// (these double as role identifiers; e.g. CRM Profiles)
1200-
// Real RBAC enforcement is still owned by SecurityPlugin, which
1201-
// matches these names against `permission` metadata entries.
1195+
// those names (otherwise it 400s with `ROLE_NOT_FOUND`).
1196+
// better-auth boundary: its API keeps the word "roles"
1197+
// (ADR-0090 D3 exception). Sources:
1198+
// - top-level `positions[]` (flat distribution groups)
1199+
// - `permissions[]` PermissionSet names
1200+
// Real RBAC enforcement is still owned by SecurityPlugin.
12021201
const additionalOrgRoles = new Set<string>();
12031202
try {
12041203
const stackAny: any = config ?? {};
@@ -1209,10 +1208,10 @@ export default class Serve extends Command {
12091208
if (n && n !== 'owner' && n !== 'admin' && n !== 'member') additionalOrgRoles.add(n);
12101209
}
12111210
};
1212-
collect(stackAny.roles);
1211+
collect(stackAny.positions);
12131212
if (Array.isArray(stackAny.permissions)) {
12141213
for (const p of stackAny.permissions) {
1215-
if (p && typeof p.name === 'string' && p.isProfile !== false) {
1214+
if (p && typeof p.name === 'string') {
12161215
if (p.name !== 'owner' && p.name !== 'admin' && p.name !== 'member') additionalOrgRoles.add(p.name);
12171216
}
12181217
}
@@ -1301,13 +1300,13 @@ export default class Serve extends Command {
13011300
// Pair: SecurityPlugin (RBAC) — optional
13021301
try {
13031302
const securityPkg = '@objectstack/plugin-security';
1304-
const { SecurityPlugin, appDefaultProfileName } = await import(/* webpackIgnore: true */ securityPkg);
1303+
const { SecurityPlugin, appDefaultPermissionSetName } = await import(/* webpackIgnore: true */ securityPkg);
13051304
// ADR-0056 D7 — honor an app-declared default profile. A stack
1306-
// permission set marked `isProfile && isDefault` becomes the
1305+
// permission set marked `isDefault` becomes the
13071306
// fallback for users with no explicit grants. The SecurityPlugin's
13081307
// own scan only sees its built-in sets, so the CLI passes the
13091308
// declared name through explicitly (undefined → built-in default).
1310-
const appDefaultProfile = appDefaultProfileName((config as any)?.permissions);
1309+
const appDefaultProfile = appDefaultPermissionSetName((config as any)?.permissions);
13111310
await kernel.use(new SecurityPlugin(appDefaultProfile ? { fallbackPermissionSet: appDefaultProfile } : undefined));
13121311
trackPlugin('Security');
13131312
} catch {

‎packages/cli/src/utils/format.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ export interface MetadataStats {
123123
workflows: number;
124124
agents: number;
125125
apis: number;
126-
roles: number;
126+
positions: number;
127127
permissions: number;
128128
themes: number;
129129
datasources: number;
@@ -163,7 +163,7 @@ export function collectMetadataStats(config: any): MetadataStats {
163163
workflows: count(config.workflows),
164164
agents: count(config.agents),
165165
apis: count(config.apis),
166-
roles: count(config.roles),
166+
positions: count(config.positions),
167167
permissions: count(config.permissions),
168168
themes: count(config.themes),
169169
datasources: count(config.datasources),
@@ -268,7 +268,7 @@ export function printMetadataStats(stats: MetadataStats) {
268268
{
269269
label: 'Security',
270270
items: [
271-
['Roles', stats.roles],
271+
['Positions', stats.positions],
272272
['Permissions', stats.permissions],
273273
],
274274
},

0 commit comments

Comments
 (0)