diff --git a/docs/javascripts/production-lifecycle.js b/docs/javascripts/production-lifecycle.js index 91a9ba5..247208e 100644 --- a/docs/javascripts/production-lifecycle.js +++ b/docs/javascripts/production-lifecycle.js @@ -222,25 +222,65 @@ return targets; } - function validateLiveAdmissions(value, projection, targets) { + function isObjectReferenceRow(admission) { + return ( + isObject(admission) && + admission.kind === "qualification-release" && + typeof admission.object_path === "string" && + typeof admission.target !== "string" + ); + } + + function latestAdmissionsAreUntilRevoked(targets) { + for (const targetId of TARGET_IDS) { + const admission = targets.get(targetId)?.latest_admission; + if ( + !isObject(admission) || + admission.expires_at !== null || + admission.revoked_at != null + ) { + return false; + } + } + return true; + } + + function issuedPolicyBindsOrUntilRevoked(value, projection, targets, policy) { + if (!DIGEST.test(value.policy_sha256)) return false; + if (value.policy_sha256 === projection.source.files.policy.sha256) return true; + return ( + isObject(policy) && + policy.admission_validity === "until_revoked" && + latestAdmissionsAreUntilRevoked(targets) + ); + } + + function validateLiveAdmissions(value, projection, targets, policy = null) { if ( !hasExactKeys(value, ["$schema", "schema_version", "policy_sha256", "admissions"]) || value.$schema !== "schemas/production-lifecycle-admissions.schema.json" || value.schema_version !== "openadapt.production-lifecycle-admissions/v1" || - value.policy_sha256 !== projection.source.files.policy.sha256 || - !Array.isArray(value.admissions) + !Array.isArray(value.admissions) || + !issuedPolicyBindsOrUntilRevoked(value, projection, targets, policy) ) { return false; } const byTarget = new Map(TARGET_IDS.map((id) => [id, []])); + let objectRefCount = 0; for (const admission of value.admissions) { - if (!isObject(admission) || !byTarget.has(admission.target)) return false; + if (!isObject(admission)) return false; + if (isObjectReferenceRow(admission)) { + objectRefCount += 1; + continue; + } + if (!byTarget.has(admission.target)) return false; byTarget.get(admission.target).push(admission); } for (const targetId of TARGET_IDS) { const live = byTarget .get(targetId) .sort((left, right) => left?.release_identity?.sequence - right?.release_identity?.sequence); + if (live.length === 0 && objectRefCount > 0) continue; if (!sameJson(live, targets.get(targetId).admission_history)) return false; } return true; @@ -294,6 +334,7 @@ summaryUrl: null, evidence: { class: admission.evidence_class }, release, + untilRevoked: true, }); } @@ -624,7 +665,28 @@ const expectedDigest = projection.source.files.admissions.sha256; if ((await sha256(admissionsBytes)) !== expectedDigest) return null; const liveAdmissions = JSON.parse(new TextDecoder().decode(admissionsBytes)); - if (!validateLiveAdmissions(liveAdmissions, projection, targets)) return null; + let policy = null; + if (liveAdmissions.policy_sha256 !== projection.source.files.policy.sha256) { + const policyItem = projection.source.files.policy; + if ( + !isObject(policyItem) || + !isHttpsUrl(policyItem.url) || + !DIGEST.test(policyItem.sha256) + ) { + return null; + } + const policyBytes = await fetchBytes(fetchImpl, policyItem.url, now); + if (!policyBytes || (await sha256(policyBytes)) !== policyItem.sha256) { + return null; + } + policy = JSON.parse(new TextDecoder().decode(policyBytes)); + if (!isObject(policy) || typeof policy.admission_validity !== "string") { + return null; + } + } + if (!validateLiveAdmissions(liveAdmissions, projection, targets, policy)) { + return null; + } const candidates = new Map(); for (const [targetId, target] of targets) { @@ -636,7 +698,9 @@ [...candidates.entries()].map(async ([targetId, active]) => [ targetId, active, - await verifyTargetAuthorities(active, fetchImpl, now), + active.untilRevoked === true + ? true + : await verifyTargetAuthorities(active, fetchImpl, now), ]), ); const activeTargets = new Map( @@ -660,6 +724,7 @@ return; } element.append("Production: ", active.releaseLabel, ". "); + if (!isHttpsUrl(active.summaryUrl)) return; const link = element.ownerDocument.createElement("a"); link.href = active.summaryUrl; link.rel = "noopener noreferrer"; diff --git a/tests/js/production_lifecycle.test.cjs b/tests/js/production_lifecycle.test.cjs index 913edc9..fe32f02 100644 --- a/tests/js/production_lifecycle.test.cjs +++ b/tests/js/production_lifecycle.test.cjs @@ -704,3 +704,166 @@ test("a timestamped v2 expiry is not until-revoked", () => { target.latest_admission.expires_at = "2026-09-09T18:24:25Z"; assert.equal(lifecycle.deriveTarget(target, committed, now), null); }); + +const V2_SPEC = { + agent: { claimScope: "production_agent", releaseKind: "package" }, + capture: { claimScope: "production_capture", releaseKind: "package" }, + cloud: { claimScope: "production_cloud", releaseKind: "deployment" }, + desktop: { claimScope: "production_desktop", releaseKind: "package" }, + docs: { claimScope: "production_docs", releaseKind: "deployment" }, + flow: { claimScope: "production_flow", releaseKind: "package" }, + openadapt: { claimScope: "production_openadapt", releaseKind: "package" }, +}; + +function v2Admission(targetId, overrides = {}) { + const spec = V2_SPEC[targetId]; + const release = + spec.releaseKind === "package" + ? { kind: "package", version: "1.2.3" } + : { kind: "deployment", deployment_id: `${targetId}-deploy` }; + return { + target: targetId, + claim_scope: spec.claimScope, + verdict: "accepted", + expires_at: null, + revoked_at: null, + evidence_class: "remote-safe-synthetic", + issued_at: "2026-09-02T18:24:25Z", + release_identity: { + schema_version: "openadapt.monotonic-production-release/v1", + channel: "production", + sequence: 1, + previous_admission_sha256: null, + }, + release, + ...overrides, + }; +} + +function objectRef(index) { + return { + schema_version: "openadapt.production-evidence-object-reference/v2", + kind: "qualification-release", + object_path: `production-evidence/objects/sha256/${index}/obj.qualification-release.json`, + }; +} + +function makeUntilRevokedFixture(overrides = {}) { + const histories = Object.fromEntries( + lifecycle.TARGET_IDS.map((id) => [id, [v2Admission(id, overrides.admissions?.[id])]]), + ); + const fixture = makeFixture(histories); + const policy = { + admission_validity: overrides.admissionValidity ?? "until_revoked", + }; + const policyBytes = jsonBytes(policy); + const live = { + $schema: "schemas/production-lifecycle-admissions.schema.json", + schema_version: "openadapt.production-lifecycle-admissions/v1", + policy_sha256: overrides.issuedPolicySha256 ?? `sha256:${"a".repeat(64)}`, + admissions: overrides.liveAdmissions ?? lifecycle.TARGET_IDS.map((_, index) => objectRef(index)), + }; + const bytes = jsonBytes(live); + fixture.live = live; + fixture.bytes = bytes; + fixture.policyBytes = policyBytes; + fixture.projection.source.files.admissions.sha256 = digest(bytes); + fixture.projection.source.files.policy.sha256 = digest(policyBytes); + return fixture; +} + +function fetchUntilRevoked(fixture, options = {}) { + return async (url) => { + if (options.unavailable) return { ok: false }; + if (url.startsWith(`${lifecycle.PROJECTION_URL}?openadapt_lifecycle_request=`)) { + return { ok: true, json: async () => structuredClone(fixture.projection) }; + } + if (url.startsWith(`${lifecycle.ADMISSIONS_URL}?openadapt_lifecycle_request=`)) { + return byteResponse(options.bytes ?? fixture.bytes); + } + const policyUrl = fixture.projection.source.files.policy.url; + if (url === policyUrl || url.startsWith(`${policyUrl}?`)) { + if (options.policyUnavailable) return { ok: false }; + return byteResponse(options.policyBytes ?? fixture.policyBytes); + } + return { ok: false }; + }; +} + +const AFTER_ISSUE = Date.parse("2026-09-02T20:00:00Z"); + +test("until-revoked object-refs keep retained policy hashes active", async () => { + const fixture = makeUntilRevokedFixture(); + const state = await lifecycle.load(fetchUntilRevoked(fixture), AFTER_ISSUE); + + assert.ok(state?.activeTargets instanceof Map); + assert.deepEqual([...state.activeTargets.keys()].sort(), lifecycle.TARGET_IDS); + assert.equal(state.activeTargets.get("flow").untilRevoked, true); + assert.equal(state.activeTargets.get("flow").releaseLabel, "release 1.2.3"); + assert.equal(state.defaultInstallVerified, true); +}); + +test("until-revoked policy mismatch fails closed without a parseable policy", async () => { + const fixture = makeUntilRevokedFixture(); + assert.equal( + await lifecycle.load( + fetchUntilRevoked(fixture, { policyUnavailable: true }), + AFTER_ISSUE, + ), + null, + ); + + const unparseable = Buffer.from("not-json"); + fixture.projection.source.files.policy.sha256 = digest(unparseable); + assert.equal( + await lifecycle.load( + fetchUntilRevoked(fixture, { policyBytes: unparseable }), + AFTER_ISSUE, + ), + null, + ); + + const bounded = makeUntilRevokedFixture({ admissionValidity: "bounded" }); + assert.equal(await lifecycle.load(fetchUntilRevoked(bounded), AFTER_ISSUE), null); +}); + +test("until-revoked mismatch fails closed on expiry, revocation, or identity drift", async () => { + const expired = makeUntilRevokedFixture({ + admissions: { flow: { expires_at: "2026-09-09T18:24:25Z" } }, + }); + assert.equal(await lifecycle.load(fetchUntilRevoked(expired), AFTER_ISSUE), null); + + const revoked = makeUntilRevokedFixture({ + admissions: { flow: { revoked_at: "2026-09-02T19:00:00Z" } }, + }); + assert.equal(await lifecycle.load(fetchUntilRevoked(revoked), AFTER_ISSUE), null); + + const drifted = makeUntilRevokedFixture({ + admissions: { flow: { claim_scope: "production_openadapt" } }, + }); + const driftedState = await lifecycle.load(fetchUntilRevoked(drifted), AFTER_ISSUE); + assert.equal(driftedState.activeTargets.has("flow"), false); + assert.equal(driftedState.activeTargets.size, 6); + assert.equal(driftedState.defaultInstallVerified, false); +}); + +test("object-ref rows are not product targets and unknown rows fail closed", async () => { + const fixture = makeUntilRevokedFixture({ + liveAdmissions: [ + ...lifecycle.TARGET_IDS.map((_, index) => objectRef(index)), + { kind: "not-a-target", object_path: "nope.json" }, + ], + }); + assert.equal(await lifecycle.load(fetchUntilRevoked(fixture), AFTER_ISSUE), null); + + const valid = makeUntilRevokedFixture(); + assert.equal( + lifecycle.validateLiveAdmissions( + valid.live, + valid.projection, + lifecycle.validateProjection(valid.projection), + { admission_validity: "until_revoked" }, + ), + true, + ); +});