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/18535-anchor-declared-capabilities-consumers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/plugin-security': minor
---

The `everyone`-anchor doors now pass the stack's declared capabilities, so an app capability token a stack DECLARES no longer makes its `isDefault` set unbindable (#18535).

ADR-0090 D5 rules the `everyone`-anchor offending list as 「平台系统权限;带 package provenance 的应用声明 capability 令牌不计」, and PR #17811 landed the predicate that implements it: `describeHighPrivilegeBits(def, context?)` excuses a `systemPermissions` name when the caller says this stack declared it. No consumer in this package passed a context, so all three doors kept judging an app's own gate exactly like `manage_users` — declared ≠ enforced on a contract both the ADR and the spec had already ruled, and an app that declared a capability its navigation gates on could not ship the "every employee holds this" set those gates need.

All three now read one source — the stack's `capabilities:` declarations, through `readDeclaredCapabilityContext` (registry first, metadata service as the fallback, exactly as the `sys_capability` seeder reads them):

- **the boot binding** (`bindBaselineToEveryone`) — the ADR-0090 D5 bind of the configured baseline set(s) to this organization's `everyone` anchor;
- **the engine write gate** on a `sys_position_permission_set` insert/update, read at most once per pass and only once an anchor row is in play;
- **`confirmAudienceBindingSuggestion`**'s early refusal, which is the friendly rendition of that same gate — one source is what keeps it from answering "confirmed" and then having its own insert refused under it.

**Why the declarations and not the `sys_capability` rows.** The predicate's docblock names the rows at boot, but the boot binding runs BEFORE `bootstrapDeclaredCapabilities` seeds them (the bind must follow `bootstrapBuiltinRoles`, which seeds the anchor, and precede the suggestion reconciliation), so the rows are empty there on a first boot. Reading them would refuse every declared token one layer in.

**Two things do not move.** The platform floor is absolute — declaring a capability named `manage_users` launders nothing, because the predicate applies `PLATFORM_CAPABILITY_NAMES` itself — and an UNDECLARED name still refuses at every door, as does every unreadable or empty declaration list (「omission refuses」). The `guest` tier is untouched: the predicate drops the context for it by contract.

**What changes for a consumer:** a permission set whose `systemPermissions` names only capabilities the stack declares, marked `isDefault: true`, now binds to `everyone` at boot instead of logging `refusing to bind fallback set to everyone`. If you were relying on that refusal to keep such a set unbound, remove the token from the set or stop declaring the capability.

Clause-②: yes (widening)
17 changes: 17 additions & 0 deletions .changeset/18535-lint-anchor-declared-capabilities.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/lint': minor
---

`security-anchor-high-privilege` now reads the stack's own `capabilities:` declarations, so a declared app capability token on an `isDefault` set lints clean (#18535).

The rule holds an `isDefault: true` set to the `everyone`-anchor tier at authoring time, and ADR-0090 D5 puts 「带 package provenance 的应用声明 capability 令牌」 outside that tier's offending list. The rule called `describeAnchorForbiddenBits(ps, 'everyone')` with no `AnchorBindingContext`, so it reported an error for a set the runtime — once it reads the same declarations — binds without complaint. A lint that refuses what the runtime accepts is the drift ADR-0049 says not to ship, in the direction that is hardest to notice: the author never gets to the runtime.

`validateSecurityPosture` now builds the context from `stack.capabilities` and passes it at that one call site. Nothing else about the rule moves:

- an **undeclared** `systemPermissions` token still errors — membership in the declaration list is what excuses a token, not the presence of a `capabilities:` collection;
- a **platform** capability still errors even when the stack declares a capability of that name: the platform floor lives inside the predicate, shared with the runtime gate;
- a stack that declares nothing gets the pre-#17811 verdict verbatim.

**What changes for a consumer:** `os validate` (and any other caller of this rule) stops reporting `security-anchor-high-privilege` on an `isDefault` set whose `systemPermissions` names only capabilities the same stack declares. A stack that was editing its set to silence this rule can declare the capability instead — which is what the ADR asks for, since the declaration is what the runtime reads at boot.

Clause-②: yes (widening)
78 changes: 77 additions & 1 deletion packages/lint/src/validate-security-posture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,67 @@ describe('validateSecurityPosture (ADR-0090 D7)', () => {
).toEqual([]);
});

// ── [#18535] …and the ADR-0090 D5 half of that rule: 「平台系统权限;带
// package provenance 的应用声明 capability 令牌不计」. The predicate has taken
// an `AnchorBindingContext` since PR #17811; this rule passes the stack's own
// `capabilities:` declarations into it, which is what makes an app's
// "every employee holds this" set authorable at all. Three cases, because a
// single one of them is satisfied by both a correct rule and a rule that
// stopped judging `systemPermissions` altogether.
it('accepts an isDefault set whose systemPermissions token THIS stack declares (ADR-0090 D5)', () => {
expect(
rulesOf({
capabilities: [{ name: 'crm.export_pipeline', label: 'Export Pipeline' }],
permissions: [
{
name: 'app_default',
isDefault: true,
systemPermissions: ['crm.export_pipeline'],
objects: { invoice: { allowRead: true } },
},
],
}),
).toEqual([]);
});

it('still errors on an UNDECLARED systemPermissions token — the control for the case above', () => {
const findings = validateSecurityPosture({
// A real declaration list, naming a DIFFERENT capability: this pins that
// membership is what excuses a token, never the mere presence of a
// `capabilities:` collection on the stack.
capabilities: [{ name: 'crm.export_pipeline', label: 'Export Pipeline' }],
permissions: [
{
name: 'app_default',
isDefault: true,
systemPermissions: ['crm.settle_ledger'],
objects: { invoice: { allowRead: true } },
},
],
}).filter((f) => f.rule === SECURITY_ANCHOR_HIGH_PRIVILEGE);
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('system permissions');
});

it('still errors on a PLATFORM capability even when the stack declares a capability of that name', () => {
// The platform floor, applied inside the predicate: declaring
// `manage_users` must not launder it past the anchor gate. Keeping this
// case beside the two above is what stops the lint and the runtime gate
// from drifting — both read the same `PLATFORM_CAPABILITY_NAMES`.
const findings = validateSecurityPosture({
capabilities: [{ name: 'manage_users', label: 'Not Yours' }],
permissions: [
{
name: 'app_default',
isDefault: true,
systemPermissions: ['manage_users'],
objects: { invoice: { allowRead: true } },
},
],
}).filter((f) => f.rule === SECURITY_ANCHOR_HIGH_PRIVILEGE);
expect(findings).toHaveLength(1);
});

// ── Rule: security-role-word (ADR-0090 D3) ──────────────────────────
// [#8310] Its own function (and registry entry) since the rest of the block
// crossed the runtime publish surface — same file, same rule id, same
Expand Down Expand Up @@ -1189,7 +1250,11 @@ const NOT_SCHEMA_RECEIVERS: Record<string, string> = {
const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: string; keys: () => string[] }> = [
{
receiver: 'stack',
expected: ['apps', 'books', 'data', 'objects', 'permissions', 'positions'],
// [#18535] `capabilities` joined the list when the ADR-0090 D5 anchor rule
// started passing the stack's own capability declarations to the predicate
// as `AnchorBindingContext.declaredCapabilities` — a declared token is the
// app's own gate, not a platform system permission.
expected: ['apps', 'books', 'capabilities', 'data', 'objects', 'permissions', 'positions'],
declaredBy: 'ObjectStackSchema',
keys: () => Object.keys(ObjectStackSchema.shape),
},
Expand Down Expand Up @@ -1261,6 +1326,16 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s
declaredBy: 'ObjectStackSchema.data[]',
keys: () => shapeKeysOf(ObjectStackSchema.shape.data),
},
// [#18535] The ADR-0066 D1 capability declarations the anchor rule reads: it
// wants their NAMES and hands the declarations themselves to the predicate,
// which reads `name` and ignores every other field — so nothing is
// transcribed here and this stays a one-key surface.
{
receiver: 'cap',
expected: ['name'],
declaredBy: 'ObjectStackSchema.capabilities[]',
keys: () => shapeKeysOf(ObjectStackSchema.shape.capabilities),
},
];

/** The `.shape` object itself (not just its keys) of a wrapped collection. */
Expand Down Expand Up @@ -1304,6 +1379,7 @@ describe('validateSecurityPosture — reads only keys the spec declares (meta-te
'declared', // #16108: one object's sorted field-name list — `.length` / `.slice` / `.join`.
'entries', // #7503: the rule's own field list — `.find`, a JS method.
'matched', // #14747: one tier's candidate list — `.length` / `.map`, JS methods.
'declaredCapabilities', // #18535: the stack's own capability list — `.length`, a JS property.
]);
expect(receivers.filter((r) => !tabled.has(r) && !PLUMBING.has(r))).toEqual([]);
});
Expand Down
18 changes: 16 additions & 2 deletions packages/lint/src/validate-security-posture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@
*/

import { referenceCarrierOf } from '@objectstack/spec/data';
import { describeAnchorForbiddenBits } from '@objectstack/spec/security';
import { describeAnchorForbiddenBits, type AnchorBindingContext } from '@objectstack/spec/security';
import { indexObjectGraph, recordsOf, type ObjectGraph } from './object-graph.js';

export const SECURITY_OWD_UNSET = 'security-owd-unset';
Expand Down Expand Up @@ -427,6 +427,20 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number }

const objects = recordsOf(stack.objects);
const permissionSets = recordsOf(stack.permissions);
// [#18535, ADR-0090 D5 / ADR-0066 D1] The stack's own capability
// declarations, handed to the anchor predicate as
// `AnchorBindingContext.declaredCapabilities` — the authoring-time half of
// the source the runtime reads at boot. A `systemPermissions` token this
// stack DECLARES is the app's own gate and does not make an `isDefault` set
// unbindable (the runtime agrees, so the lint and the gate stay one rule);
// an UNDECLARED token still offends, and the platform floor is applied by
// the predicate itself, so declaring `manage_users` excuses nothing.
// No declarations ⇒ `undefined` ⇒ the pre-#17811 verdict verbatim.
const declaredCapabilities = recordsOf(stack.capabilities).filter(
(cap) => typeof cap.name === 'string' && cap.name.length > 0,
);
const anchorContext: AnchorBindingContext | undefined =
declaredCapabilities.length > 0 ? { declaredCapabilities } : undefined;

// ── D1/D4/D11: per-object OWD posture ────────────────────────────────
for (let i = 0; i < objects.length; i++) {
Expand Down Expand Up @@ -778,7 +792,7 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number }
// this rule to key off, so that binding is outside what a package-time
// linter can see and is judged by the bind-time gate alone (#16110).
if (ps.isDefault === true) {
const offending = describeAnchorForbiddenBits(ps, 'everyone');
const offending = describeAnchorForbiddenBits(ps, 'everyone', anchorContext);
if (offending) {
findings.push({
severity: 'error',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#18535, ADR-0090 D5 / ADR-0066 D1] The {@link AnchorBindingContext} the
* audience-anchor predicates need, read from the stack's `capabilities:`
* declarations.
*
* `describeHighPrivilegeBits` / `describeAnchorForbiddenBits`
* (`@objectstack/spec/security`) are pure and synchronous: they read one
* permission-set definition and cannot discover which capability names THIS
* stack declared. That fact belongs to the caller, and until this module
* existed no runtime caller passed it — so a `systemPermissions` token an app
* had DECLARED was judged exactly like `manage_users`, and the app's own
* `isDefault` set was refused at the `everyone` anchor. ADR-0090 D5 rules the
* opposite: 「平台系统权限;带 package provenance 的应用声明 capability 令牌不计」.
*
* ## Why the METADATA declarations and not the `sys_capability` rows
*
* The predicate's docblock names two sources — the `sys_capability` rows
* carrying `managed_by:'package'` at boot, the stack's own `capabilities`
* array at authoring time. At the boot moment the anchor binding runs, the
* rows DO NOT EXIST YET: `runBootstrap` binds the baseline to `everyone`
* before it calls `bootstrapDeclaredCapabilities`, and that order is fixed by
* two other constraints (the binding must follow `bootstrapBuiltinRoles`,
* which seeds the anchor, and precede the suggestion reconciliation). Reading
* the rows there would read an empty table on a first boot and refuse every
* declared token — the defect this module removes, reintroduced one layer in.
*
* So all three runtime consumers read the DECLARATIONS, through the same
* two-step the seeder itself reads them by (registry first, metadata service
* as the fallback). One source for the three verdicts is not a convenience:
* `confirmAudienceBindingSuggestion` is the friendly early rendition of the
* gate the engine middleware re-enforces on the insert, so a second source
* there would let a confirm pass its own check and then be refused by the
* write it performs.
*
* ⛔ Never derive this list from the set under test — the predicate's own
* docblock says why: a "declared" list read off `systemPermissions` excuses
* every token by construction and turns the gate off. And nothing here filters
* by platform-ness: {@link describeHighPrivilegeBits} applies the platform
* floor itself, so a capability declared under a curated platform name is
* still high-privilege however it reaches this list.
*
* Fails CLOSED at every step: an unreadable registry, an unreadable metadata
* service, a declaration with no `name` and an empty stack all yield
* `undefined`, which is the pre-#17811 verdict verbatim (「omission refuses」).
*/

import type { AnchorBindingContext } from '@objectstack/spec/security';
import { readDeclared } from './bootstrap-declared-permissions.js';

/**
* Read this stack's declared authorization capabilities as an
* {@link AnchorBindingContext}, or `undefined` when it declares none.
*
* The declarations are handed over as they are — the predicate reads `name`
* off each entry and ignores every other field, so nothing is transcribed and
* a shape change in `CapabilityDeclarationSchema` cannot desynchronize a copy.
*
* @param ql The ObjectQL engine handle (its registry is the primary source).
* @param metadataService The metadata service, read only when the registry
* lists nothing — the same fallback `bootstrapDeclaredCapabilities` uses.
*/
export async function readDeclaredCapabilityContext(
ql: any,
metadataService?: any,
): Promise<AnchorBindingContext | undefined> {
let caps: any[] = readDeclared(ql, 'capability');
if (caps.length === 0) {
try {
const listed = metadataService?.list?.('capability');
caps = typeof (listed as any)?.then === 'function' ? await listed : (listed ?? []);
} catch { caps = []; }
}
if (!Array.isArray(caps)) return undefined;
const declared = caps.filter(
(c) => c && typeof c === 'object' && typeof (c as { name?: unknown }).name === 'string'
&& (c as { name: string }).name.length > 0,
);
return declared.length > 0 ? { declaredCapabilities: declared } : undefined;
}
Loading
Loading