@openwop/openwop-conformance 1.127.0 → 1.129.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/schemas/CORPUS-STAMP.json +2 -2
- package/schemas/workload-identity.schema.json +7 -1
- package/src/scenarios/compensation-recovery.test.ts +195 -0
- package/src/scenarios/workload-identity-behavior.test.ts +1 -1
- package/src/scenarios/workload-identity-chain-bounds.test.ts +141 -0
- package/src/scenarios/workload-identity-profile.test.ts +9 -0
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "Provenance of this vendored schemas/ copy. See conformance/README.md \u00a7\"Resolving the contract\". Compare against the stamp in your installed @openwop/openwop-conformance to detect a stale hand-copied contract.",
|
|
3
|
-
"suiteVersion": "1.
|
|
4
|
-
"corpusCommit": "
|
|
3
|
+
"suiteVersion": "1.129.0",
|
|
4
|
+
"corpusCommit": "edd6a891da09af801b04e750c0219ee7cb401137"
|
|
5
5
|
}
|
|
@@ -52,7 +52,13 @@
|
|
|
52
52
|
"required": ["subject"],
|
|
53
53
|
"properties": {
|
|
54
54
|
"subject": { "type": "string", "minLength": 1 },
|
|
55
|
-
"issuer": { "type": "string", "minLength": 1 }
|
|
55
|
+
"issuer": { "type": "string", "minLength": 1 },
|
|
56
|
+
"scopes": {
|
|
57
|
+
"type": "array",
|
|
58
|
+
"uniqueItems": true,
|
|
59
|
+
"items": { "type": "string", "minLength": 1 },
|
|
60
|
+
"description": "RFC 0154 §B — the effective scopes VERIFIED for this hop from its proof (RFC 0049 scope grammar). OPTIONAL and provenance, not authorization: the host still evaluates the resolved principal's own scopes. When present on consecutive hops, a later hop's scopes MUST NOT exceed the previous hop's (`auth.md` §\"Bounds\" — scope amplification is refused: `delegation_scope_amplified`)."
|
|
61
|
+
}
|
|
56
62
|
}
|
|
57
63
|
}
|
|
58
64
|
},
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0151 §C / §E / §B+§F — the compensation RECOVERY witness: retry-stable
|
|
3
|
+
* identity, tenant-bound operator authority, recorded-facts-only inputs.
|
|
4
|
+
*
|
|
5
|
+
* `compensation-behavior.test.ts` proves the happy unwind: plan before effect,
|
|
6
|
+
* reverse order, replay does not re-fire, content-free events, rollup ⇄ events.
|
|
7
|
+
* RFC 0151 §G names three further invariants that file cannot see, and
|
|
8
|
+
* `SECURITY/threat-model-compensation.md` §7 said so and named the seam each
|
|
9
|
+
* one needs. This file is that seam's consumer (`host-sample-test-seams.md` §21
|
|
10
|
+
* "Recovery extension"):
|
|
11
|
+
*
|
|
12
|
+
* - **`compensation-effect-id-retry-stable`** (§C). `unwind` with
|
|
13
|
+
* `failFirstInverseAttempts: 2`: the first inverse action fails twice then
|
|
14
|
+
* succeeds. The response's `inverseActions[]` MUST show one entry for that
|
|
15
|
+
* ordinal with `attempts: 3` and a SINGLE distinct `downstreamKeys` value —
|
|
16
|
+
* one obligation, three attempts, the same idempotency key at the
|
|
17
|
+
* downstream every time. Two keys is two refunds.
|
|
18
|
+
* - **`compensation-tenant-authority-bound`** (§E). `unwind` with `hold: true`
|
|
19
|
+
* leaves a held plan (`compensationStatus: manual`); the `operator` seam is
|
|
20
|
+
* then driven three times — a cross-tenant actor (404: RFC 0132 §A.2,
|
|
21
|
+
* neutralize, do not reveal), a same-tenant non-operator (403, audited),
|
|
22
|
+
* the operator (200, `audited: true`). A seam that answers 200 to all
|
|
23
|
+
* three consults nothing.
|
|
24
|
+
* - **`compensation-input-recorded-facts-only`** (§B/§F). `replay` reports the
|
|
25
|
+
* inverse actions of the source run and of the replay with the input each
|
|
26
|
+
* executed with; they MUST deep-equal while `refiredEffects` stays 0. An
|
|
27
|
+
* inverse built from a re-derived value is not the inverse of what was
|
|
28
|
+
* done, and replay is where re-derivation would happen.
|
|
29
|
+
*
|
|
30
|
+
* Every leg is capability-gated on `compensation.supported` via `behaviorGate`
|
|
31
|
+
* (soft-skips without the advert, HARD-FAILS under `OPENWOP_REQUIRE_BEHAVIOR=true`)
|
|
32
|
+
* and records its RFC 0148 §A disposition. The three sub-features are
|
|
33
|
+
* INDEPENDENTLY optional: a host that wires the base seams but not this
|
|
34
|
+
* extension is `blocked` here (named as such via `seamAbsent`) and keeps its
|
|
35
|
+
* `compensation-behavior` witness — the base and the extension are different
|
|
36
|
+
* claims and are recorded separately.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { describe, it, expect } from 'vitest';
|
|
40
|
+
import { driver } from '../lib/driver.js';
|
|
41
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
42
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
43
|
+
import { seamAbsent } from '../lib/soft-skip.js';
|
|
44
|
+
import { readErrorCode, readRetriable } from '../lib/error-envelope.js';
|
|
45
|
+
|
|
46
|
+
const PROFILE = 'openwop-compensation';
|
|
47
|
+
const UNWIND = '/v1/host/sample/test/compensation/unwind';
|
|
48
|
+
const REPLAY = '/v1/host/sample/test/compensation/replay';
|
|
49
|
+
const OPERATOR = '/v1/host/sample/test/compensation/operator';
|
|
50
|
+
|
|
51
|
+
interface InverseAction {
|
|
52
|
+
readonly ordinal?: number;
|
|
53
|
+
readonly effectId?: string;
|
|
54
|
+
readonly attempts?: number;
|
|
55
|
+
readonly outcome?: string;
|
|
56
|
+
readonly downstreamKeys?: readonly string[];
|
|
57
|
+
readonly input?: unknown;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface UnwindResponse {
|
|
61
|
+
readonly runId?: string;
|
|
62
|
+
readonly events?: ReadonlyArray<{ type?: string; payload?: Record<string, unknown> }>;
|
|
63
|
+
readonly compensatedOrder?: readonly number[];
|
|
64
|
+
readonly inverseActions?: readonly InverseAction[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface ReplayResponse {
|
|
68
|
+
readonly runId?: string;
|
|
69
|
+
readonly refiredEffects?: number;
|
|
70
|
+
readonly source?: readonly InverseAction[];
|
|
71
|
+
readonly replayed?: readonly InverseAction[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function advertised(): Promise<boolean> {
|
|
75
|
+
const disco = await driver.get('/.well-known/openwop');
|
|
76
|
+
return capabilityFamily<{ supported?: boolean }>(disco.json, 'compensation')?.supported === true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function post(path: string, body: Record<string, unknown>): Promise<{ status: number; json: unknown } | null> {
|
|
80
|
+
const r = await driver.post(path, body);
|
|
81
|
+
if (r.status === 404 && path !== OPERATOR) {
|
|
82
|
+
seamAbsent(`host advertises \`compensation.supported: true\` but ${path} answered 404 — the RFC 0151 recovery requirements are unobservable (host-sample-test-seams.md §21)`);
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return { status: r.status, json: r.json };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The recovery extension is optional; a base seam that ignores the new fields is `blocked` here, not failed. */
|
|
89
|
+
function extensionAbsent(what: string): undefined {
|
|
90
|
+
return seamAbsent(`the §21 recovery extension is not wired: ${what} (host-sample-test-seams.md §21 "Recovery extension") — retry-stability / operator authority / recorded-facts are unobservable`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe('RFC 0151 §C — inverse-action identity is retry-stable (capability-gated behavior)', () => {
|
|
94
|
+
it('a transient inverse failure retries under the SAME identity and the same downstream key', async () => {
|
|
95
|
+
if (!behaviorGate(PROFILE, await advertised())) return;
|
|
96
|
+
const r = await post(UNWIND, { nodes: 2, failFirstInverseAttempts: 2 });
|
|
97
|
+
if (r === null) return;
|
|
98
|
+
expect(r.status, driver.describe('spec/v1/host-sample-test-seams.md §21', 'unwind seam answers 200')).toBe(200);
|
|
99
|
+
const body = r.json as UnwindResponse;
|
|
100
|
+
if (!Array.isArray(body.inverseActions)) return extensionAbsent('`unwind` did not return `inverseActions[]`');
|
|
101
|
+
// Under reverse-completion the first inverse to run is the highest forward ordinal (2).
|
|
102
|
+
const first = body.inverseActions.find((a) => a.ordinal === 2) ?? body.inverseActions[0];
|
|
103
|
+
expect(first, driver.describe('spec/v1/host-sample-test-seams.md §21', 'one entry per plan entry')).toBeDefined();
|
|
104
|
+
const a = first as InverseAction;
|
|
105
|
+
expect(a.attempts, driver.describe('RFCS/0151 §C', 'two transient failures then success is THREE attempts of one obligation')).toBe(3);
|
|
106
|
+
expect(a.outcome, driver.describe('RFCS/0151 §C', 'a transient failure MUST be retryable — the action completes')).toBe('completed');
|
|
107
|
+
expect(Array.isArray(a.downstreamKeys) ? a.downstreamKeys.length : -1, driver.describe('spec/v1/host-sample-test-seams.md §21', '`downstreamKeys` has one entry per attempt')).toBe(3);
|
|
108
|
+
const distinct = new Set(a.downstreamKeys ?? []);
|
|
109
|
+
expect(
|
|
110
|
+
distinct.size,
|
|
111
|
+
driver.describe(
|
|
112
|
+
'RFCS/0151 §C + RFC 0150 §B',
|
|
113
|
+
'a retry re-presents the SAME inverse-action identity as its idempotency key — a second key at the downstream is a second obligation (two refunds). `attempt` is outside the identity for exactly this reason.',
|
|
114
|
+
),
|
|
115
|
+
).toBe(1);
|
|
116
|
+
// One obligation per ordinal: no duplicate plan entries.
|
|
117
|
+
const ordinals = body.inverseActions.map((x) => x.ordinal);
|
|
118
|
+
expect(new Set(ordinals).size, driver.describe('RFCS/0151 §C', 'exactly one plan entry per forward ordinal')).toBe(ordinals.length);
|
|
119
|
+
expect(typeof a.effectId === 'string' && a.effectId.length > 0, driver.describe('RFCS/0151 §D', 'the identity is carried as an opaque `effectId`')).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('RFC 0151 §E — operator authority is bound to the plan\'s tenant (capability-gated behavior)', () => {
|
|
124
|
+
it('cross-tenant is neutralized (404), same-tenant non-operator is refused and audited (403), the operator acts (200)', async () => {
|
|
125
|
+
if (!behaviorGate(PROFILE, await advertised())) return;
|
|
126
|
+
const held = await post(UNWIND, { nodes: 2, hold: true });
|
|
127
|
+
if (held === null) return;
|
|
128
|
+
const body = held.json as UnwindResponse;
|
|
129
|
+
const runId = body.runId;
|
|
130
|
+
if (typeof runId !== 'string') return extensionAbsent('`unwind` did not return a `runId`');
|
|
131
|
+
const heldEntry = (body.inverseActions ?? []).find((x) => x.outcome === 'held');
|
|
132
|
+
const manual = (body.events ?? []).some((e) => e.type === 'compensation.manual_intervention_required');
|
|
133
|
+
if (heldEntry === undefined && !manual) return extensionAbsent('`unwind` with `hold: true` produced neither a held plan entry nor `compensation.manual_intervention_required`');
|
|
134
|
+
// Snapshot must read `manual` while held (§D fold).
|
|
135
|
+
const snap = await driver.get(`/v1/runs/${encodeURIComponent(runId)}`);
|
|
136
|
+
expect(snap.status, driver.describe('rest-endpoints.md', 'GET /v1/runs/{runId} for the held run')).toBe(200);
|
|
137
|
+
expect((snap.json as { compensationStatus?: string }).compensationStatus, driver.describe('spec/v1/compensation.md §D', 'a held plan reads `manual`')).toBe('manual');
|
|
138
|
+
|
|
139
|
+
const op = async (actor: Record<string, unknown>) =>
|
|
140
|
+
driver.post(OPERATOR, { runId, action: 'retry', actor });
|
|
141
|
+
// 1. Cross-tenant actor: neutralize to the actor's tenant, do not reveal.
|
|
142
|
+
const cross = await op({ tenantId: 'conformance-other-tenant', principalId: 'conformance-operator', operator: true });
|
|
143
|
+
if (cross.status === 404 && readErrorCode(cross.json) === undefined && (cross.json === null || cross.json === undefined || Object.keys(cross.json as object).length === 0)) {
|
|
144
|
+
return extensionAbsent(`${OPERATOR} answered a bodiless 404 — the operator seam is absent (an absent seam and a neutralized cross-tenant plan must be told apart by the envelope: RFC 0132 §A.2 answers \`not_found\` in the canonical envelope)`);
|
|
145
|
+
}
|
|
146
|
+
expect(cross.status, driver.describe('RFCS/0151 §E + RFC 0132 §A.2', 'an actor from another tenant MUST NOT learn the plan exists — 404, not 403')).toBe(404);
|
|
147
|
+
expect(readErrorCode(cross.json), driver.describe('rest-endpoints.md §Error codes', '`not_found` — do not leak existence')).toBe('not_found');
|
|
148
|
+
// 2. Same tenant, no operator authority: refused AND audited.
|
|
149
|
+
const plain = await op({ tenantId: 'default', principalId: 'conformance-bystander', operator: false });
|
|
150
|
+
expect(plain.status, driver.describe('RFCS/0151 §E', 'a principal without operator authority in the plan\'s tenant is refused')).toBe(403);
|
|
151
|
+
expect(readErrorCode(plain.json), driver.describe('rest-endpoints.md §Error codes', '`forbidden`')).toBe('forbidden');
|
|
152
|
+
expect(readRetriable(plain.json), driver.describe('spec/v1/host-sample-test-seams.md §21', 'a refused override is not retriable')).toBe(false);
|
|
153
|
+
// 3. The operator: acts, audited, rollup moves off `manual`.
|
|
154
|
+
const ok = await op({ tenantId: 'default', principalId: 'conformance-operator', operator: true });
|
|
155
|
+
expect(ok.status, driver.describe('RFCS/0151 §E', 'an authorized operator MAY retry a held inverse action')).toBe(200);
|
|
156
|
+
const res = ok.json as { compensationStatus?: string; audited?: boolean; planVersion?: unknown };
|
|
157
|
+
expect(res.audited, driver.describe('RFCS/0151 §E', 'every override MUST be audited (`authorization.decided`)')).toBe(true);
|
|
158
|
+
expect(
|
|
159
|
+
['completed', 'partial', 'failed', 'running'],
|
|
160
|
+
driver.describe('spec/v1/compensation.md §D', 'after the operator retries, the rollup is whatever the recorded outcomes yield — never still `manual` for a resolved hold'),
|
|
161
|
+
).toContain(res.compensationStatus);
|
|
162
|
+
// The refusal in step 2 was audited too: the plan's events carry a reason.
|
|
163
|
+
const after = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events`);
|
|
164
|
+
if (after.status === 200) {
|
|
165
|
+
const evs = (after.json as { events?: Array<{ type?: string; payload?: Record<string, unknown> }> }).events ?? [];
|
|
166
|
+
const decided = evs.filter((e) => e.type === 'authorization.decided');
|
|
167
|
+
expect(decided.length, driver.describe('RFCS/0151 §E + RFC 0049', 'both the refusal and the override are `authorization.decided` records')).toBeGreaterThanOrEqual(2);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('RFC 0151 §B/§F — inverse inputs are recorded facts, not re-derived (capability-gated behavior)', () => {
|
|
173
|
+
it('a replay executes the same inverse actions with the same inputs, and re-fires nothing', async () => {
|
|
174
|
+
if (!behaviorGate(PROFILE, await advertised())) return;
|
|
175
|
+
const r = await post(REPLAY, {});
|
|
176
|
+
if (r === null) return;
|
|
177
|
+
expect(r.status, driver.describe('spec/v1/host-sample-test-seams.md §21', 'replay seam answers 200')).toBe(200);
|
|
178
|
+
const body = r.json as ReplayResponse;
|
|
179
|
+
if (!Array.isArray(body.source) || !Array.isArray(body.replayed)) return extensionAbsent('`replay` did not return `source[]` / `replayed[]`');
|
|
180
|
+
expect(body.refiredEffects, driver.describe('RFCS/0151 §F', 'replay MUST NOT re-fire inverse effects')).toBe(0);
|
|
181
|
+
expect(body.source.length, driver.describe('spec/v1/host-sample-test-seams.md §21', 'the source run had inverse actions to compare')).toBeGreaterThan(0);
|
|
182
|
+
const norm = (xs: readonly InverseAction[]) =>
|
|
183
|
+
[...xs].sort((a, b) => (a.ordinal ?? 0) - (b.ordinal ?? 0)).map((x) => ({ ordinal: x.ordinal, effectId: x.effectId, input: x.input }));
|
|
184
|
+
expect(
|
|
185
|
+
norm(body.replayed),
|
|
186
|
+
driver.describe(
|
|
187
|
+
'RFCS/0151 §B + §F',
|
|
188
|
+
'a replay uses the RECORDED inverse inputs and identities — an inverse built from a re-derived (prompt / model / live) value is not the inverse of what was actually done',
|
|
189
|
+
),
|
|
190
|
+
).toEqual(norm(body.source));
|
|
191
|
+
for (const x of body.source) {
|
|
192
|
+
expect(x.input, driver.describe('RFCS/0151 §B', 'each inverse action executed with a recorded input')).toBeDefined();
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -167,7 +167,7 @@ describe('RFC 0154 §A — workload identity resolution (capability-gated behavi
|
|
|
167
167
|
),
|
|
168
168
|
).toBe(false);
|
|
169
169
|
expect(
|
|
170
|
-
['identity_unverified', 'identity_unresolvable', 'audience_mismatch', 'delegation_expired', 'sender_constraint_missing'],
|
|
170
|
+
['identity_unverified', 'identity_unresolvable', 'audience_mismatch', 'delegation_expired', 'sender_constraint_missing', 'delegation_chain_too_long', 'delegation_chain_cyclic', 'delegation_scope_amplified'],
|
|
171
171
|
driver.describe('RFCS/0154 §A', 'failures use a closed reason vocabulary'),
|
|
172
172
|
).toContain(readErrorCode(r.json));
|
|
173
173
|
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0154 §B "Bounds" — the delegation chain is bounded, acyclic, and cannot
|
|
3
|
+
* amplify scope hop-to-hop (`auth.md` §"Workload identity and delegated actor
|
|
4
|
+
* chain" → Bounds; threat model A4 "chain launderer").
|
|
5
|
+
*
|
|
6
|
+
* `workload-identity-behavior.test.ts` proves the resolver verifies audience and
|
|
7
|
+
* expiry. It does not touch the chain's SHAPE as a source of authority, and that
|
|
8
|
+
* is where laundering lives: a chain that is one hop longer than the host said
|
|
9
|
+
* it would accept, a chain that loops back through a subject it already passed,
|
|
10
|
+
* or a chain whose second hop claims a scope its first hop never held. Each is a
|
|
11
|
+
* verified-looking presentation that a passthrough resolver accepts and a real
|
|
12
|
+
* one refuses, which is what makes the legs non-vacuous — `resolved: true` on
|
|
13
|
+
* any of them is the finding.
|
|
14
|
+
*
|
|
15
|
+
* All three go through the §20 seam (`host-sample-test-seams.md`) with the
|
|
16
|
+
* three closed reasons added for them: `delegation_chain_too_long`,
|
|
17
|
+
* `delegation_chain_cyclic`, `delegation_scope_amplified`. Every leg is gated on
|
|
18
|
+
* `auth.workloadIdentity.delegation.supported` via `behaviorGate` (a host doing
|
|
19
|
+
* §A-only identity resolution has not claimed §B) — soft-skips without it,
|
|
20
|
+
* HARD-FAILS under `OPENWOP_REQUIRE_BEHAVIOR=true`, and records the RFC 0148 §A
|
|
21
|
+
* disposition either way. A seam that answers 404/403 is `blocked`, not a pass.
|
|
22
|
+
*
|
|
23
|
+
* Per-hop `scopes` on the wire are OPTIONAL (`workload-identity.schema.json`,
|
|
24
|
+
* 2026-08-16); the amplification leg presents them explicitly, so a host that
|
|
25
|
+
* ignores the field will resolve the chain and fail the leg — which is the
|
|
26
|
+
* honest outcome, because a host that cannot see hop scopes cannot enforce the
|
|
27
|
+
* MUST NOT either.
|
|
28
|
+
*
|
|
29
|
+
* Registered invariants: `delegation-chain-acyclic`,
|
|
30
|
+
* `delegation-no-scope-amplification` (`SECURITY/invariants.yaml`), and the
|
|
31
|
+
* length half of `delegation-chain-bounded`.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { describe, it, expect } from 'vitest';
|
|
35
|
+
import { readErrorCode, readRetriable } from '../lib/error-envelope.js';
|
|
36
|
+
import { driver } from '../lib/driver.js';
|
|
37
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
38
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
39
|
+
import { seamAbsent } from '../lib/soft-skip.js';
|
|
40
|
+
|
|
41
|
+
const DELEGATION_PROFILE = 'openwop-workload-identity-delegation';
|
|
42
|
+
const SEAM = '/v1/host/sample/test/workload-identity/resolve';
|
|
43
|
+
|
|
44
|
+
interface AuthCaps {
|
|
45
|
+
readonly workloadIdentity?: {
|
|
46
|
+
readonly supported?: boolean;
|
|
47
|
+
readonly delegation?: { readonly supported?: boolean; readonly maxChainDepth?: number };
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function delegationCaps(): Promise<{ supported: boolean; maxChainDepth: number | null }> {
|
|
52
|
+
const disco = await driver.get('/.well-known/openwop');
|
|
53
|
+
const d = capabilityFamily<AuthCaps>(disco.json, 'auth')?.workloadIdentity?.delegation;
|
|
54
|
+
return {
|
|
55
|
+
supported: d?.supported === true,
|
|
56
|
+
maxChainDepth: typeof d?.maxChainDepth === 'number' && Number.isInteger(d.maxChainDepth) && d.maxChainDepth > 0 ? d.maxChainDepth : null,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function resolve(body: Record<string, unknown>): Promise<{ status: number; json: unknown } | null> {
|
|
61
|
+
const r = await driver.post(SEAM, body);
|
|
62
|
+
if (r.status === 404 || r.status === 403) {
|
|
63
|
+
seamAbsent(`host advertises \`auth.workloadIdentity.delegation.supported: true\` but ${SEAM} answered ${r.status} — RFC 0154 §B chain bounds are unobservable (host-sample-test-seams.md §20)`);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
return { status: r.status, json: r.json };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const IDENTITY = { scheme: 'spiffe', subject: 'spiffe://example/dispatcher', issuer: 'spiffe://example', audience: 'openwop-host' };
|
|
70
|
+
const LIVE = '2099-01-01T00:00:00Z';
|
|
71
|
+
|
|
72
|
+
function hop(n: number, scopes?: readonly string[]): Record<string, unknown> {
|
|
73
|
+
const h: Record<string, unknown> = { subject: `spiffe://example/hop-${n}`, issuer: 'spiffe://example' };
|
|
74
|
+
if (scopes !== undefined) h['scopes'] = [...scopes];
|
|
75
|
+
return h;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A refusal: 4xx, non-retriable, the named closed reason. */
|
|
79
|
+
function expectRefusal(r: { status: number; json: unknown }, reason: string, why: string): void {
|
|
80
|
+
expect(r.status >= 400, driver.describe('RFCS/0154 §B', why)).toBe(true);
|
|
81
|
+
expect(readErrorCode(r.json), driver.describe('spec/v1/host-sample-test-seams.md §20', `closed reason \`${reason}\``)).toBe(reason);
|
|
82
|
+
expect(readRetriable(r.json), driver.describe('spec/v1/host-sample-test-seams.md §20', 'a chain the host refuses will be refused on retry')).toBe(false);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe('RFC 0154 §B — delegation chain bounds (capability-gated behavior)', () => {
|
|
86
|
+
it('a chain longer than the advertised maxChainDepth is refused', async () => {
|
|
87
|
+
const caps = await delegationCaps();
|
|
88
|
+
if (!behaviorGate(DELEGATION_PROFILE, caps.supported)) return;
|
|
89
|
+
expect(
|
|
90
|
+
caps.maxChainDepth,
|
|
91
|
+
driver.describe('spec/v1/auth.md §Bounds', 'a host advertising delegation MUST advertise a positive integer `maxChainDepth`'),
|
|
92
|
+
).not.toBeNull();
|
|
93
|
+
const depth = caps.maxChainDepth as number;
|
|
94
|
+
const chain = Array.from({ length: depth + 1 }, (_, i) => hop(i + 1));
|
|
95
|
+
const r = await resolve({ identity: { ...IDENTITY, delegation: { chain, audience: 'openwop-host', expiresAt: LIVE } }, expectedAudience: 'openwop-host' });
|
|
96
|
+
if (r === null) return;
|
|
97
|
+
expectRefusal(r, 'delegation_chain_too_long', `a chain of ${depth + 1} hops exceeds the advertised bound of ${depth} — each hop is another party the host trusts transitively`);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('a chain that revisits a subject is refused as cyclic', async () => {
|
|
101
|
+
const caps = await delegationCaps();
|
|
102
|
+
if (!behaviorGate(DELEGATION_PROFILE, caps.supported)) return;
|
|
103
|
+
// Two distinct hops then the first subject again: length 3, so on any
|
|
104
|
+
// host with maxChainDepth >= 3 the ONLY reason to refuse it is the cycle.
|
|
105
|
+
// On a host with maxChainDepth < 3 the too-long leg already covers refusal
|
|
106
|
+
// and this leg accepts either reason, saying so.
|
|
107
|
+
const chain = [hop(1), hop(2), hop(1)];
|
|
108
|
+
const r = await resolve({ identity: { ...IDENTITY, delegation: { chain, audience: 'openwop-host', expiresAt: LIVE } }, expectedAudience: 'openwop-host' });
|
|
109
|
+
if (r === null) return;
|
|
110
|
+
const bound = caps.maxChainDepth ?? Number.POSITIVE_INFINITY;
|
|
111
|
+
if (bound < 3) {
|
|
112
|
+
expect(r.status >= 400, driver.describe('RFCS/0154 §B', 'a cyclic chain is refused')).toBe(true);
|
|
113
|
+
expect(['delegation_chain_cyclic', 'delegation_chain_too_long'], driver.describe('spec/v1/host-sample-test-seams.md §20', 'cyclic or too-long — the bound is below the cycle length')).toContain(readErrorCode(r.json));
|
|
114
|
+
expect(readRetriable(r.json)).toBe(false);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
expectRefusal(r, 'delegation_chain_cyclic', 'a subject appearing twice is a chain that loops back through authority it already spent — unbounded laundering with a bounded length');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('a later hop claiming a scope the previous hop did not hold is refused', async () => {
|
|
121
|
+
const caps = await delegationCaps();
|
|
122
|
+
if (!behaviorGate(DELEGATION_PROFILE, caps.supported)) return;
|
|
123
|
+
const chain = [hop(1, ['runs:read']), hop(2, ['runs:read', 'runs:write'])];
|
|
124
|
+
const r = await resolve({ identity: { ...IDENTITY, delegation: { chain, audience: 'openwop-host', expiresAt: LIVE } }, expectedAudience: 'openwop-host' });
|
|
125
|
+
if (r === null) return;
|
|
126
|
+
expectRefusal(r, 'delegation_scope_amplified', 'the effective scopes at any hop MUST NOT exceed the hop before it — a chain is provenance, and provenance cannot mint `runs:write` from `runs:read`');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('a well-formed bounded, acyclic, non-amplifying chain still resolves (the negatives are not a blanket refusal)', async () => {
|
|
130
|
+
const caps = await delegationCaps();
|
|
131
|
+
if (!behaviorGate(DELEGATION_PROFILE, caps.supported)) return;
|
|
132
|
+
const chain = [hop(1, ['runs:read', 'manifest:read']), hop(2, ['runs:read'])];
|
|
133
|
+
const r = await resolve({ identity: { ...IDENTITY, delegation: { chain, audience: 'openwop-host', expiresAt: LIVE } }, expectedAudience: 'openwop-host' });
|
|
134
|
+
if (r === null) return;
|
|
135
|
+
// A host that refuses EVERY chain passes the three negatives vacuously; this
|
|
136
|
+
// is the positive that keeps them honest. Scopes narrow hop-to-hop, which
|
|
137
|
+
// is the one direction §B permits.
|
|
138
|
+
expect(r.status, driver.describe('RFCS/0154 §B', 'a compliant chain resolves; the bounds refuse laundering, not delegation')).toBe(200);
|
|
139
|
+
expect((r.json as { resolved?: unknown }).resolved).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -151,6 +151,15 @@ describe('RFC 0154 §B — delegated actor chain', () => {
|
|
|
151
151
|
it('a chain hop cannot carry extra fields', () => {
|
|
152
152
|
expect(validate({ ...base, delegation: { chain: [{ subject: 'x', token: 'y' }], audience: 'h' } })).toBe(false);
|
|
153
153
|
});
|
|
154
|
+
|
|
155
|
+
it('a chain hop MAY carry its verified scopes, as a set of non-empty strings', () => {
|
|
156
|
+
// RFC 0154 §B "Bounds": scope amplification is only observable if a hop can
|
|
157
|
+
// state the scopes its proof carried. Provenance, not authorization.
|
|
158
|
+
expect(validate({ ...base, delegation: { chain: [{ subject: 'x', scopes: ['runs:read'] }, { subject: 'y', scopes: ['runs:read'] }], audience: 'h' } })).toBe(true);
|
|
159
|
+
expect(validate({ ...base, delegation: { chain: [{ subject: 'x', scopes: 'runs:read' }], audience: 'h' } })).toBe(false);
|
|
160
|
+
expect(validate({ ...base, delegation: { chain: [{ subject: 'x', scopes: ['runs:read', 'runs:read'] }], audience: 'h' } })).toBe(false);
|
|
161
|
+
expect(validate({ ...base, delegation: { chain: [{ subject: 'x', scopes: [''] }], audience: 'h' } })).toBe(false);
|
|
162
|
+
});
|
|
154
163
|
});
|
|
155
164
|
|
|
156
165
|
describe.skipIf(RFCS_DIR === null)('RFC 0154 — what this does NOT establish', () => {
|