Skip to content

Commit 47caef3

Browse files
os-zhuangclaude
andcommitted
fix(security): fail-closed for destructive ops; mark dangling permission bits experimental (ADR-0049)
The metadata liveness audit (#1878) found a cluster of security properties that parse but enforce nothing — a false sense of compliance. ADR-0049 establishes the enforce-or-remove gate: a security property must be enforced, explicitly `experimental`, or absent — never parsed-but-silently-unenforced. This is the no-regret slice (PR A) of the P0 cluster: - PermissionEvaluator now fails CLOSED for the destructive operation class (transfer/restore/purge). Previously any operation absent from OPERATION_TO_PERMISSION fell through to default-allow, so a future destructive op added without a map entry would be silently ungated. Non-destructive unknown ops retain default-allow (custom read-side ops unaffected). - ObjectPermission allowTransfer/allowRestore/allowPurge marked `[EXPERIMENTAL — not enforced]` in the spec: the operations they gate do not yet exist and no runtime consumer reads them. Re-introduced with enforcement when the feature lands (#1883). - Adds a test asserting destructive ops fail closed even for a fully- permissioned set. Refs #1878 #1883 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0c0e12f commit 47caef3

4 files changed

Lines changed: 160 additions & 6 deletions

File tree

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# ADR-0049: Spec must not declare security properties the runtime does not enforce (enforce-or-remove gate)
2+
3+
**Status**: Proposed (2026-06-15)
4+
**Deciders**: ObjectStack Protocol Architects
5+
**Builds on**: [ADR-0005](./0005-metadata-customization-overlay.md) (artifact vs runtime overlay), [ADR-0010](./0010-metadata-protection-model.md) (package provenance), [ADR-0027](./0027-metadata-authoring-lifecycle.md) (authoring lifecycle)
6+
**Consumers**: `@objectstack/spec` (security/identity schemas), `@objectstack/plugin-security` (`PermissionEvaluator`, `SecurityPlugin`), spec authors, the metadata-property liveness audit follow-ups (#1878 P0 cluster).
7+
**Surfaced by**: the metadata property liveness audit (#1878, `docs/audits/`) — which found that **roughly half of all spec properties are dead**, and that a cluster of *security* properties is **parsed but unenforced**.
8+
9+
---
10+
11+
## TL;DR
12+
13+
A protocol-level audit cross-referenced every spec property against its actual
14+
runtime consumers. The most serious finding is a cluster of **security
15+
properties that imply an access-control boundary but enforce nothing**:
16+
`PolicySchema` (100% dead — password/session/MFA/IP/audit), permission
17+
lifecycle bits (`allowTransfer`/`allowRestore`/`allowPurge`), `agent`
18+
access-control, flow `runAs`, object `apiEnabled`/`apiMethods`, action
19+
`disabled`, role `parent`, and `SharingRuleSchema`.
20+
21+
A security property that parses but does nothing is **worse than absent**: it
22+
produces a *false sense of compliance*. An admin who sets `allowPurge: false`
23+
or authors a strict password policy believes a boundary exists where none does.
24+
25+
**Decision.** A spec property that names a security/access-control boundary
26+
**must be in exactly one of three states**:
27+
28+
1. **Enforced** — a runtime consumer reads it and changes a decision (`file:line`).
29+
2. **`experimental`** — explicitly marked and documented as *not yet enforced*,
30+
so authoring it is a known no-op (roadmapped, not a promise).
31+
3. **Absent** — removed from the spec.
32+
33+
Shipping a security property in a fourth state — *parsed, unmarked, unenforced*
34+
— is prohibited. This is the **enforce-or-remove gate**.
35+
36+
A second, roadmap-independent defect compounds the first: `PermissionEvaluator`
37+
**fails open** for operations it doesn't recognise
38+
(`permission-evaluator.ts:35`, `if (!permKey) return true`). Any future
39+
destructive operation added without registering it in `OPERATION_TO_PERMISSION`
40+
is silently ungated. The evaluator must **fail closed** for the destructive
41+
operation class.
42+
43+
---
44+
45+
## Context
46+
47+
- Evidence: `docs/audits/2026-06-security-identity-property-liveness.md` and the
48+
cross-type synthesis in `docs/audits/README.md` (cluster #1).
49+
- The CRUD path *is* enforced: `SecurityPlugin` (`security-plugin.ts:326`)
50+
resolves permission sets and calls `PermissionEvaluator.checkObjectPermission`,
51+
which maps the ObjectQL operation to an `ObjectPermission` key via
52+
`OPERATION_TO_PERMISSION` (`permission-evaluator.ts:8-16`).
53+
- That map covers only `find/findOne/count/aggregate/insert/update/delete`. The
54+
three destructive permission bits in the spec
55+
(`permission.zod.ts:28-30``allowTransfer`/`allowRestore`/`allowPurge`)
56+
have **no operation pointing at them**, and the operations they describe
57+
(`transfer`/`restore`/`purge`) **do not yet exist** as ObjectQL operations.
58+
So the bits are dangling, and the `if (!permKey) return true` default means
59+
that *if* such an operation were added without a map entry, it would be
60+
allowed for everyone.
61+
62+
## Decision — staged by the platform's current (pre-MVP) phase
63+
64+
The audit's instinct was "enforce every unenforced security prop." At the
65+
current milestone that is the **wrong default**: building enforcement for
66+
features that do not exist yet is speculative. The real, shippable liability is
67+
the *false promise*, not the missing feature. So we split the P0 cluster by
68+
**whether the feature already exists**:
69+
70+
| Situation | Items | Phase action |
71+
|---|---|---|
72+
| **Feature does not exist; spec bit is a dangling promise** | `PolicySchema` (#1882), permission lifecycle bits (#1883), `SharingRuleSchema` spec form (#1887), flow `runAs` (#1888) | **Remove or mark `experimental`** now. Re-introduce *with* the feature + enforcement at M2/production. |
73+
| **Feature is live; the gate is missing or bypassed** | agent access-control (#1884), object `apiEnabled`/`apiMethods` (#1889), action `disabled` CEL (#1885) | **Enforce** now — these are real, exploitable gaps and the fix is a localized check at the route/renderer. |
74+
75+
Plus one **no-regret correctness fix**, independent of roadmap:
76+
77+
- `PermissionEvaluator` fails **closed** for the destructive operation class:
78+
introduce an explicit set of sensitive/destructive operations; an unrecognised
79+
operation in that class is **denied**, not allowed. (Non-destructive unknown
80+
operations may retain default-allow to avoid breaking custom read-side ops.)
81+
82+
### `experimental` convention for the "mark, don't remove" path
83+
84+
For a roadmapped property we keep but cannot yet enforce, annotate it so the
85+
no-op is explicit to authors and tooling, rather than silently parsing:
86+
87+
- prefix the Zod `.describe()` with **`[EXPERIMENTAL — not enforced]`**, and
88+
- where the surrounding schema already carries a status/stability enum (e.g.
89+
`model-registry.zod.ts`, `plugin-capability.zod.ts`), prefer that enum.
90+
91+
Removal is preferred over marking when there is no committed roadmap for the
92+
property — a smaller spec surface is the stronger default pre-MVP.
93+
94+
## Consequences
95+
96+
- **Positive.** No spec property silently misleads an admin about a security
97+
boundary. The evaluator can no longer be made to fail open by adding a
98+
destructive operation. The P0 cluster splits into a cheap no-regret PR
99+
(evaluator fail-closed + mark/remove dangling bits) and a small enforcement
100+
PR (live-but-ungated features), deferring the heavy work (policy registration,
101+
sharing-rule engine reconciliation) to when the feature lands.
102+
- **Negative / cost.** Removing or `experimental`-tagging spec bits is a
103+
spec-surface change; seeds/fixtures that author the removed bits must be
104+
updated (low risk pre-MVP). The fail-closed change requires enumerating the
105+
destructive operation class so legitimate custom operations are not denied.
106+
- **Follow-up.** This ADR is the umbrella decision for the #1878 P0 cluster;
107+
each sub-issue records its enforce/experimental/remove disposition against the
108+
table above.
109+
110+
## Non-goals
111+
112+
- Building the transfer/restore/purge, policy-enforcement, or sharing-rule
113+
engines themselves — those are feature work for M2/production, tracked by
114+
their respective issues.
115+
- The P1 (ADR-0021 analytics migration) and P2 (spec hygiene) clusters of
116+
#1878 — non-security, governed separately.

packages/plugins/plugin-security/src/permission-evaluator.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@ const OPERATION_TO_PERMISSION: Record<string, keyof ObjectPermission> = {
1515
delete: 'allowDelete',
1616
};
1717

18+
/**
19+
* Destructive operation class — operations that must FAIL CLOSED when they are
20+
* not mapped to a concrete permission key. See ADR-0049: an unrecognised
21+
* destructive operation (e.g. a future `transfer`/`restore`/`purge` added
22+
* without a matching `OPERATION_TO_PERMISSION` entry, gated by the spec's
23+
* `allowTransfer`/`allowRestore`/`allowPurge` bits) must be DENIED rather than
24+
* silently allowed by the default-allow fallthrough. Non-destructive unknown
25+
* operations retain default-allow so custom read-side operations are not broken.
26+
*/
27+
const DESTRUCTIVE_OPERATIONS = new Set<string>(['transfer', 'restore', 'purge']);
28+
1829
/**
1930
* PermissionEvaluator
2031
*
@@ -32,7 +43,12 @@ export class PermissionEvaluator {
3243
permissionSets: PermissionSet[]
3344
): boolean {
3445
const permKey = OPERATION_TO_PERMISSION[operation];
35-
if (!permKey) return true; // Unknown operations are allowed by default
46+
if (!permKey) {
47+
// Fail CLOSED for the destructive operation class (ADR-0049): an
48+
// unrecognised destructive op must be denied, never silently allowed.
49+
// Other unknown operations are allowed by default.
50+
return !DESTRUCTIVE_OPERATIONS.has(operation);
51+
}
3652

3753
for (const ps of permissionSets) {
3854
// Honour the `'*'` wildcard sentinel — admin permission sets typically

packages/plugins/plugin-security/src/security-plugin.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -610,11 +610,24 @@ describe('PermissionEvaluator', () => {
610610
expect(evaluator.checkObjectPermission('insert', 'contact', [ps])).toBe(false);
611611
});
612612

613-
it('should allow unknown operations by default', () => {
613+
it('should allow unknown (non-destructive) operations by default', () => {
614614
const evaluator = new PermissionEvaluator();
615615
expect(evaluator.checkObjectPermission('unknownOp', 'contact', [])).toBe(true);
616616
});
617617

618+
it('should fail CLOSED for unmapped destructive operations (ADR-0049)', () => {
619+
const evaluator = new PermissionEvaluator();
620+
// transfer/restore/purge are not in OPERATION_TO_PERMISSION; they must be
621+
// denied rather than falling through to default-allow — even for an
622+
// otherwise fully-permissioned set.
623+
const ps = makePermSet('admin', {
624+
contact: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, modifyAllRecords: true },
625+
});
626+
expect(evaluator.checkObjectPermission('transfer', 'contact', [ps])).toBe(false);
627+
expect(evaluator.checkObjectPermission('restore', 'contact', [ps])).toBe(false);
628+
expect(evaluator.checkObjectPermission('purge', 'contact', [ps])).toBe(false);
629+
});
630+
618631
it('should allow via viewAllRecords', () => {
619632
const evaluator = new PermissionEvaluator();
620633
const ps = makePermSet('viewer', { task: { allowRead: false, allowCreate: false, allowEdit: false, allowDelete: false, viewAllRecords: true } });

packages/spec/src/security/permission.zod.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,19 @@ export const ObjectPermissionSchema = lazySchema(() => z.object({
2424
/** D: Delete (Owned records or Shared records) */
2525
allowDelete: z.boolean().default(false).describe('Delete permission'),
2626

27-
/** Lifecycle Operations */
28-
allowTransfer: z.boolean().default(false).describe('Change record ownership'),
29-
allowRestore: z.boolean().default(false).describe('Restore from trash (Undelete)'),
30-
allowPurge: z.boolean().default(false).describe('Permanently delete (Hard Delete/GDPR)'),
27+
/**
28+
* Lifecycle Operations.
29+
*
30+
* EXPERIMENTAL — not enforced (ADR-0049). The `transfer`/`restore`/`purge`
31+
* operations these bits gate do not yet exist as ObjectQL operations, and no
32+
* runtime consumer reads these bits. Authoring them is currently a no-op.
33+
* The runtime fails CLOSED if such an operation is ever introduced without a
34+
* matching permission mapping (see `permission-evaluator.ts`
35+
* DESTRUCTIVE_OPERATIONS). Tracked by #1883.
36+
*/
37+
allowTransfer: z.boolean().default(false).describe('[EXPERIMENTAL — not enforced] Change record ownership'),
38+
allowRestore: z.boolean().default(false).describe('[EXPERIMENTAL — not enforced] Restore from trash (Undelete)'),
39+
allowPurge: z.boolean().default(false).describe('[EXPERIMENTAL — not enforced] Permanently delete (Hard Delete/GDPR)'),
3140

3241
/**
3342
* View All Records: Super-user read access.

0 commit comments

Comments
 (0)