@openwop/openwop-conformance 1.4.0 → 1.6.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +2 -2
  3. package/api/asyncapi.yaml +8 -3
  4. package/api/openapi.yaml +305 -0
  5. package/coverage.md +35 -10
  6. package/fixtures/conformance-phase4-nondet-tool.json +53 -0
  7. package/fixtures/conformance-phase4-replay-divergence.json +40 -0
  8. package/fixtures.md +5 -3
  9. package/package.json +1 -1
  10. package/schemas/README.md +2 -0
  11. package/schemas/capabilities.schema.json +176 -3
  12. package/schemas/credential-reference.schema.json +21 -0
  13. package/schemas/node-pack-manifest.schema.json +112 -1
  14. package/schemas/run-diff-response.schema.json +64 -0
  15. package/schemas/run-event-payloads.schema.json +104 -2
  16. package/schemas/run-event.schema.json +8 -1
  17. package/schemas/run-snapshot.schema.json +11 -0
  18. package/src/lib/behavior-gate.ts +51 -0
  19. package/src/lib/driver.ts +13 -1
  20. package/src/lib/saml-idp.ts +179 -0
  21. package/src/scenarios/approval-gate-events.test.ts +61 -0
  22. package/src/scenarios/approval-gate-flow.test.ts +68 -0
  23. package/src/scenarios/auth-saml-profile.test.ts +119 -0
  24. package/src/scenarios/auth-scim-profile.test.ts +65 -0
  25. package/src/scenarios/authorization-fail-closed.test.ts +80 -0
  26. package/src/scenarios/authorization-roles-shape.test.ts +83 -0
  27. package/src/scenarios/connector-manifest-validity.test.ts +142 -0
  28. package/src/scenarios/credential-payload-redaction.test.ts +93 -0
  29. package/src/scenarios/credentials-capability-shape.test.ts +90 -0
  30. package/src/scenarios/cross-engine-append-behavior.test.ts +204 -0
  31. package/src/scenarios/cross-host-traceparent-propagation.test.ts +13 -6
  32. package/src/scenarios/cross-workspace-isolation.test.ts +72 -0
  33. package/src/scenarios/deadletter-capability-shape.test.ts +59 -0
  34. package/src/scenarios/deadletter-retry-exhaustion.test.ts +62 -0
  35. package/src/scenarios/experimental-tier-shape.test.ts +192 -0
  36. package/src/scenarios/identity-owner-shape.test.ts +64 -0
  37. package/src/scenarios/multi-agent-confidence-escalation.test.ts +59 -21
  38. package/src/scenarios/multi-agent-memory-lifecycle.test.ts +87 -12
  39. package/src/scenarios/multi-region-idempotency-behavior.test.ts +203 -0
  40. package/src/scenarios/oauth-capability-shape.test.ts +97 -0
  41. package/src/scenarios/oauth-connector-redaction.test.ts +91 -0
  42. package/src/scenarios/pack-registry-isolation.test.ts +108 -0
  43. package/src/scenarios/pack-registry-publish.test.ts +1 -1
  44. package/src/scenarios/prompt-mutation-workspace-membership-enforced.test.ts +126 -0
  45. package/src/scenarios/prompt-read-workspace-membership-enforced.test.ts +183 -0
  46. package/src/scenarios/replay-divergence-at-refusal.test.ts +187 -7
  47. package/src/scenarios/replay-observable-sequence-determinism.test.ts +20 -6
  48. package/src/scenarios/run-diff.test.ts +143 -0
  49. package/src/scenarios/sandbox-capability-gate-respected.test.ts +15 -13
  50. package/src/scenarios/sandbox-memory-cap.test.ts +7 -8
  51. package/src/scenarios/sandbox-mvp-behavior.test.ts +280 -0
  52. package/src/scenarios/sandbox-no-cross-pack-mutation.test.ts +14 -13
  53. package/src/scenarios/sandbox-no-host-env-leak.test.ts +14 -21
  54. package/src/scenarios/sandbox-no-host-fs-escape.test.ts +20 -15
  55. package/src/scenarios/sandbox-no-host-process-escape.test.ts +18 -13
  56. package/src/scenarios/sandbox-no-network-escape.test.ts +14 -31
  57. package/src/scenarios/sandbox-timeout-cap.test.ts +7 -8
  58. package/src/scenarios/scheduling-capability-shape.test.ts +81 -0
  59. package/src/scenarios/scheduling-cron-fires-once.test.ts +66 -0
  60. package/src/scenarios/secret-leakage-otel-attribute.test.ts +241 -0
  61. package/src/scenarios/spec-corpus-validity.test.ts +2 -2
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Synthetic SAML 2.0 IdP for conformance scenarios (RFC 0050).
3
+ *
4
+ * Mints SAML assertions — a valid signed one plus the negative variants
5
+ * the `openwop-auth-saml` profile requires hosts to reject — and exposes
6
+ * the signing certificate a trusting host configures. Hermetic: uses only
7
+ * `node:crypto` stdlib (RSA-SHA256), no npm dependencies, no XML library.
8
+ *
9
+ * Scope: this harness is a wire-shape + validation-logic reference, NOT a
10
+ * full XML-DSig stack. It produces a controlled, fixed-shape assertion
11
+ * template and signs an enveloped digest of it with RSA-SHA256; `verify()`
12
+ * implements exactly the RFC 0050 §A MUST list (signature present + valid,
13
+ * `alg:none` rejected, validity window enforced, signature-wrapping
14
+ * rejected) so the suite can assert each negative variant is detectably
15
+ * malformed. A host's real SAML ACS validates the same assertions over the
16
+ * `auth/saml/validate` test seam; sign/verify here are mutually consistent
17
+ * by construction (the harness owns both the serialization and the digest).
18
+ *
19
+ * @see RFCS/0050-saml-scim-enterprise-identity-profiles.md §A
20
+ * @see spec/v1/auth-profiles.md §`openwop-auth-saml`
21
+ */
22
+
23
+ import {
24
+ createSign,
25
+ createVerify,
26
+ createHash,
27
+ generateKeyPairSync,
28
+ } from 'node:crypto';
29
+
30
+ /** The assertion variants the conformance suite exercises (1 positive + 6 negatives). */
31
+ export type SamlVariant =
32
+ | 'valid'
33
+ | 'alg-none'
34
+ | 'bad-signature'
35
+ | 'unsigned'
36
+ | 'expired'
37
+ | 'not-yet-valid'
38
+ | 'signature-wrapping';
39
+
40
+ export interface SamlVerifyResult {
41
+ /** True only for a well-formed, signed, in-window, non-wrapped assertion. */
42
+ readonly valid: boolean;
43
+ /** Machine-readable rejection cause; `null` when `valid`. */
44
+ readonly reason:
45
+ | null
46
+ | 'unsigned'
47
+ | 'alg-none'
48
+ | 'bad-signature'
49
+ | 'expired'
50
+ | 'not-yet-valid'
51
+ | 'signature-wrapping'
52
+ | 'malformed';
53
+ }
54
+
55
+ export interface SyntheticSamlIdp {
56
+ /** PEM signing certificate (public key) the host configures to trust this IdP. */
57
+ readonly certificatePem: string;
58
+ /** Mint a SAML assertion of the given variant. */
59
+ mint(variant: SamlVariant, opts?: { subject?: string }): string;
60
+ /** Validate an assertion per the RFC 0050 §A MUST list. */
61
+ verify(assertionXml: string): SamlVerifyResult;
62
+ }
63
+
64
+ const SIG_ALG_RSA_SHA256 = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256';
65
+ const SIG_ALG_NONE = 'http://www.w3.org/2000/09/xmldsig#none';
66
+
67
+ function digest(input: string): string {
68
+ return createHash('sha256').update(input, 'utf8').digest('base64');
69
+ }
70
+
71
+ /** Deterministic canonical form of the signed element: the harness controls
72
+ * the exact byte string, so sign/verify agree without a full C14N stack. */
73
+ function canonicalAssertion(id: string, subject: string, notBefore: string, notOnOrAfter: string): string {
74
+ return (
75
+ `<saml:Assertion ID="${id}" Version="2.0">` +
76
+ `<saml:Conditions NotBefore="${notBefore}" NotOnOrAfter="${notOnOrAfter}"/>` +
77
+ `<saml:Subject><saml:NameID>${subject}</saml:NameID></saml:Subject>` +
78
+ `</saml:Assertion>`
79
+ );
80
+ }
81
+
82
+ export function createSyntheticSamlIdp(): SyntheticSamlIdp {
83
+ const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
84
+ const certificatePem = publicKey.export({ format: 'pem', type: 'spki' }).toString();
85
+
86
+ function sign(canonical: string): string {
87
+ return createSign('RSA-SHA256').update(canonical, 'utf8').sign(privateKey, 'base64');
88
+ }
89
+
90
+ function envelope(parts: {
91
+ id: string;
92
+ subject: string;
93
+ notBefore: string;
94
+ notOnOrAfter: string;
95
+ sigAlg: string;
96
+ signatureValue: string | null;
97
+ refId: string; // the ID the <Reference> points at (≠ id ⇒ wrapping)
98
+ extraInjected?: string; // an unsigned injected assertion (wrapping attack)
99
+ }): string {
100
+ const inner = canonicalAssertion(parts.id, parts.subject, parts.notBefore, parts.notOnOrAfter);
101
+ const sig =
102
+ parts.signatureValue === null
103
+ ? ''
104
+ : `<ds:Signature>` +
105
+ `<ds:SignedInfo><ds:SignatureMethod Algorithm="${parts.sigAlg}"/>` +
106
+ `<ds:Reference URI="#${parts.refId}"><ds:DigestValue>${digest(inner)}</ds:DigestValue></ds:Reference>` +
107
+ `</ds:SignedInfo><ds:SignatureValue>${parts.signatureValue}</ds:SignatureValue></ds:Signature>`;
108
+ return `<samlp:Response>${parts.extraInjected ?? ''}${inner.replace('</saml:Assertion>', `${sig}</saml:Assertion>`)}</samlp:Response>`;
109
+ }
110
+
111
+ function mint(variant: SamlVariant, opts?: { subject?: string }): string {
112
+ const id = 'a-' + variant;
113
+ const subject = opts?.subject ?? 'user_42@example.com-opaque';
114
+ const now = Date.now();
115
+ const iso = (ms: number): string => new Date(ms).toISOString();
116
+ const past = iso(now - 3_600_000);
117
+ const future = iso(now + 3_600_000);
118
+ const canonical = canonicalAssertion(id, subject, past, future);
119
+
120
+ switch (variant) {
121
+ case 'valid':
122
+ return envelope({ id, subject, notBefore: past, notOnOrAfter: future, sigAlg: SIG_ALG_RSA_SHA256, signatureValue: sign(canonical), refId: id });
123
+ case 'unsigned':
124
+ return envelope({ id, subject, notBefore: past, notOnOrAfter: future, sigAlg: SIG_ALG_RSA_SHA256, signatureValue: null, refId: id });
125
+ case 'alg-none':
126
+ return envelope({ id, subject, notBefore: past, notOnOrAfter: future, sigAlg: SIG_ALG_NONE, signatureValue: '', refId: id });
127
+ case 'bad-signature':
128
+ return envelope({ id, subject, notBefore: past, notOnOrAfter: future, sigAlg: SIG_ALG_RSA_SHA256, signatureValue: Buffer.from('forged').toString('base64'), refId: id });
129
+ case 'expired': {
130
+ const c = canonicalAssertion(id, subject, iso(now - 7_200_000), past);
131
+ return envelope({ id, subject, notBefore: iso(now - 7_200_000), notOnOrAfter: past, sigAlg: SIG_ALG_RSA_SHA256, signatureValue: sign(c), refId: id });
132
+ }
133
+ case 'not-yet-valid': {
134
+ const c = canonicalAssertion(id, subject, future, iso(now + 7_200_000));
135
+ return envelope({ id, subject, notBefore: future, notOnOrAfter: iso(now + 7_200_000), sigAlg: SIG_ALG_RSA_SHA256, signatureValue: sign(c), refId: id });
136
+ }
137
+ case 'signature-wrapping': {
138
+ // Signature validly covers a benign assertion (refId = benign), but a
139
+ // second, attacker-injected assertion with a different Subject is what
140
+ // a naive consumer reads. The signed element ≠ the consumed element.
141
+ const benign = 'a-benign';
142
+ const benignCanonical = canonicalAssertion(benign, subject, past, future);
143
+ const injected = canonicalAssertion(id, 'attacker@evil.example-opaque', past, future);
144
+ const sig =
145
+ `<ds:Signature><ds:SignedInfo><ds:SignatureMethod Algorithm="${SIG_ALG_RSA_SHA256}"/>` +
146
+ `<ds:Reference URI="#${benign}"><ds:DigestValue>${digest(benignCanonical)}</ds:DigestValue></ds:Reference>` +
147
+ `</ds:SignedInfo><ds:SignatureValue>${sign(benignCanonical)}</ds:SignatureValue></ds:Signature>`;
148
+ // Consumed (first) assertion carries the signature but is the INJECTED one.
149
+ return `<samlp:Response>${injected.replace('</saml:Assertion>', `${sig}</saml:Assertion>`)}${benignCanonical}</samlp:Response>`;
150
+ }
151
+ }
152
+ }
153
+
154
+ function verify(assertionXml: string): SamlVerifyResult {
155
+ const sigAlg = /<ds:SignatureMethod Algorithm="([^"]+)"/.exec(assertionXml)?.[1];
156
+ const sigValue = /<ds:SignatureValue>([^<]*)<\/ds:SignatureValue>/.exec(assertionXml)?.[1];
157
+ const refId = /<ds:Reference URI="#([^"]+)"/.exec(assertionXml)?.[1];
158
+ // The consumed assertion is the FIRST <saml:Assertion> in the response.
159
+ const consumed = /<saml:Assertion ID="([^"]+)"[^>]*>[\s\S]*?<saml:Conditions NotBefore="([^"]+)" NotOnOrAfter="([^"]+)"\/>[\s\S]*?<saml:NameID>([^<]*)<\/saml:NameID>/.exec(assertionXml);
160
+ if (consumed === null) return { valid: false, reason: 'malformed' };
161
+ const [, consumedId, notBefore, notOnOrAfter, subject] = consumed;
162
+
163
+ if (sigValue === undefined || sigAlg === undefined) return { valid: false, reason: 'unsigned' };
164
+ if (sigAlg === SIG_ALG_NONE) return { valid: false, reason: 'alg-none' };
165
+ // Anti-wrapping: the signature MUST reference the consumed assertion.
166
+ if (refId !== consumedId) return { valid: false, reason: 'signature-wrapping' };
167
+
168
+ const canonical = canonicalAssertion(consumedId, subject, notBefore, notOnOrAfter);
169
+ const ok = createVerify('RSA-SHA256').update(canonical, 'utf8').verify(publicKey, sigValue, 'base64');
170
+ if (!ok) return { valid: false, reason: 'bad-signature' };
171
+
172
+ const now = Date.now();
173
+ if (now < Date.parse(notBefore)) return { valid: false, reason: 'not-yet-valid' };
174
+ if (now >= Date.parse(notOnOrAfter)) return { valid: false, reason: 'expired' };
175
+ return { valid: true, reason: null };
176
+ }
177
+
178
+ return { certificatePem, mint, verify };
179
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * approval-gate-events — RFC 0051 §B event-shape verification.
3
+ *
4
+ * Status: DRAFT. RFC 0051 (approval & deployment-gate primitive) is `Draft`.
5
+ * The `approval.granted` / `approval.rejected` / `approval.overridden` event
6
+ * payloads have landed in `schemas/run-event-payloads.schema.json` (+ the
7
+ * `RunEventType` enum).
8
+ *
9
+ * Server-free schema validation of the three governance events:
10
+ * - granted: requires `{ gateId, principal }`; optional `quorumProgress`.
11
+ * - rejected: requires `{ gateId, principal }`; optional `reason`.
12
+ * - overridden: requires `{ gateId, principal, reason }` (reason mandatory —
13
+ * the audit breadcrumb).
14
+ * - each rejects unknown properties (additionalProperties:false).
15
+ *
16
+ * @see RFCS/0051-approval-deployment-gate-primitive.md
17
+ * @see spec/v1/interrupt-profiles.md §`core.openwop.governance.approvalGate`
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest';
21
+ import { readFileSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import Ajv2020 from 'ajv/dist/2020.js';
24
+ import { SCHEMAS_DIR } from '../lib/paths.js';
25
+
26
+ interface PayloadsSchema {
27
+ $schema: string;
28
+ $defs: Record<string, Record<string, unknown>>;
29
+ }
30
+
31
+ const payloads = JSON.parse(
32
+ readFileSync(join(SCHEMAS_DIR, 'run-event-payloads.schema.json'), 'utf8'),
33
+ ) as PayloadsSchema;
34
+
35
+ function compile(defName: string) {
36
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
37
+ return ajv.compile({ $schema: payloads.$schema, ...payloads.$defs[defName] });
38
+ }
39
+
40
+ describe('category: approval-gate governance events (RFC 0051 §B)', () => {
41
+ it('approval.granted requires gateId + principal; quorumProgress optional', () => {
42
+ const v = compile('approvalGranted');
43
+ expect(v({ gateId: 'g1', principal: 'user_1' }), JSON.stringify(v.errors)).toBe(true);
44
+ expect(v({ gateId: 'g1', principal: 'user_1', quorumProgress: { granted: 1, required: 2 } })).toBe(true);
45
+ expect(v({ gateId: 'g1' })).toBe(false); // missing principal
46
+ expect(v({ gateId: 'g1', principal: 'user_1', role: 'admin' })).toBe(false); // unknown prop
47
+ });
48
+
49
+ it('approval.rejected requires gateId + principal; reason optional', () => {
50
+ const v = compile('approvalRejected');
51
+ expect(v({ gateId: 'g1', principal: 'user_1' }), JSON.stringify(v.errors)).toBe(true);
52
+ expect(v({ gateId: 'g1', principal: 'user_1', reason: 'incomplete' })).toBe(true);
53
+ expect(v({ principal: 'user_1' })).toBe(false); // missing gateId
54
+ });
55
+
56
+ it('approval.overridden requires gateId + principal + reason (audit breadcrumb)', () => {
57
+ const v = compile('approvalOverridden');
58
+ expect(v({ gateId: 'g1', principal: 'owner_1', reason: 'emergency publish' }), JSON.stringify(v.errors)).toBe(true);
59
+ expect(v({ gateId: 'g1', principal: 'owner_1' })).toBe(false); // reason MUST be present
60
+ });
61
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * approval-gate-flow — RFC 0051 §A behavioral verification.
3
+ *
4
+ * Status: DRAFT. RFC 0051 (approval & deployment-gate primitive) is `Draft`.
5
+ *
6
+ * Capability-gated: the `core.openwop.governance.approvalGate` node requires
7
+ * a host advertising `capabilities.authorization.supported = true`
8
+ * (peerDependency `authorization: 'supported'`). Skips otherwise.
9
+ *
10
+ * What this scenario asserts (via the optional
11
+ * `POST /v1/host/sample/governance/approval-gate` seam):
12
+ * 1. Unauthorized principal — a principal lacking `requiredRole`/`requiredScope`
13
+ * is denied; the gate does NOT release (fail-closed, RFC 0049 §C).
14
+ * 2. Override is audited — taking the role-gated `override` path returns an
15
+ * `approval.overridden` event whose `reason` is present.
16
+ *
17
+ * Hosts without the seam soft-skip the behavioral probes (404).
18
+ *
19
+ * @see RFCS/0051-approval-deployment-gate-primitive.md
20
+ * @see spec/v1/interrupt-profiles.md §`core.openwop.governance.approvalGate`
21
+ */
22
+
23
+ import { describe, it, expect } from 'vitest';
24
+ import { driver } from '../lib/driver.js';
25
+
26
+ interface DiscoveryDoc {
27
+ capabilities?: { authorization?: { supported?: boolean } };
28
+ }
29
+
30
+ async function authorizationSupported(): Promise<boolean> {
31
+ const res = await driver.get('/.well-known/openwop');
32
+ return (res.json as DiscoveryDoc | undefined)?.capabilities?.authorization?.supported === true;
33
+ }
34
+
35
+ describe('approval-gate-flow: role-gated, audited approval (RFC 0051 §A)', () => {
36
+ it('an unauthorized principal does NOT release the gate (fail-closed)', async () => {
37
+ if (!(await authorizationSupported())) return; // capability-gated
38
+ const res = await driver.post('/v1/host/sample/governance/approval-gate', {
39
+ scenario: 'unauthorized-grant',
40
+ principal: 'conformance-unauthorized-principal',
41
+ });
42
+ if (res.status === 404) return; // seam unwired — soft-skip
43
+ const body = res.json as { released?: boolean } | undefined;
44
+ expect(
45
+ body?.released,
46
+ driver.describe('RFC 0051 §A', 'an unauthorized principal MUST NOT release the gate (fail-closed)'),
47
+ ).toBe(false);
48
+ });
49
+
50
+ it('the override path emits an audited approval.overridden with a reason', async () => {
51
+ if (!(await authorizationSupported())) return; // capability-gated
52
+ const res = await driver.post('/v1/host/sample/governance/approval-gate', {
53
+ scenario: 'override',
54
+ principal: 'conformance-owner-principal',
55
+ reason: 'conformance emergency publish',
56
+ });
57
+ if (res.status === 404) return; // seam unwired — soft-skip
58
+ const body = res.json as { event?: { type?: string; payload?: { reason?: string } } } | undefined;
59
+ expect(
60
+ body?.event?.type,
61
+ driver.describe('RFC 0051 §B', 'taking the override path MUST emit approval.overridden'),
62
+ ).toBe('approval.overridden');
63
+ expect(
64
+ typeof body?.event?.payload?.reason === 'string' && body.event.payload.reason.length > 0,
65
+ driver.describe('RFC 0051 §B', 'approval.overridden MUST carry a non-empty reason (the audit breadcrumb)'),
66
+ ).toBe(true);
67
+ });
68
+ });
@@ -0,0 +1,119 @@
1
+ /**
2
+ * auth-saml-profile — RFC 0050: openwop-auth-saml profile.
3
+ *
4
+ * Status: DRAFT. RFC 0050 (SAML / SCIM enterprise identity profiles) is
5
+ * `Draft`. The profile is documented in `auth-profiles.md`
6
+ * §`openwop-auth-saml` and reserved in `capabilities.auth.profiles`.
7
+ *
8
+ * Capability shape runs unconditionally when the profile is advertised.
9
+ * The assertion-validation behavior (1 positive + ≥6 negatives: bad
10
+ * signature, `alg:none`, absent signature, `NotOnOrAfter` expiry,
11
+ * `NotBefore` not-yet-valid, signature-wrapping) is opt-in via
12
+ * `OPENWOP_TEST_SAML_IDP_URL` (operator-supplied synthetic IdP), because
13
+ * a deterministic XML-DSig signer harness isn't bundled yet — follows the
14
+ * `auth-mtls.test.ts` opt-in precedent. Soft-skips otherwise.
15
+ *
16
+ * @see RFCS/0050-saml-scim-enterprise-identity-profiles.md
17
+ * @see spec/v1/auth-profiles.md §`openwop-auth-saml`
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest';
21
+ import { driver } from '../lib/driver.js';
22
+ import { createSyntheticSamlIdp, type SamlVariant } from '../lib/saml-idp.js';
23
+
24
+ const SAML_PROFILE = 'openwop-auth-saml';
25
+
26
+ interface DiscoveryAuth {
27
+ profiles?: string[];
28
+ }
29
+
30
+ interface DiscoveryDoc {
31
+ capabilities?: { auth?: DiscoveryAuth };
32
+ extensions?: { auth?: DiscoveryAuth };
33
+ }
34
+
35
+ async function readProfiles(): Promise<string[] | null> {
36
+ const res = await driver.get('/.well-known/openwop');
37
+ const body = res.json as DiscoveryDoc | undefined;
38
+ return body?.capabilities?.auth?.profiles ?? body?.extensions?.auth?.profiles ?? null;
39
+ }
40
+
41
+ describe('auth-saml-profile: advertisement shape (RFC 0050)', () => {
42
+ it('auth.profiles, when present, is an array of non-empty strings', async () => {
43
+ const profiles = await readProfiles();
44
+ if (profiles === null) return; // host advertises no auth profiles
45
+ expect(
46
+ Array.isArray(profiles),
47
+ driver.describe('auth-profiles.md §Discovery', 'capabilities.auth.profiles MUST be an array'),
48
+ ).toBe(true);
49
+ for (const p of profiles) {
50
+ expect(typeof p === 'string' && p.length > 0).toBe(true);
51
+ }
52
+ });
53
+
54
+ it('claims openwop-auth-saml as a well-formed profile id when advertised', async () => {
55
+ const profiles = await readProfiles();
56
+ if (profiles === null || !profiles.includes(SAML_PROFILE)) return; // profile not claimed
57
+ expect(
58
+ profiles.includes(SAML_PROFILE),
59
+ driver.describe('RFC 0050 §A', 'openwop-auth-saml MUST appear verbatim in capabilities.auth.profiles when claimed'),
60
+ ).toBe(true);
61
+ });
62
+ });
63
+
64
+ describe('auth-saml-profile: assertion validation (RFC 0050 §A — opt-in)', () => {
65
+ const idpUrl = process.env.OPENWOP_TEST_SAML_IDP_URL;
66
+
67
+ it('rejects an `alg:none` / unsigned assertion (synthetic IdP required)', async () => {
68
+ const profiles = await readProfiles();
69
+ if (profiles === null || !profiles.includes(SAML_PROFILE)) return; // capability-gated
70
+ if (idpUrl === undefined || idpUrl.length === 0) return; // opt-in: synthetic-IdP harness not provided
71
+ // With a synthetic IdP, an `alg:none`/unsigned assertion presented to the
72
+ // host's SAML ACS MUST be rejected with `unauthenticated`.
73
+ const res = await driver.post('/v1/host/sample/auth/saml/validate', { idpUrl, variant: 'alg-none' });
74
+ if (res.status === 404) return; // seam unwired
75
+ expect(
76
+ res.status,
77
+ driver.describe('RFC 0050 §A', 'an `alg:none`/unsigned SAML assertion MUST be rejected (non-2xx)'),
78
+ ).toBeGreaterThanOrEqual(400);
79
+ });
80
+ });
81
+
82
+ describe('category: auth-saml synthetic-IdP reference suite (RFC 0050 §A)', () => {
83
+ // Server-free: the bundled synthetic IdP (conformance/src/lib/saml-idp.ts)
84
+ // mints a valid assertion + the 6 negative variants, and its verify()
85
+ // implements the RFC 0050 §A MUST list. This proves each negative is
86
+ // detectably malformed and gives the suite a reference SAML validator.
87
+ // A host's real ACS validates the SAME assertions over the
88
+ // `auth/saml/validate` seam (gated above on OPENWOP_TEST_SAML_IDP_URL).
89
+ const idp = createSyntheticSamlIdp();
90
+
91
+ it('publishes a PEM signing certificate', () => {
92
+ expect(idp.certificatePem).toContain('BEGIN PUBLIC KEY');
93
+ });
94
+
95
+ it('accepts a valid signed, in-window, non-wrapped assertion', () => {
96
+ const r = idp.verify(idp.mint('valid'));
97
+ expect(r.valid, `expected valid; got reason=${r.reason}`).toBe(true);
98
+ });
99
+
100
+ const negatives: ReadonlyArray<[Exclude<SamlVariant, 'valid'>, string]> = [
101
+ ['alg-none', 'alg-none'],
102
+ ['unsigned', 'unsigned'],
103
+ ['bad-signature', 'bad-signature'],
104
+ ['expired', 'expired'],
105
+ ['not-yet-valid', 'not-yet-valid'],
106
+ ['signature-wrapping', 'signature-wrapping'],
107
+ ];
108
+
109
+ for (const [variant, expectedReason] of negatives) {
110
+ it(`rejects the ${variant} assertion (RFC 0050 §A MUST)`, () => {
111
+ const r = idp.verify(idp.mint(variant));
112
+ expect(r.valid, `${variant} MUST be rejected`).toBe(false);
113
+ expect(
114
+ r.reason,
115
+ `${variant} MUST be rejected for the ${expectedReason} reason`,
116
+ ).toBe(expectedReason);
117
+ });
118
+ }
119
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * auth-scim-profile — RFC 0050: openwop-auth-scim profile.
3
+ *
4
+ * Status: DRAFT. RFC 0050 (SAML / SCIM enterprise identity profiles) is
5
+ * `Draft`. The profile is documented in `auth-profiles.md`
6
+ * §`openwop-auth-scim` and reserved in `capabilities.auth.profiles`.
7
+ *
8
+ * Capability shape runs unconditionally when the profile is advertised.
9
+ * The provisioning roundtrip (SCIM user+group → RFC 0048 principal +
10
+ * RFC 0049 role; deactivate ⇒ subsequent deny) is opt-in via
11
+ * `OPENWOP_TEST_SCIM_URL` (operator-supplied SCIM endpoint), following the
12
+ * `auth-mtls.test.ts` opt-in precedent. Soft-skips otherwise.
13
+ *
14
+ * @see RFCS/0050-saml-scim-enterprise-identity-profiles.md
15
+ * @see spec/v1/auth-profiles.md §`openwop-auth-scim`
16
+ */
17
+
18
+ import { describe, it, expect } from 'vitest';
19
+ import { driver } from '../lib/driver.js';
20
+
21
+ const SCIM_PROFILE = 'openwop-auth-scim';
22
+
23
+ interface DiscoveryAuth {
24
+ profiles?: string[];
25
+ }
26
+
27
+ interface DiscoveryDoc {
28
+ capabilities?: { auth?: DiscoveryAuth };
29
+ extensions?: { auth?: DiscoveryAuth };
30
+ }
31
+
32
+ async function readProfiles(): Promise<string[] | null> {
33
+ const res = await driver.get('/.well-known/openwop');
34
+ const body = res.json as DiscoveryDoc | undefined;
35
+ return body?.capabilities?.auth?.profiles ?? body?.extensions?.auth?.profiles ?? null;
36
+ }
37
+
38
+ describe('auth-scim-profile: advertisement shape (RFC 0050)', () => {
39
+ it('claims openwop-auth-scim as a well-formed profile id when advertised', async () => {
40
+ const profiles = await readProfiles();
41
+ if (profiles === null || !profiles.includes(SCIM_PROFILE)) return; // profile not claimed
42
+ expect(
43
+ profiles.includes(SCIM_PROFILE),
44
+ driver.describe('RFC 0050 §B', 'openwop-auth-scim MUST appear verbatim in capabilities.auth.profiles when claimed'),
45
+ ).toBe(true);
46
+ });
47
+ });
48
+
49
+ describe('auth-scim-profile: provisioning roundtrip (RFC 0050 §B — opt-in)', () => {
50
+ const scimUrl = process.env.OPENWOP_TEST_SCIM_URL;
51
+
52
+ it('provisions a SCIM user → principal (SCIM endpoint required)', async () => {
53
+ const profiles = await readProfiles();
54
+ if (profiles === null || !profiles.includes(SCIM_PROFILE)) return; // capability-gated
55
+ if (scimUrl === undefined || scimUrl.length === 0) return; // opt-in: SCIM endpoint not provided
56
+ // A SCIM POST /Users against the operator-supplied endpoint MUST upsert a
57
+ // principal the host can subsequently resolve.
58
+ const res = await driver.post('/v1/host/sample/auth/scim/provision', { scimUrl, op: 'create-user' });
59
+ if (res.status === 404) return; // seam unwired
60
+ expect(
61
+ res.status,
62
+ driver.describe('RFC 0050 §B', 'a SCIM user provisioning MUST succeed (2xx) and upsert an RFC 0048 principal'),
63
+ ).toBeLessThan(400);
64
+ });
65
+ });
@@ -0,0 +1,80 @@
1
+ /**
2
+ * authorization-fail-closed — RFC 0049 §C invariant verification.
3
+ *
4
+ * Status: DRAFT. RFC 0049 (RBAC scopes & authorization decisions) is `Draft`.
5
+ * Backs the SECURITY invariant `authorization-fail-closed`.
6
+ *
7
+ * Capability-gated: skips when the host does not advertise
8
+ * `capabilities.authorization.supported = true`.
9
+ *
10
+ * What this scenario asserts:
11
+ * 1. Advertisement shape — when authorization is supported, `failClosed`
12
+ * (when present) is exactly `true` (RFC 0049 §C).
13
+ * 2. Fail-closed MUST-NOT — when the host exposes the optional
14
+ * `POST /v1/host/sample/authorization/decide` test seam, a decision for
15
+ * a principal with an absent/unseeded role MUST resolve to
16
+ * `allowed: false`. The host MUST NOT default-allow under any error
17
+ * condition.
18
+ *
19
+ * Hosts without the seam soft-skip the behavioral probe (404).
20
+ *
21
+ * @see RFCS/0049-rbac-scopes-and-authorization-decisions.md
22
+ * @see SECURITY/invariants.yaml id: authorization-fail-closed
23
+ */
24
+
25
+ import { describe, it, expect } from 'vitest';
26
+ import { driver } from '../lib/driver.js';
27
+
28
+ interface DiscoveryAuthorization {
29
+ supported?: boolean;
30
+ failClosed?: boolean;
31
+ }
32
+
33
+ interface DiscoveryDoc {
34
+ capabilities?: {
35
+ authorization?: DiscoveryAuthorization;
36
+ };
37
+ }
38
+
39
+ async function readAuthorization(): Promise<DiscoveryAuthorization | null> {
40
+ const res = await driver.get('/.well-known/openwop');
41
+ const body = res.json as DiscoveryDoc | undefined;
42
+ return body?.capabilities?.authorization ?? null;
43
+ }
44
+
45
+ describe('authorization-fail-closed: advertisement shape (RFC 0049 §C)', () => {
46
+ it('failClosed is exactly true when authorization is supported', async () => {
47
+ const authz = await readAuthorization();
48
+ if (!authz?.supported || authz.failClosed === undefined) return;
49
+ expect(
50
+ authz.failClosed,
51
+ driver.describe('RFC 0049 §C', 'capabilities.authorization.failClosed MUST be `true`'),
52
+ ).toBe(true);
53
+ });
54
+ });
55
+
56
+ describe('authorization-fail-closed: absent/unseeded role MUST deny (RFC 0049 §C)', () => {
57
+ it('a decision for an unseeded-role principal resolves allowed=false', async () => {
58
+ const authz = await readAuthorization();
59
+ if (!authz?.supported) return; // capability-gated
60
+
61
+ // Seam contract: request an authorization decision for a principal whose
62
+ // role is absent/unseeded. The host MUST fail closed.
63
+ const res = await driver.post('/v1/host/sample/authorization/decide', {
64
+ principal: 'conformance-unseeded-principal',
65
+ action: 'runs:cancel',
66
+ resource: 'run-conformance-probe',
67
+ });
68
+ // 404 from a host that hasn't wired the seam is a soft-skip.
69
+ if (res.status === 404) return;
70
+
71
+ const decision = res.json as { allowed?: boolean } | undefined;
72
+ expect(
73
+ decision?.allowed,
74
+ driver.describe(
75
+ 'SECURITY/invariants.yaml authorization-fail-closed',
76
+ 'an absent/unseeded role MUST deny (allowed=false); the host MUST NOT default-allow',
77
+ ),
78
+ ).toBe(false);
79
+ });
80
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * authorization-roles-shape — RFC 0049 §A advertisement-shape verification.
3
+ *
4
+ * Status: DRAFT. RFC 0049 (RBAC scopes & authorization decisions) is `Draft`.
5
+ * The `capabilities.authorization` block has landed in
6
+ * `schemas/capabilities.schema.json`.
7
+ *
8
+ * Always runs (shape-only): when the host advertises
9
+ * `capabilities.authorization`, its fields MUST be well-formed.
10
+ *
11
+ * What this scenario asserts:
12
+ * 1. `capabilities.authorization` is either absent or a well-formed object.
13
+ * 2. When `supported: true`: `failClosed` (when present) is exactly `true`
14
+ * (RFC 0049 §C), and every `roles[]` entry has a non-empty `role` + a
15
+ * `scopes` array (RFC 0049 §A).
16
+ *
17
+ * @see RFCS/0049-rbac-scopes-and-authorization-decisions.md
18
+ * @see spec/v1/auth.md §"Role-based authorization (RFC 0049)"
19
+ */
20
+
21
+ import { describe, it, expect } from 'vitest';
22
+ import { driver } from '../lib/driver.js';
23
+
24
+ interface DiscoveryRole {
25
+ role?: string;
26
+ scopes?: string[];
27
+ }
28
+
29
+ interface DiscoveryAuthorization {
30
+ supported?: boolean;
31
+ failClosed?: boolean;
32
+ roles?: DiscoveryRole[];
33
+ }
34
+
35
+ interface DiscoveryDoc {
36
+ capabilities?: {
37
+ authorization?: DiscoveryAuthorization;
38
+ };
39
+ }
40
+
41
+ async function readAuthorization(): Promise<DiscoveryAuthorization | null> {
42
+ const res = await driver.get('/.well-known/openwop');
43
+ const body = res.json as DiscoveryDoc | undefined;
44
+ return body?.capabilities?.authorization ?? null;
45
+ }
46
+
47
+ describe('authorization-roles-shape: advertisement shape (RFC 0049 §A)', () => {
48
+ it('capabilities.authorization is either absent or well-formed', async () => {
49
+ const authz = await readAuthorization();
50
+ if (authz === null) return; // host doesn't advertise authorization at all
51
+ expect(
52
+ typeof authz.supported,
53
+ driver.describe(
54
+ 'capabilities.schema.json §authorization',
55
+ 'capabilities.authorization.supported MUST be a boolean when authorization is advertised',
56
+ ),
57
+ ).toBe('boolean');
58
+ });
59
+
60
+ it('failClosed, when present, is exactly true (RFC 0049 §C)', async () => {
61
+ const authz = await readAuthorization();
62
+ if (!authz?.supported || authz.failClosed === undefined) return;
63
+ expect(
64
+ authz.failClosed,
65
+ driver.describe('RFC 0049 §C', 'capabilities.authorization.failClosed MUST be `true` (fail-closed)'),
66
+ ).toBe(true);
67
+ });
68
+
69
+ it('every advertised role has a non-empty role name + a scopes array', async () => {
70
+ const authz = await readAuthorization();
71
+ if (!authz?.supported || authz.roles === undefined) return;
72
+ for (const entry of authz.roles) {
73
+ expect(
74
+ typeof entry.role === 'string' && entry.role.length > 0,
75
+ driver.describe('RFC 0049 §A', 'each capabilities.authorization.roles[] entry MUST declare a non-empty role'),
76
+ ).toBe(true);
77
+ expect(
78
+ Array.isArray(entry.scopes),
79
+ driver.describe('RFC 0049 §A', 'each role MUST declare a scopes array'),
80
+ ).toBe(true);
81
+ }
82
+ });
83
+ });