Skip to content

Commit b271691

Browse files
os-zhuangclaude
andauthored
feat(security)!: ADR-0090 P3 — security publish linter (D7), delegated administration (D12), BU assignment anchor (#2711)
* feat(security)!: ADR-0090 P3 — security publish linter (D7), delegated administration (D12), BU assignment anchor D7 — validateSecurityPosture in @objectstack/lint, gating os compile and reported by os lint. Seven rules, each with a failing fixture: unset OWD on custom objects (the objectui#2348 leave_request shape), retired OWD aliases (fix-it), external dial wider than internal (D11), wildcard VAMA outside the platform admin set (ADR-0066), high-privilege everyone-suggested sets, the reserved word 'role' in security identifiers/labels (D3), and advisory private-object-read-without-depth. The linter immediately caught real gaps: app-crm shipped six objects with no OWD, app-showcase two, plus a 'role' field on showcase_project_membership and the CLI golden corpus — all fixed in this commit (grandfather stamps + engagement rename), proving the rule set against its own repo. D12 — DelegatedAdminGate in plugin-security. PermissionSetSchema.adminScope (persisted as sys_permission_set.admin_scope) declares WHERE (BU subtree), WHAT (manageAssignments / manageBindings / authorEnvironmentSets) and WHICH sets a delegate may hand out (allowlist). Writes to sys_user_position, sys_position_permission_set, sys_user_permission_set and sys_permission_set are now governed: tenant admins (superuser wildcard) pass to ordinary CRUD/RLS; delegates need a covering scope — subtree-anchored assignments, allowlisted sets only (to others AND themselves), single-row writes, granted_by audit stamping; plain CRUD holders with no scope are denied. Granting/authoring a set that carries an adminScope requires strict containment. everyone/guest anchors stay tenant-level only, and stored assignments to an anchor are rejected for every caller. ADR-0090 Addendum — sys_user_position.business_unit_id lands with its three consumers: D12 delegation boundary (enforced), audit fact, and the depth-anchor contract documented on IHierarchyScopeResolver for enterprise resolvers. D9 tightening — describeHighPrivilegeBits moved to @objectstack/spec/security (shared by lint + runtime, re-exported from plugin-security) plus new describeAnchorForbiddenBits: guest bindings now also reject edit bits. D3 sweep — SysRole→SysPosition, SysUserRole→SysUserPosition, SysRolePermissionSet→SysPositionPermissionSet (one-step, no aliases); sys_position actions/list views/labels de-roled; sys_business_unit_member.role_in_business_unit→function_in_business_unit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H * fix: stamp todo_task OWD (D7 linter), drop always-true ql conditional (CodeQL) 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 5be00c3 commit b271691

48 files changed

Lines changed: 2076 additions & 130 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/lint": minor
4+
"@objectstack/cli": minor
5+
"@objectstack/plugin-security": major
6+
"@objectstack/platform-objects": major
7+
---
8+
9+
ADR-0090 P3 — security-domain publish linter (D7) and delegated administration (D12).
10+
11+
**D7 — `validateSecurityPosture` (@objectstack/lint), wired into `os compile` (errors gate the build) and `os lint`.** Rules, each with a failing fixture: `security-owd-unset` (custom object with no `sharingModel` — the objectui#2348 leave_request shape), `security-owd-alias` (retired D4 alias values, with fix-it), `security-external-wider-than-internal` (D11 `external ≤ internal`), `security-wildcard-vama` (`'*'` + View/Modify All outside the platform admin set, ADR-0066), `security-anchor-high-privilege` (an `isDefault`/everyone-suggested set carrying anchor-forbidden bits), `security-role-word` (D3 vocabulary freeze in security identifiers/labels; ARIA/page roles exempt), and advisory `security-private-no-readscope`.
12+
13+
**D12 — delegated administration (@objectstack/plugin-security `DelegatedAdminGate`).** `PermissionSetSchema.adminScope` (new in spec, persisted as `sys_permission_set.admin_scope`) declares WHERE (a `sys_business_unit` subtree), WHAT (`manageAssignments` / `manageBindings` / `authorEnvironmentSets`), and WHICH sets a delegate may hand out (`assignablePermissionSets` allowlist). Writes to `sys_user_position`, `sys_position_permission_set`, `sys_user_permission_set`, and `sys_permission_set` are now governed: tenant-level admins (ADR-0066 superuser wildcard) pass through; delegates need a covering scope — inside their subtree, allowlisted sets only (to others AND themselves), single-row writes, `granted_by` audit-stamped; everyone else (including holders of plain CRUD on RBAC tables) is denied. Granting or authoring a set that itself carries an `adminScope` requires a held scope that STRICTLY contains it. The `everyone`/`guest` anchors stay tenant-level only, and direct position assignments to an anchor are rejected for every caller.
14+
15+
**ADR-0090 Addendum — assignment-level BU anchor.** `sys_user_position.business_unit_id` lands with its three consumers scoped: D12 delegation boundary (enforced here), audit fact, and the depth-anchor contract for enterprise `hierarchy-scope-resolver` implementations (documented on `IHierarchyScopeResolver`).
16+
17+
**D9 tier tightening.** `describeHighPrivilegeBits` moved to `@objectstack/spec/security` (re-exported from plugin-security) alongside new `describeAnchorForbiddenBits`: `guest` bindings now additionally reject edit bits (read-only by default; create stays the case-by-case exception).
18+
19+
**BREAKING (@objectstack/plugin-security):** exports renamed to the ADR-0090 D3 vocabulary — `SysRole``SysPosition`, `SysUserRole``SysUserPosition`, `SysRolePermissionSet``SysPositionPermissionSet` (no aliases, pre-launch one-step rename). `sys_position` row actions/list views renamed (`activate_position`, …), labels relabeled Role→Position. Non-tenant-admin writes to the RBAC link tables without an `adminScope` are now denied (previously any CRUD grant on those tables sufficed).
20+
21+
**BREAKING (@objectstack/platform-objects):** `sys_business_unit_member.role_in_business_unit``function_in_business_unit` (D3 reserved-word sweep; values member/lead/deputy unchanged).

docs/design/permission-model.md

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,8 +208,10 @@ being **structured data**:
208208

209209
1. **A small, closed vocabulary** — 5 concepts, 4 OWD values, no aliases, banned words: the error
210210
space is shrunk before any checker runs. Strict authoring (rejects, never lenient-parses).
211-
2. **Publish linter** (security domain): unset OWD, everyone+high-privilege, non-admin superuser
212-
wildcards, forbidden vocabulary — each rule traceable to an observed failure class.
211+
2. **Publish linter** (security domain, landed in P3 as `validateSecurityPosture` in
212+
`@objectstack/lint`, gating `os compile`): unset OWD, retired OWD aliases, external dial wider
213+
than internal, non-admin superuser wildcards, high-privilege everyone-suggested sets, forbidden
214+
vocabulary — each rule traceable to an observed failure class and mirrored by a runtime gate.
213215
3. **Access-matrix snapshot**: publishes evaluate representative positions × objects and diff
214216
against the committed matrix; an unchanged matrix auto-passes, a changed one raises a human gate
215217
showing the *semantic* impact ("grants `sales_rep` (~1,200 users) org-wide read on
@@ -285,6 +287,26 @@ anything outside the allowlist — **including to themselves** — and can never
285287
things (the `everyone`/`guest` anchors, security publishes). Headquarters keeps one dashboard:
286288
the same explain engine answers "who *could have* granted this", not just "who did".
287289

290+
**How it is authored (landed in P3).** An admin scope is a field on an ordinary permission set
291+
(`adminScope: { businessUnit, includeSubtree, manageAssignments, manageBindings,
292+
authorEnvironmentSets, assignablePermissionSets[] }`), so it is distributed via positions and
293+
audited like every other grant. The same set should also carry plain CRUD on the RBAC link
294+
tables (`sys_user_position`, `sys_position_permission_set`, `sys_user_permission_set`) — the
295+
scope authorizes *what* may be administered, the CRUD bits let the requests through at all.
296+
Runtime rules enforced by the `DelegatedAdminGate` (plugin-security):
297+
298+
- assignments a delegate creates must be **anchored** (`sys_user_position.business_unit_id`)
299+
inside their subtree, and are `granted_by`-stamped automatically;
300+
- every set reached by the write — bound to the assigned position, or granted directly — must be
301+
in the allowlist; re-composing a position (bindings) requires every current holder to sit
302+
inside the subtree;
303+
- granting or authoring a set that itself carries an `adminScope` requires a held scope that
304+
**strictly contains** it (handing your own exact scope to a peer is refused — no lateral
305+
propagation);
306+
- delegates write **single rows by id** only (a broad filter-write cannot be boundary-checked);
307+
- holders of plain CRUD on the RBAC tables with **no** scope are refused: administration is a
308+
scoped capability now, not a side effect of table access.
309+
288310
Planned next (tracked as follow-up ADRs, not yet in the model): **expiring grants** (contractor
289311
access that ends on a date, stand-in approvers during vacations, break-glass access that
290312
auto-revokes), **separation-of-duties rules** ("the person who creates vendors must not also

examples/app-crm/src/objects/account.object.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';
44

55
export const Account = ObjectSchema.create({
66
name: 'crm_account',
7+
// [ADR-0090 D1] Explicit grandfather stamp: record isolation for this demo
8+
// object is intentionally org-shared; without this the new secure default
9+
// (unset OWD => private) would owner-filter it, and the D7 publish linter
10+
// (security-owd-unset) fails the build on an undeclared baseline.
11+
sharingModel: 'public_read_write',
712
label: 'Account',
813
pluralLabel: 'Accounts',
914
icon: 'building',

examples/app-crm/src/objects/activity.object.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';
44

55
export const Activity = ObjectSchema.create({
66
name: 'crm_activity',
7+
// [ADR-0090 D1] Explicit grandfather stamp: record isolation for this demo
8+
// object is intentionally org-shared; without this the new secure default
9+
// (unset OWD => private) would owner-filter it, and the D7 publish linter
10+
// (security-owd-unset) fails the build on an undeclared baseline.
11+
sharingModel: 'public_read_write',
712
label: 'Activity',
813
pluralLabel: 'Activities',
914
icon: 'calendar-check',

examples/app-crm/src/objects/contact.object.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import { cel } from '@objectstack/spec';
55

66
export const Contact = ObjectSchema.create({
77
name: 'crm_contact',
8+
// [ADR-0090 D1] Explicit grandfather stamp: record isolation for this demo
9+
// object is intentionally org-shared; without this the new secure default
10+
// (unset OWD => private) would owner-filter it, and the D7 publish linter
11+
// (security-owd-unset) fails the build on an undeclared baseline.
12+
sharingModel: 'public_read_write',
813
label: 'Contact',
914
pluralLabel: 'Contacts',
1015
icon: 'user',

examples/app-crm/src/objects/lead.object.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import { cel, P } from '@objectstack/spec';
55

66
export const Lead = ObjectSchema.create({
77
name: 'crm_lead',
8+
// [ADR-0090 D1] Explicit grandfather stamp: record isolation for this demo
9+
// object is intentionally org-shared; without this the new secure default
10+
// (unset OWD => private) would owner-filter it, and the D7 publish linter
11+
// (security-owd-unset) fails the build on an undeclared baseline.
12+
sharingModel: 'public_read_write',
813
label: 'Lead',
914
pluralLabel: 'Leads',
1015
icon: 'funnel',

examples/app-crm/src/objects/opportunity-line-item.object.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ import { cel } from '@objectstack/spec';
1616
*/
1717
export const OpportunityLineItem = ObjectSchema.create({
1818
name: 'crm_opportunity_line_item',
19+
// [ADR-0090 D1/D4] Master-detail child: record access follows the parent
20+
// opportunity (the D7 publish linter requires the baseline to be declared).
21+
sharingModel: 'controlled_by_parent',
1922
label: 'Line Item',
2023
pluralLabel: 'Line Items',
2124
icon: 'list',

examples/app-crm/src/objects/opportunity.object.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import { cel, P } from '@objectstack/spec';
55

66
export const Opportunity = ObjectSchema.create({
77
name: 'crm_opportunity',
8+
// [ADR-0090 D1] Explicit grandfather stamp: record isolation for this demo
9+
// object is intentionally org-shared; without this the new secure default
10+
// (unset OWD => private) would owner-filter it, and the D7 publish linter
11+
// (security-owd-unset) fails the build on an undeclared baseline.
12+
sharingModel: 'public_read_write',
813
label: 'Opportunity',
914
pluralLabel: 'Opportunities',
1015
icon: 'trending-up',

examples/app-showcase/src/data/objects/semantic-zoo.object.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ export const SemanticZoo = ObjectSchema.create({
5656

5757
export const SemanticZooLegacy = ObjectSchema.create({
5858
name: 'showcase_semantic_zoo_legacy',
59+
// [ADR-0090 D1] Explicit grandfather stamp: record isolation for this demo
60+
// object is RLS-owned / intentionally public; without this the new secure
61+
// default (unset OWD => private) would owner-filter it.
62+
sharingModel: 'public_read_write',
5963
label: 'Semantic Zoo (Legacy)',
6064
pluralLabel: 'Semantic Zoo Legacies',
6165
icon: 'flask-round',

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ export const Team = ObjectSchema.create({
2828
/** Junction row joining Team ↔ Project (many-to-many). */
2929
export const ProjectMembership = ObjectSchema.create({
3030
name: 'showcase_project_membership',
31+
// [ADR-0090 D1] Explicit grandfather stamp: record isolation for this demo
32+
// object is RLS-owned / intentionally public; without this the new secure
33+
// default (unset OWD => private) would owner-filter it.
34+
sharingModel: 'public_read_write',
3135
label: 'Project Membership',
3236
pluralLabel: 'Project Memberships',
3337
icon: 'link',
@@ -36,8 +40,10 @@ export const ProjectMembership = ObjectSchema.create({
3640
fields: {
3741
team: Field.masterDetail('showcase_team', { label: 'Team', required: true }),
3842
project: Field.masterDetail('showcase_project', { label: 'Project', required: true }),
39-
role: Field.select({
40-
label: 'Role',
43+
// [ADR-0090 D3] Formerly `role` — reserved word; this is the team's
44+
// engagement on the project, not a capability container.
45+
engagement: Field.select({
46+
label: 'Engagement',
4147
options: [
4248
{ label: 'Owner', value: 'owner', default: true },
4349
{ label: 'Contributor', value: 'contributor' },

0 commit comments

Comments
 (0)