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
21 changes: 21 additions & 0 deletions .changeset/adr-0090-p3-linter-delegated-admin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@objectstack/spec": minor
"@objectstack/lint": minor
"@objectstack/cli": minor
"@objectstack/plugin-security": major
"@objectstack/platform-objects": major
---

ADR-0090 P3 — security-domain publish linter (D7) and delegated administration (D12).

**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`.

**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.

**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`).

**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).

**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).

**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).
26 changes: 24 additions & 2 deletions docs/design/permission-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,10 @@ being **structured data**:

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

**How it is authored (landed in P3).** An admin scope is a field on an ordinary permission set
(`adminScope: { businessUnit, includeSubtree, manageAssignments, manageBindings,
authorEnvironmentSets, assignablePermissionSets[] }`), so it is distributed via positions and
audited like every other grant. The same set should also carry plain CRUD on the RBAC link
tables (`sys_user_position`, `sys_position_permission_set`, `sys_user_permission_set`) — the
scope authorizes *what* may be administered, the CRUD bits let the requests through at all.
Runtime rules enforced by the `DelegatedAdminGate` (plugin-security):

- assignments a delegate creates must be **anchored** (`sys_user_position.business_unit_id`)
inside their subtree, and are `granted_by`-stamped automatically;
- every set reached by the write — bound to the assigned position, or granted directly — must be
in the allowlist; re-composing a position (bindings) requires every current holder to sit
inside the subtree;
- granting or authoring a set that itself carries an `adminScope` requires a held scope that
**strictly contains** it (handing your own exact scope to a peer is refused — no lateral
propagation);
- delegates write **single rows by id** only (a broad filter-write cannot be boundary-checked);
- holders of plain CRUD on the RBAC tables with **no** scope are refused: administration is a
scoped capability now, not a side effect of table access.

Planned next (tracked as follow-up ADRs, not yet in the model): **expiring grants** (contractor
access that ends on a date, stand-in approvers during vacations, break-glass access that
auto-revokes), **separation-of-duties rules** ("the person who creates vendors must not also
Expand Down
5 changes: 5 additions & 0 deletions examples/app-crm/src/objects/account.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';

export const Account = ObjectSchema.create({
name: 'crm_account',
// [ADR-0090 D1] Explicit grandfather stamp: record isolation for this demo
// object is intentionally org-shared; without this the new secure default
// (unset OWD => private) would owner-filter it, and the D7 publish linter
// (security-owd-unset) fails the build on an undeclared baseline.
sharingModel: 'public_read_write',
label: 'Account',
pluralLabel: 'Accounts',
icon: 'building',
Expand Down
5 changes: 5 additions & 0 deletions examples/app-crm/src/objects/activity.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';

export const Activity = ObjectSchema.create({
name: 'crm_activity',
// [ADR-0090 D1] Explicit grandfather stamp: record isolation for this demo
// object is intentionally org-shared; without this the new secure default
// (unset OWD => private) would owner-filter it, and the D7 publish linter
// (security-owd-unset) fails the build on an undeclared baseline.
sharingModel: 'public_read_write',
label: 'Activity',
pluralLabel: 'Activities',
icon: 'calendar-check',
Expand Down
5 changes: 5 additions & 0 deletions examples/app-crm/src/objects/contact.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import { cel } from '@objectstack/spec';

export const Contact = ObjectSchema.create({
name: 'crm_contact',
// [ADR-0090 D1] Explicit grandfather stamp: record isolation for this demo
// object is intentionally org-shared; without this the new secure default
// (unset OWD => private) would owner-filter it, and the D7 publish linter
// (security-owd-unset) fails the build on an undeclared baseline.
sharingModel: 'public_read_write',
label: 'Contact',
pluralLabel: 'Contacts',
icon: 'user',
Expand Down
5 changes: 5 additions & 0 deletions examples/app-crm/src/objects/lead.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import { cel, P } from '@objectstack/spec';

export const Lead = ObjectSchema.create({
name: 'crm_lead',
// [ADR-0090 D1] Explicit grandfather stamp: record isolation for this demo
// object is intentionally org-shared; without this the new secure default
// (unset OWD => private) would owner-filter it, and the D7 publish linter
// (security-owd-unset) fails the build on an undeclared baseline.
sharingModel: 'public_read_write',
label: 'Lead',
pluralLabel: 'Leads',
icon: 'funnel',
Expand Down
3 changes: 3 additions & 0 deletions examples/app-crm/src/objects/opportunity-line-item.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import { cel } from '@objectstack/spec';
*/
export const OpportunityLineItem = ObjectSchema.create({
name: 'crm_opportunity_line_item',
// [ADR-0090 D1/D4] Master-detail child: record access follows the parent
// opportunity (the D7 publish linter requires the baseline to be declared).
sharingModel: 'controlled_by_parent',
label: 'Line Item',
pluralLabel: 'Line Items',
icon: 'list',
Expand Down
5 changes: 5 additions & 0 deletions examples/app-crm/src/objects/opportunity.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import { cel, P } from '@objectstack/spec';

export const Opportunity = ObjectSchema.create({
name: 'crm_opportunity',
// [ADR-0090 D1] Explicit grandfather stamp: record isolation for this demo
// object is intentionally org-shared; without this the new secure default
// (unset OWD => private) would owner-filter it, and the D7 publish linter
// (security-owd-unset) fails the build on an undeclared baseline.
sharingModel: 'public_read_write',
label: 'Opportunity',
pluralLabel: 'Opportunities',
icon: 'trending-up',
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 @@ -56,6 +56,10 @@ export const SemanticZoo = ObjectSchema.create({

export const SemanticZooLegacy = ObjectSchema.create({
name: 'showcase_semantic_zoo_legacy',
// [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 (Legacy)',
pluralLabel: 'Semantic Zoo Legacies',
icon: 'flask-round',
Expand Down
10 changes: 8 additions & 2 deletions examples/app-showcase/src/data/objects/team.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ export const Team = ObjectSchema.create({
/** Junction row joining Team ↔ Project (many-to-many). */
export const ProjectMembership = ObjectSchema.create({
name: 'showcase_project_membership',
// [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 Membership',
pluralLabel: 'Project Memberships',
icon: 'link',
Expand All @@ -36,8 +40,10 @@ export const ProjectMembership = ObjectSchema.create({
fields: {
team: Field.masterDetail('showcase_team', { label: 'Team', required: true }),
project: Field.masterDetail('showcase_project', { label: 'Project', required: true }),
role: Field.select({
label: 'Role',
// [ADR-0090 D3] Formerly `role` — reserved word; this is the team's
// engagement on the project, not a capability container.
engagement: Field.select({
label: 'Engagement',
options: [
{ label: 'Owner', value: 'owner', default: true },
{ label: 'Contributor', value: 'contributor' },
Expand Down
6 changes: 3 additions & 3 deletions examples/app-showcase/src/data/seed/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,9 @@ const products = defineSeed(Product, {
const memberships = defineSeed(ProjectMembership, {
mode: 'insert',
records: [
{ team: 'Experience', project: 'Website Relaunch', role: 'owner', allocation_percent: 80 },
{ team: 'Platform', project: 'Data Platform', role: 'owner', allocation_percent: 100 },
{ team: 'Platform', project: 'Website Relaunch', role: 'contributor', allocation_percent: 20 },
{ team: 'Experience', project: 'Website Relaunch', engagement: 'owner', allocation_percent: 80 },
{ team: 'Platform', project: 'Data Platform', engagement: 'owner', allocation_percent: 100 },
{ team: 'Platform', project: 'Website Relaunch', engagement: 'contributor', allocation_percent: 20 },
],
});

Expand Down
4 changes: 2 additions & 2 deletions examples/app-showcase/src/system/translations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export const ShowcaseTranslationBundle = {
},
showcase_project_membership: {
label: 'Membership', pluralLabel: 'Memberships',
fields: { team: { label: 'Team' }, project: { label: 'Project' }, role: { label: 'Role' }, allocation_percent: { label: 'Allocation %' } },
fields: { team: { label: 'Team' }, project: { label: 'Project' }, engagement: { label: 'Engagement' }, allocation_percent: { label: 'Allocation %' } },
},
showcase_category: {
label: 'Category', pluralLabel: 'Categories',
Expand Down Expand Up @@ -232,7 +232,7 @@ export const ShowcaseTranslationBundle = {
},
showcase_project_membership: {
label: '成员', pluralLabel: '成员',
fields: { team: { label: '团队' }, project: { label: '项目' }, role: { label: '角色' }, allocation_percent: { label: '分配比例' } },
fields: { team: { label: '团队' }, project: { label: '项目' }, engagement: { label: '协作职能' }, allocation_percent: { label: '分配比例' } },
},
showcase_category: {
label: '分类', pluralLabel: '分类',
Expand Down
4 changes: 4 additions & 0 deletions examples/app-todo/src/objects/task.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';

export const Task = ObjectSchema.create({
name: 'todo_task',
// [ADR-0090 D1] Explicit grandfather stamp: this demo object is
// intentionally org-shared; without it the secure default (unset OWD =>
// private) owner-filters it and the D7 publish linter fails the build.
sharingModel: 'public_read_write',
label: 'Task',
pluralLabel: 'Tasks',
icon: 'check-square',
Expand Down
34 changes: 34 additions & 0 deletions packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { lowerCallables } from '../utils/lower-callables.js';
import { validateStackExpressions } from '@objectstack/lint';
import { validateWidgetBindings } from '@objectstack/lint';
import { validateResponsiveStyles } from '@objectstack/lint';
import { validateSecurityPosture } from '@objectstack/lint';
import { lintFlowPatterns } from '../utils/lint-flow-patterns.js';
import { lintAutonumberFormats } from '../utils/lint-autonumber-formats.js';
import { lintLivenessProperties } from '../utils/lint-liveness-properties.js';
Expand Down Expand Up @@ -323,6 +324,39 @@ export default class Compile extends Command {
}
}

// 3e. [ADR-0090 D7] Security-domain publish linter. Every error rule
// mirrors a runtime enforcement point (fail-closed OWD default,
// canonical enum, anchor binding gate, vocabulary freeze) — the lint
// moves the failure from a runtime deny to an author-time fix-it.
// Errors GATE the build (per ADR-0049 this is not advisory
// security); `info` findings are printed dimmed and never fatal.
if (!flags.json) printStep('Checking security posture (ADR-0090 D7)...');
const securityFindings = validateSecurityPosture(result.data as Record<string, unknown>);
const securityErrors = securityFindings.filter((f) => f.severity === 'error');
const securityAdvisories = securityFindings.filter((f) => f.severity !== 'error');
if (securityErrors.length > 0) {
if (flags.json) {
console.log(JSON.stringify({ success: false, error: 'security posture validation failed', issues: securityErrors }));
this.exit(1);
}
console.log('');
printError(`Security posture check failed (${securityErrors.length} issue${securityErrors.length > 1 ? 's' : ''})`);
for (const f of securityErrors.slice(0, 50)) {
console.log(` • ${f.where}: ${f.message}`);
console.log(chalk.dim(` ${f.hint}`));
console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`));
}
this.exit(1);
}
if (securityAdvisories.length > 0 && !flags.json) {
console.log('');
for (const f of securityAdvisories) {
printWarning(`${f.where}: ${f.message}`);
console.log(chalk.dim(` ${f.hint}`));
console.log(chalk.dim(` rule: ${f.rule}`));
}
}

// 3d. Package docs (ADR-0046): compile flat `src/docs/*.md` into
// `docs: DocSchema[]` and lint the combined set (flatness,
// namespace-prefixed names, MDX/image ban, same-package link
Expand Down
19 changes: 18 additions & 1 deletion packages/cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js';
import { computeI18nCoverage } from '../utils/i18n-coverage.js';
import { lintDataModel } from '../lint/data-model-rules.js';
import { validateWidgetBindings } from '@objectstack/lint';
import { validateRecordTitle, validateSemanticRoles, validateCapabilityReferences } from '@objectstack/lint';
import { validateRecordTitle, validateSemanticRoles, validateCapabilityReferences, validateSecurityPosture } from '@objectstack/lint';
import { collectAndLintDocs } from '../utils/collect-docs.js';
import { scoreMetadata } from '../lint/score.js';
import { runMetadataEval } from '../lint/metadata-eval.js';
Expand Down Expand Up @@ -352,6 +352,23 @@ export function lintConfig(config: any): LintIssue[] {
});
}

// ── Security posture (ADR-0090 D7) ──
// The security-domain publish linter: unset/alias OWD, external dial wider
// than internal, wildcard VAMA, high-privilege everyone-suggested sets, the
// reserved word "role", and private-object read grants with no depth. Runs
// on the NORMALIZED (pre-zod) input here, so alias values that the schema
// gate would reject in `os compile` get a located fix-it instead of a Zod
// enum error. `error` findings gate `os compile`; `info` maps to suggestion.
for (const t of validateSecurityPosture(config)) {
issues.push({
severity: t.severity === 'info' ? 'suggestion' : t.severity,
rule: t.rule,
message: `${t.where}: ${t.message}`,
path: t.path,
fix: t.hint,
});
}

return issues;
}

Expand Down
Loading