Skip to content

Commit ae7e4c9

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-17735-sdui-manifest-producer-parity
2 parents 2298170 + 21b7c12 commit ae7e4c9

11 files changed

Lines changed: 1290 additions & 10 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
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).
6+
7+
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.
8+
9+
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):
10+
11+
- **the boot binding** (`bindBaselineToEveryone`) — the ADR-0090 D5 bind of the configured baseline set(s) to this organization's `everyone` anchor;
12+
- **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;
13+
- **`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.
14+
15+
**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.
16+
17+
**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.
18+
19+
**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.
20+
21+
Clause-②: yes (widening)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
`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).
6+
7+
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.
8+
9+
`validateSecurityPosture` now builds the context from `stack.capabilities` and passes it at that one call site. Nothing else about the rule moves:
10+
11+
- an **undeclared** `systemPermissions` token still errors — membership in the declaration list is what excuses a token, not the presence of a `capabilities:` collection;
12+
- 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;
13+
- a stack that declares nothing gets the pre-#17811 verdict verbatim.
14+
15+
**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.
16+
17+
Clause-②: yes (widening)

packages/lint/src/validate-security-posture.test.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,67 @@ describe('validateSecurityPosture (ADR-0090 D7)', () => {
447447
).toEqual([]);
448448
});
449449

450+
// ── [#18535] …and the ADR-0090 D5 half of that rule: 「平台系统权限;带
451+
// package provenance 的应用声明 capability 令牌不计」. The predicate has taken
452+
// an `AnchorBindingContext` since PR #17811; this rule passes the stack's own
453+
// `capabilities:` declarations into it, which is what makes an app's
454+
// "every employee holds this" set authorable at all. Three cases, because a
455+
// single one of them is satisfied by both a correct rule and a rule that
456+
// stopped judging `systemPermissions` altogether.
457+
it('accepts an isDefault set whose systemPermissions token THIS stack declares (ADR-0090 D5)', () => {
458+
expect(
459+
rulesOf({
460+
capabilities: [{ name: 'crm.export_pipeline', label: 'Export Pipeline' }],
461+
permissions: [
462+
{
463+
name: 'app_default',
464+
isDefault: true,
465+
systemPermissions: ['crm.export_pipeline'],
466+
objects: { invoice: { allowRead: true } },
467+
},
468+
],
469+
}),
470+
).toEqual([]);
471+
});
472+
473+
it('still errors on an UNDECLARED systemPermissions token — the control for the case above', () => {
474+
const findings = validateSecurityPosture({
475+
// A real declaration list, naming a DIFFERENT capability: this pins that
476+
// membership is what excuses a token, never the mere presence of a
477+
// `capabilities:` collection on the stack.
478+
capabilities: [{ name: 'crm.export_pipeline', label: 'Export Pipeline' }],
479+
permissions: [
480+
{
481+
name: 'app_default',
482+
isDefault: true,
483+
systemPermissions: ['crm.settle_ledger'],
484+
objects: { invoice: { allowRead: true } },
485+
},
486+
],
487+
}).filter((f) => f.rule === SECURITY_ANCHOR_HIGH_PRIVILEGE);
488+
expect(findings).toHaveLength(1);
489+
expect(findings[0].message).toContain('system permissions');
490+
});
491+
492+
it('still errors on a PLATFORM capability even when the stack declares a capability of that name', () => {
493+
// The platform floor, applied inside the predicate: declaring
494+
// `manage_users` must not launder it past the anchor gate. Keeping this
495+
// case beside the two above is what stops the lint and the runtime gate
496+
// from drifting — both read the same `PLATFORM_CAPABILITY_NAMES`.
497+
const findings = validateSecurityPosture({
498+
capabilities: [{ name: 'manage_users', label: 'Not Yours' }],
499+
permissions: [
500+
{
501+
name: 'app_default',
502+
isDefault: true,
503+
systemPermissions: ['manage_users'],
504+
objects: { invoice: { allowRead: true } },
505+
},
506+
],
507+
}).filter((f) => f.rule === SECURITY_ANCHOR_HIGH_PRIVILEGE);
508+
expect(findings).toHaveLength(1);
509+
});
510+
450511
// ── Rule: security-role-word (ADR-0090 D3) ──────────────────────────
451512
// [#8310] Its own function (and registry entry) since the rest of the block
452513
// crossed the runtime publish surface — same file, same rule id, same
@@ -1189,7 +1250,11 @@ const NOT_SCHEMA_RECEIVERS: Record<string, string> = {
11891250
const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: string; keys: () => string[] }> = [
11901251
{
11911252
receiver: 'stack',
1192-
expected: ['apps', 'books', 'data', 'objects', 'permissions', 'positions'],
1253+
// [#18535] `capabilities` joined the list when the ADR-0090 D5 anchor rule
1254+
// started passing the stack's own capability declarations to the predicate
1255+
// as `AnchorBindingContext.declaredCapabilities` — a declared token is the
1256+
// app's own gate, not a platform system permission.
1257+
expected: ['apps', 'books', 'capabilities', 'data', 'objects', 'permissions', 'positions'],
11931258
declaredBy: 'ObjectStackSchema',
11941259
keys: () => Object.keys(ObjectStackSchema.shape),
11951260
},
@@ -1261,6 +1326,16 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s
12611326
declaredBy: 'ObjectStackSchema.data[]',
12621327
keys: () => shapeKeysOf(ObjectStackSchema.shape.data),
12631328
},
1329+
// [#18535] The ADR-0066 D1 capability declarations the anchor rule reads: it
1330+
// wants their NAMES and hands the declarations themselves to the predicate,
1331+
// which reads `name` and ignores every other field — so nothing is
1332+
// transcribed here and this stays a one-key surface.
1333+
{
1334+
receiver: 'cap',
1335+
expected: ['name'],
1336+
declaredBy: 'ObjectStackSchema.capabilities[]',
1337+
keys: () => shapeKeysOf(ObjectStackSchema.shape.capabilities),
1338+
},
12641339
];
12651340

12661341
/** The `.shape` object itself (not just its keys) of a wrapped collection. */
@@ -1304,6 +1379,7 @@ describe('validateSecurityPosture — reads only keys the spec declares (meta-te
13041379
'declared', // #16108: one object's sorted field-name list — `.length` / `.slice` / `.join`.
13051380
'entries', // #7503: the rule's own field list — `.find`, a JS method.
13061381
'matched', // #14747: one tier's candidate list — `.length` / `.map`, JS methods.
1382+
'declaredCapabilities', // #18535: the stack's own capability list — `.length`, a JS property.
13071383
]);
13081384
expect(receivers.filter((r) => !tabled.has(r) && !PLUMBING.has(r))).toEqual([]);
13091385
});

packages/lint/src/validate-security-posture.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@
119119
*/
120120

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

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

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

431445
// ── D1/D4/D11: per-object OWD posture ────────────────────────────────
432446
for (let i = 0; i < objects.length; i++) {
@@ -778,7 +792,7 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number }
778792
// this rule to key off, so that binding is outside what a package-time
779793
// linter can see and is judged by the bind-time gate alone (#16110).
780794
if (ps.isDefault === true) {
781-
const offending = describeAnchorForbiddenBits(ps, 'everyone');
795+
const offending = describeAnchorForbiddenBits(ps, 'everyone', anchorContext);
782796
if (offending) {
783797
findings.push({
784798
severity: 'error',
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#18535, ADR-0090 D5 / ADR-0066 D1] The {@link AnchorBindingContext} the
5+
* audience-anchor predicates need, read from the stack's `capabilities:`
6+
* declarations.
7+
*
8+
* `describeHighPrivilegeBits` / `describeAnchorForbiddenBits`
9+
* (`@objectstack/spec/security`) are pure and synchronous: they read one
10+
* permission-set definition and cannot discover which capability names THIS
11+
* stack declared. That fact belongs to the caller, and until this module
12+
* existed no runtime caller passed it — so a `systemPermissions` token an app
13+
* had DECLARED was judged exactly like `manage_users`, and the app's own
14+
* `isDefault` set was refused at the `everyone` anchor. ADR-0090 D5 rules the
15+
* opposite: 「平台系统权限;带 package provenance 的应用声明 capability 令牌不计」.
16+
*
17+
* ## Why the METADATA declarations and not the `sys_capability` rows
18+
*
19+
* The predicate's docblock names two sources — the `sys_capability` rows
20+
* carrying `managed_by:'package'` at boot, the stack's own `capabilities`
21+
* array at authoring time. At the boot moment the anchor binding runs, the
22+
* rows DO NOT EXIST YET: `runBootstrap` binds the baseline to `everyone`
23+
* before it calls `bootstrapDeclaredCapabilities`, and that order is fixed by
24+
* two other constraints (the binding must follow `bootstrapBuiltinRoles`,
25+
* which seeds the anchor, and precede the suggestion reconciliation). Reading
26+
* the rows there would read an empty table on a first boot and refuse every
27+
* declared token — the defect this module removes, reintroduced one layer in.
28+
*
29+
* So all three runtime consumers read the DECLARATIONS, through the same
30+
* two-step the seeder itself reads them by (registry first, metadata service
31+
* as the fallback). One source for the three verdicts is not a convenience:
32+
* `confirmAudienceBindingSuggestion` is the friendly early rendition of the
33+
* gate the engine middleware re-enforces on the insert, so a second source
34+
* there would let a confirm pass its own check and then be refused by the
35+
* write it performs.
36+
*
37+
* ⛔ Never derive this list from the set under test — the predicate's own
38+
* docblock says why: a "declared" list read off `systemPermissions` excuses
39+
* every token by construction and turns the gate off. And nothing here filters
40+
* by platform-ness: {@link describeHighPrivilegeBits} applies the platform
41+
* floor itself, so a capability declared under a curated platform name is
42+
* still high-privilege however it reaches this list.
43+
*
44+
* Fails CLOSED at every step: an unreadable registry, an unreadable metadata
45+
* service, a declaration with no `name` and an empty stack all yield
46+
* `undefined`, which is the pre-#17811 verdict verbatim (「omission refuses」).
47+
*/
48+
49+
import type { AnchorBindingContext } from '@objectstack/spec/security';
50+
import { readDeclared } from './bootstrap-declared-permissions.js';
51+
52+
/**
53+
* Read this stack's declared authorization capabilities as an
54+
* {@link AnchorBindingContext}, or `undefined` when it declares none.
55+
*
56+
* The declarations are handed over as they are — the predicate reads `name`
57+
* off each entry and ignores every other field, so nothing is transcribed and
58+
* a shape change in `CapabilityDeclarationSchema` cannot desynchronize a copy.
59+
*
60+
* @param ql The ObjectQL engine handle (its registry is the primary source).
61+
* @param metadataService The metadata service, read only when the registry
62+
* lists nothing — the same fallback `bootstrapDeclaredCapabilities` uses.
63+
*/
64+
export async function readDeclaredCapabilityContext(
65+
ql: any,
66+
metadataService?: any,
67+
): Promise<AnchorBindingContext | undefined> {
68+
let caps: any[] = readDeclared(ql, 'capability');
69+
if (caps.length === 0) {
70+
try {
71+
const listed = metadataService?.list?.('capability');
72+
caps = typeof (listed as any)?.then === 'function' ? await listed : (listed ?? []);
73+
} catch { caps = []; }
74+
}
75+
if (!Array.isArray(caps)) return undefined;
76+
const declared = caps.filter(
77+
(c) => c && typeof c === 'object' && typeof (c as { name?: unknown }).name === 'string'
78+
&& (c as { name: string }).name.length > 0,
79+
);
80+
return declared.length > 0 ? { declaredCapabilities: declared } : undefined;
81+
}

0 commit comments

Comments
 (0)