@openwop/openwop-conformance 1.5.0 → 1.6.1
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/CHANGELOG.md +27 -0
- package/README.md +2 -2
- package/api/asyncapi.yaml +25 -4
- package/api/openapi.yaml +371 -0
- package/coverage.md +31 -4
- package/fixtures/conformance-phase4-nondet-tool.json +53 -0
- package/fixtures/conformance-phase4-replay-divergence.json +40 -0
- package/fixtures.md +5 -3
- package/package.json +1 -1
- package/schemas/README.md +4 -0
- package/schemas/annotation-create.schema.json +37 -0
- package/schemas/annotation.schema.json +56 -0
- package/schemas/capabilities.schema.json +191 -3
- package/schemas/credential-reference.schema.json +21 -0
- package/schemas/node-pack-manifest.schema.json +112 -1
- package/schemas/run-diff-response.schema.json +64 -0
- package/schemas/run-event-payloads.schema.json +104 -2
- package/schemas/run-event.schema.json +8 -1
- package/schemas/run-snapshot.schema.json +11 -0
- package/src/lib/behavior-gate.ts +51 -0
- package/src/lib/driver.ts +13 -1
- package/src/lib/feedback.ts +31 -0
- package/src/lib/saml-idp.ts +179 -0
- package/src/scenarios/approval-gate-events.test.ts +61 -0
- package/src/scenarios/approval-gate-flow.test.ts +68 -0
- package/src/scenarios/auth-saml-profile.test.ts +119 -0
- package/src/scenarios/auth-scim-profile.test.ts +65 -0
- package/src/scenarios/authorization-fail-closed.test.ts +80 -0
- package/src/scenarios/authorization-roles-shape.test.ts +83 -0
- package/src/scenarios/connector-manifest-validity.test.ts +142 -0
- package/src/scenarios/credential-payload-redaction.test.ts +93 -0
- package/src/scenarios/credentials-capability-shape.test.ts +90 -0
- package/src/scenarios/cross-engine-append-behavior.test.ts +204 -0
- package/src/scenarios/cross-host-traceparent-propagation.test.ts +13 -6
- package/src/scenarios/cross-workspace-isolation.test.ts +72 -0
- package/src/scenarios/deadletter-capability-shape.test.ts +59 -0
- package/src/scenarios/deadletter-retry-exhaustion.test.ts +62 -0
- package/src/scenarios/experimental-tier-shape.test.ts +192 -0
- package/src/scenarios/feedback-capability-shape.test.ts +35 -0
- package/src/scenarios/feedback-correction-redaction.test.ts +35 -0
- package/src/scenarios/feedback-cross-tenant-isolation.test.ts +37 -0
- package/src/scenarios/feedback-fork-not-copied.test.ts +40 -0
- package/src/scenarios/feedback-on-terminal-run.test.ts +32 -0
- package/src/scenarios/feedback-record-and-list.test.ts +32 -0
- package/src/scenarios/feedback-unsupported-501.test.ts +32 -0
- package/src/scenarios/identity-owner-shape.test.ts +64 -0
- package/src/scenarios/multi-agent-confidence-escalation.test.ts +13 -12
- package/src/scenarios/multi-agent-memory-lifecycle.test.ts +87 -12
- package/src/scenarios/multi-region-idempotency-behavior.test.ts +203 -0
- package/src/scenarios/oauth-capability-shape.test.ts +97 -0
- package/src/scenarios/oauth-connector-redaction.test.ts +91 -0
- package/src/scenarios/pack-registry-isolation.test.ts +108 -0
- package/src/scenarios/pack-registry-publish.test.ts +1 -1
- package/src/scenarios/prompt-mutation-workspace-membership-enforced.test.ts +126 -0
- package/src/scenarios/prompt-read-workspace-membership-enforced.test.ts +183 -0
- package/src/scenarios/redaction.test.ts +4 -1
- package/src/scenarios/replay-divergence-at-refusal.test.ts +187 -7
- package/src/scenarios/replay-observable-sequence-determinism.test.ts +20 -6
- package/src/scenarios/run-diff.test.ts +143 -0
- package/src/scenarios/sandbox-capability-gate-respected.test.ts +7 -1
- package/src/scenarios/sandbox-memory-cap.test.ts +7 -5
- package/src/scenarios/sandbox-mvp-behavior.test.ts +280 -0
- package/src/scenarios/sandbox-no-cross-pack-mutation.test.ts +7 -1
- package/src/scenarios/sandbox-no-host-env-leak.test.ts +5 -1
- package/src/scenarios/sandbox-no-host-fs-escape.test.ts +9 -1
- package/src/scenarios/sandbox-no-host-process-escape.test.ts +5 -1
- package/src/scenarios/sandbox-no-network-escape.test.ts +5 -1
- package/src/scenarios/sandbox-timeout-cap.test.ts +7 -5
- package/src/scenarios/scheduling-capability-shape.test.ts +81 -0
- package/src/scenarios/scheduling-cron-fires-once.test.ts +66 -0
- package/src/scenarios/secret-leakage-otel-attribute.test.ts +241 -0
- package/src/scenarios/spec-corpus-validity.test.ts +6 -3
|
@@ -71,6 +71,7 @@
|
|
|
71
71
|
"run.paused",
|
|
72
72
|
"run.resumed",
|
|
73
73
|
"run.restored-from-snapshot",
|
|
74
|
+
"run.dead_lettered",
|
|
74
75
|
"node.started",
|
|
75
76
|
"node.completed",
|
|
76
77
|
"node.failed",
|
|
@@ -82,6 +83,9 @@
|
|
|
82
83
|
"node.cancelled",
|
|
83
84
|
"approval.requested",
|
|
84
85
|
"approval.received",
|
|
86
|
+
"approval.granted",
|
|
87
|
+
"approval.rejected",
|
|
88
|
+
"approval.overridden",
|
|
85
89
|
"clarification.requested",
|
|
86
90
|
"clarification.resolved",
|
|
87
91
|
"interrupt.requested",
|
|
@@ -126,7 +130,10 @@
|
|
|
126
130
|
"conversation.closed",
|
|
127
131
|
"memory.compacted",
|
|
128
132
|
"core.workflowChain.event",
|
|
129
|
-
"core.workflowChain.confidence-escalated"
|
|
133
|
+
"core.workflowChain.confidence-escalated",
|
|
134
|
+
"connector.authorized",
|
|
135
|
+
"connector.auth_expired",
|
|
136
|
+
"authorization.decided"
|
|
130
137
|
]
|
|
131
138
|
}
|
|
132
139
|
}
|
|
@@ -32,6 +32,17 @@
|
|
|
32
32
|
],
|
|
33
33
|
"description": "Current run state. `waiting-external` MUST be used when the suspended interrupt's `kind` is `external-event` per `interrupt-profiles.md §openwop-interrupt-external-event` — distinguishes external-event waits from HITL waits at the wire level. Forward-compat: future statuses MAY be added; readers SHOULD treat unknown values as terminal-unknown rather than throw."
|
|
34
34
|
},
|
|
35
|
+
"owner": {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"description": "RFC 0048. The identity triple that owns this run. Redaction-safe — `principal` is an opaque identifier, never PII or credential material. Optional: single-tenant hosts omit it. A principal scoped to one `workspace` MUST NOT read a run owned by another (`run_forbidden`).",
|
|
38
|
+
"required": ["tenant"],
|
|
39
|
+
"properties": {
|
|
40
|
+
"tenant": { "type": "string", "minLength": 1, "description": "Top-level isolation boundary." },
|
|
41
|
+
"workspace": { "type": "string", "minLength": 1, "description": "Optional sub-tenant within the tenant (RFC 0048 workspace)." },
|
|
42
|
+
"principal": { "type": "string", "minLength": 1, "description": "Acting identity (user or agent) — opaque id, never PII." }
|
|
43
|
+
},
|
|
44
|
+
"additionalProperties": false
|
|
45
|
+
},
|
|
35
46
|
"currentNodeId": {
|
|
36
47
|
"type": "string",
|
|
37
48
|
"description": "Set when the run is suspended at a specific node (`waiting-approval` / `waiting-input` / `waiting-external`) — identifies which node holds the interrupt."
|
package/src/lib/behavior-gate.ts
CHANGED
|
@@ -105,3 +105,54 @@ export function behaviorGate(profileName: string, advertised: boolean): boolean
|
|
|
105
105
|
);
|
|
106
106
|
return false;
|
|
107
107
|
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* RFC 0042 §D — experimental-tier soft-skip router for capability-gated
|
|
111
|
+
* scenarios. Wraps `behaviorGate` with an additional tier check.
|
|
112
|
+
*
|
|
113
|
+
* Returns true if the scenario should proceed with assertions, false if
|
|
114
|
+
* it should skip:
|
|
115
|
+
* - tier === 'experimental' AND OPENWOP_REQUIRE_EXPERIMENTAL not set → soft-skip
|
|
116
|
+
* (the scenario does NOT count toward "Failed" or "Skipped (capability-gated)"
|
|
117
|
+
* in the four-bucket taxonomy — a new fifth bucket "Skipped (experimental)"
|
|
118
|
+
* SHOULD be tallied by reporters; logged via the dedicated console.warn below.)
|
|
119
|
+
* - tier === 'experimental' AND OPENWOP_REQUIRE_EXPERIMENTAL=true → behave as
|
|
120
|
+
* stable (hand off to behaviorGate; honor OPENWOP_REQUIRE_BEHAVIOR + opt-out).
|
|
121
|
+
* - tier === 'stable' (default) → identical to behaviorGate.
|
|
122
|
+
*
|
|
123
|
+
* The `experimentalUntil` ISO-8601 date is logged for telemetry but does NOT
|
|
124
|
+
* gate behavior — the spec contract is that the wire shape MAY shift before
|
|
125
|
+
* the date, not that the scenario MUST stop running.
|
|
126
|
+
*/
|
|
127
|
+
export function experimentalGate(
|
|
128
|
+
profileName: string,
|
|
129
|
+
advertised: boolean,
|
|
130
|
+
tier: 'stable' | 'experimental' | undefined,
|
|
131
|
+
experimentalUntil?: string,
|
|
132
|
+
): boolean {
|
|
133
|
+
if (tier === 'experimental') {
|
|
134
|
+
const env = loadEnv();
|
|
135
|
+
const requireExperimental = process.env.OPENWOP_REQUIRE_EXPERIMENTAL === 'true';
|
|
136
|
+
if (!requireExperimental) {
|
|
137
|
+
// eslint-disable-next-line no-console
|
|
138
|
+
console.warn(
|
|
139
|
+
`[experimental capability: ${profileName}] host advertises tier='experimental'` +
|
|
140
|
+
(experimentalUntil ? ` until ${experimentalUntil}` : '') +
|
|
141
|
+
`; scenario skipped under default mode (set OPENWOP_REQUIRE_EXPERIMENTAL=true to run)`,
|
|
142
|
+
);
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
// Strict-mode experimental: hand off to behaviorGate with the standard
|
|
146
|
+
// advertised+opt-out rules. A host that advertises tier='experimental'
|
|
147
|
+
// AND opts the profile out via OPENWOP_OPTED_OUT_PROFILES would surface
|
|
148
|
+
// the standard advertise+opt-out conflict warning from behaviorGate.
|
|
149
|
+
// Don't strip the env.requireBehavior flag — that's a separate axis.
|
|
150
|
+
// eslint-disable-next-line no-console
|
|
151
|
+
console.warn(
|
|
152
|
+
`[experimental capability: ${profileName}] OPENWOP_REQUIRE_EXPERIMENTAL=true; ` +
|
|
153
|
+
`running as strict assertion against advertised='${advertised}'`,
|
|
154
|
+
);
|
|
155
|
+
void env;
|
|
156
|
+
}
|
|
157
|
+
return behaviorGate(profileName, advertised);
|
|
158
|
+
}
|
package/src/lib/driver.ts
CHANGED
|
@@ -50,7 +50,19 @@ class OpenWOPDriver {
|
|
|
50
50
|
|
|
51
51
|
const fetchInit: RequestInit = { method, headers };
|
|
52
52
|
if (init.body !== undefined) {
|
|
53
|
-
|
|
53
|
+
// Buffer / Uint8Array bodies are sent as raw bytes — needed by the
|
|
54
|
+
// RFC 0025 test-mode publish scenarios so the host's body-shape
|
|
55
|
+
// check sees the bytes the caller actually wrote (rather than a
|
|
56
|
+
// JSON-stringified `{"type":"Buffer","data":[...]}` envelope).
|
|
57
|
+
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(init.body)) {
|
|
58
|
+
fetchInit.body = new Uint8Array(init.body);
|
|
59
|
+
} else if (init.body instanceof Uint8Array) {
|
|
60
|
+
fetchInit.body = init.body;
|
|
61
|
+
} else if (typeof init.body === 'string') {
|
|
62
|
+
fetchInit.body = init.body;
|
|
63
|
+
} else {
|
|
64
|
+
fetchInit.body = JSON.stringify(init.body);
|
|
65
|
+
}
|
|
54
66
|
}
|
|
55
67
|
const res = await fetch(url, fetchInit);
|
|
56
68
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the RFC 0056 feedback/annotation conformance scenarios.
|
|
3
|
+
* Lives in lib/ (not a *.test.ts) so the scenarios can import it via the
|
|
4
|
+
* standard `../lib/feedback.js` path.
|
|
5
|
+
*/
|
|
6
|
+
import { driver } from './driver.js';
|
|
7
|
+
import { isFixtureAdvertised } from './fixtures.js';
|
|
8
|
+
|
|
9
|
+
interface DiscoveryDoc {
|
|
10
|
+
capabilities?: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Reads `capabilities.feedback` from discovery; null when unadvertised. */
|
|
14
|
+
export async function readFeedbackCap(): Promise<Record<string, unknown> | null> {
|
|
15
|
+
const res = await driver.get('/.well-known/openwop');
|
|
16
|
+
const body = res.json as DiscoveryDoc | undefined;
|
|
17
|
+
const top = body?.capabilities as Record<string, unknown> | undefined;
|
|
18
|
+
const fb = top && typeof top === 'object' ? (top as Record<string, unknown>)['feedback'] : undefined;
|
|
19
|
+
return fb && typeof fb === 'object' ? (fb as Record<string, unknown>) : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SEED_FIXTURE = 'conformance-a';
|
|
23
|
+
|
|
24
|
+
/** Seeds a run via the basic `conformance-a` fixture; null (soft-skip) when
|
|
25
|
+
* the fixture isn't advertised or creation fails. */
|
|
26
|
+
export async function seedRun(tenantId: string): Promise<string | null> {
|
|
27
|
+
if (!isFixtureAdvertised(SEED_FIXTURE)) return null;
|
|
28
|
+
const r = await driver.post('/v1/runs', { workflowId: SEED_FIXTURE, tenantId, inputs: {} });
|
|
29
|
+
if (r.status !== 200 && r.status !== 201) return null;
|
|
30
|
+
return (r.json as { runId?: string } | undefined)?.runId ?? null;
|
|
31
|
+
}
|
|
@@ -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
|
+
});
|