From cc083d78bf34c894e1d24a695597c552db8012c1 Mon Sep 17 00:00:00 2001 From: ECHO OMEGA PRIME Date: Tue, 10 Nov 2026 08:35:54 +0000 Subject: [PATCH] feat: export audited canonical certification identities --- .echo/sdk.json | 1 + .../certforge-capability-surface.v2.json | 1 + contracts/certforge-sdk-capabilities.v1.json | 205 +++++++++++++++- docs/CANONICAL_IDENTITY_EXPORT.md | 75 ++++++ scripts/certification_journey.py | 4 +- scripts/master_acceptance.py | 6 +- scripts/register_certforge_caps.sql | 7 + scripts/register_certforge_sdk_schemas.sql | 5 +- .../identity_export.py | 162 +++++++++++++ src/echo_certification_forge/service.py | 37 +++ tests/test_echo_sdk_manifest.py | 2 +- tests/test_identity_export.py | 227 ++++++++++++++++++ tests/test_sdk_capability_contract.py | 2 +- 13 files changed, 724 insertions(+), 10 deletions(-) create mode 100644 docs/CANONICAL_IDENTITY_EXPORT.md create mode 100644 src/echo_certification_forge/identity_export.py create mode 100644 tests/test_identity_export.py diff --git a/.echo/sdk.json b/.echo/sdk.json index 2d1fe37..3de70bf 100644 --- a/.echo/sdk.json +++ b/.echo/sdk.json @@ -31,6 +31,7 @@ "echo.certforge.governance.get", "echo.certforge.governance.update", "echo.certforge.health", + "echo.certforge.identities", "echo.certforge.list_runs", "echo.certforge.members.activate", "echo.certforge.members.deactivate", diff --git a/contracts/certforge-capability-surface.v2.json b/contracts/certforge-capability-surface.v2.json index a1851fe..ec79ce5 100644 --- a/contracts/certforge-capability-surface.v2.json +++ b/contracts/certforge-capability-surface.v2.json @@ -7,6 +7,7 @@ "GET /v1/certifications": "echo.certforge.list_runs", "POST /v1/certifications": "echo.certforge.submit", "GET /v1/certifications/{run_id}": "echo.certforge.status", + "GET /v1/certifications/{run_id}/identities": "echo.certforge.identities", "POST /v1/certifications/{run_id}/cancel": "echo.certforge.cancel", "GET /v1/certifications/{run_id}/findings": "echo.certforge.findings", "GET /v1/certifications/{run_id}/evidence": "echo.certforge.evidence", diff --git a/contracts/certforge-sdk-capabilities.v1.json b/contracts/certforge-sdk-capabilities.v1.json index e3522ea..4afc7ad 100644 --- a/contracts/certforge-sdk-capabilities.v1.json +++ b/contracts/certforge-sdk-capabilities.v1.json @@ -983,6 +983,209 @@ "type": "object" } }, + "echo.certforge.identities": { + "input_schema": { + "additionalProperties": false, + "properties": { + "command": { + "maxLength": 64, + "minLength": 1, + "type": "string" + }, + "run_id": { + "type": "string" + } + }, + "required": [ + "command", + "run_id" + ], + "type": "object" + }, + "operation": "GET /v1/certifications/{run_id}/identities", + "output_schema": { + "additionalProperties": false, + "properties": { + "environment_identity_digest": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "export_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "identities": { + "additionalProperties": false, + "properties": { + "environment": { + "additionalProperties": false, + "properties": { + "adapter_set_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "egress_policy_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "harness_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "model_route_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "os_runtime_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "policy_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "prompt_set_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "runner_image_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "test_plan_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + } + }, + "required": [ + "runner_image_sha256", + "adapter_set_sha256", + "test_plan_sha256", + "policy_sha256", + "harness_sha256", + "prompt_set_sha256", + "model_route_sha256", + "os_runtime_sha256", + "egress_policy_sha256" + ], + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "artifact_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "canonical_ref": { + "maxLength": 2048, + "minLength": 1, + "type": "string" + }, + "configuration_sha256": { + "anyOf": [ + { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "dependency_sha256": { + "anyOf": [ + { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "source_commit": { + "anyOf": [ + { + "pattern": "^[0-9a-fA-F]{40}(?:[0-9a-fA-F]{24})?$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "target_type": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", + "type": "string" + }, + "tenant_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", + "type": "string" + } + }, + "required": [ + "tenant_id", + "target_type", + "canonical_ref", + "artifact_sha256", + "source_commit", + "dependency_sha256", + "configuration_sha256" + ], + "type": "object" + } + }, + "required": [ + "target", + "environment" + ], + "type": "object" + }, + "identities_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "rule_manifest_digest": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "rule_manifest_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", + "type": "string" + }, + "run_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", + "type": "string" + }, + "schema_version": { + "const": "certforge.canonical-identities.v1", + "type": "string" + }, + "target_identity_digest": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "tenant_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", + "type": "string" + } + }, + "required": [ + "schema_version", + "run_id", + "tenant_id", + "target_identity_digest", + "environment_identity_digest", + "rule_manifest_id", + "rule_manifest_digest", + "identities", + "identities_sha256", + "export_sha256" + ], + "type": "object" + } + }, "echo.certforge.list_runs": { "input_schema": { "additionalProperties": false, @@ -2295,7 +2498,7 @@ } } }, - "capability_count": 60, + "capability_count": 61, "policy": "Every Certification Forge SDK action has a command-bearing strict input schema and an output schema derived from its production route contract.", "schema_version": "1.0.0" } diff --git a/docs/CANONICAL_IDENTITY_EXPORT.md b/docs/CANONICAL_IDENTITY_EXPORT.md new file mode 100644 index 0000000..170f6a3 --- /dev/null +++ b/docs/CANONICAL_IDENTITY_EXPORT.md @@ -0,0 +1,75 @@ +# Canonical identity export + +`GET /v1/certifications/{run_id}/identities` exports the two canonical identity +objects already stored for one certification run. This is a metadata projection; +it does not reconstruct a target, read evidence artifacts, issue an attestation, +change a verdict, or admit a deployment. + +## Authorization and integrity + +The route requires configured subscriber governance and an authenticated Bearer +principal with `runs:read`. The existing tenant consistency check and tenant-scoped +store lookup apply. A tenant header by itself is insufficient, including when +subscriber governance is disabled. Unknown and foreign runs both return 404. + +The exporter requires every field of the stored `TargetIdentity` and +`EnvironmentIdentity`, validates their models, and recomputes both identity +digests. Declared identities and records whose objects do not match their stored +digests return 409. Duplicate fields, oversized records, unknown fields, invalid +source revisions, and references containing credentials, query material, +fragments, or control characters are refused. Encoded reference material is +checked conservatively. The exporter never silently redacts an identity and +substitutes a different digest. + +Before returning data, the route rechecks the live principal through the existing +resource audit operation. Revoked keys, changed permissions, and audit failures +prevent the export. The audit records only the run identifier and four digests; +canonical references and object contents are not audit details. Responses use +`Cache-Control: no-store`. Run rows, evidence, signatures, and policy stay unchanged. + +## Response and collector handoff + +The strict response schema is `certforge.canonical-identities.v1`. It contains: + +- `run_id`, `tenant_id`, `rule_manifest_id`, and `rule_manifest_digest`; +- the stored `target_identity_digest` and `environment_identity_digest`; +- `identities`, with exactly `target` and `environment`; +- `identities_sha256`, the SHA256 of canonical JSON for `identities`; +- `export_sha256`, the SHA256 of canonical JSON for the response excluding + `export_sha256` itself. + +Canonical JSON uses sorted keys, separators `(',', ':')`, UTF-8, +`ensure_ascii=False`, and no trailing newline. An admitted consumer verifies the +run/tenant and both digests against its independently selected certification run, +verifies `export_sha256`, and writes exactly the canonical `identities` bytes. +`identities_sha256` is then the exact file hash for the separately reviewed +production-E2E intake's `--identities-sha256` argument. The identity document has +only `target` and `environment`, as required by that intake. + +These hashes establish content consistency. They are not collector signatures, +release authority, or evidence that production journeys occurred. The existing +collector trust, freshness, real journey, external acceptance, and verdict checks +remain mandatory. No identity values are supplied as hand-written examples. + +## SDK contract and admission + +The proposed capability is `echo.certforge.identities`, a GET path operation with +`command` and `run_id` as required inputs, `certforge.read` as its gateway scope, +and danger tier 0. Its complete output schema is generated from the FastAPI route. +The registration proposal retains the same server-held tenant header and vaulted +subscriber credential reference as `echo.certforge.status`. No credential value +is placed in the request, response, source change, or documentation. + +Source declarations do not activate this capability. Native admission must verify +the exact issuer revision, trusted runtime, deployed route, registered descriptor, +and current HTTP handler's forwarding of the server-held Bearer/tenant binding. +Require an authenticated positive read of the intended run, wrong-tenant and +revoked-key negatives, matching digests, and a committed audit event. Do not infer +wrapper readiness solely from a registration row or schema. Existing evidence +artifact scopes, collector keys, service protections, and deployment policy must +remain unchanged. + +The service and generated contract repairs are isolated source work until that +admitted rollout and readback succeed. The separate production-E2E intake must +also be independently reviewed and admitted; this change neither installs it nor +selects or provisions a collector signer. diff --git a/scripts/certification_journey.py b/scripts/certification_journey.py index 712b631..a33fbbd 100644 --- a/scripts/certification_journey.py +++ b/scripts/certification_journey.py @@ -62,8 +62,8 @@ def main() -> int: sdk = json.loads((ROOT / ".echo" / "sdk.json").read_text(encoding="utf-8")) capabilities = sdk.get("capabilities", []) - require(len(capabilities) == 60, "SDK capability declaration must contain exactly 60 entries") - require(len(set(capabilities)) == 60, "SDK capability declaration contains duplicates") + require(len(capabilities) == 61, "SDK capability declaration must contain exactly 61 entries") + require(len(set(capabilities)) == 61, "SDK capability declaration contains duplicates") require(all(isinstance(item, str) and item.startswith("echo.") for item in capabilities), "SDK capability declaration contains an invalid identifier") diff --git a/scripts/master_acceptance.py b/scripts/master_acceptance.py index e9f4a2e..4048db6 100644 --- a/scripts/master_acceptance.py +++ b/scripts/master_acceptance.py @@ -72,10 +72,10 @@ def _p7(value: dict[str, Any]) -> None: def _sdk(value: dict[str, Any]) -> None: capabilities = value.get("capabilities") - _require(value.get("capability_count") == 60, "SDK contract must contain exactly 60 capabilities") - _require(isinstance(capabilities, dict) and len(capabilities) == 60, "SDK capability rows are incomplete") + _require(value.get("capability_count") == 61, "SDK contract must contain exactly 61 capabilities") + _require(isinstance(capabilities, dict) and len(capabilities) == 61, "SDK capability rows are incomplete") names = list(capabilities) - _require(len(set(names)) == 60, "SDK capability IDs are not unique") + _require(len(set(names)) == 61, "SDK capability IDs are not unique") _require(all(isinstance(name, str) and name.startswith("echo.cert") for name in names), "SDK capability namespace drift") _require( all( diff --git a/scripts/register_certforge_caps.sql b/scripts/register_certforge_caps.sql index 80dbd57..61e0390 100644 --- a/scripts/register_certforge_caps.sql +++ b/scripts/register_certforge_caps.sql @@ -46,6 +46,13 @@ VALUES '{"type":"object","required":["run_id"],"properties":{"run_id":{"type":"string"}}}'::jsonb, 'certforge.read', 0, '{"X-Tenant-ID":"org-echo-sovereign","Authorization":"vault:certforge.desktop_admin_api_key"}'::jsonb, 15, 'active', 'unknown'), + -- New identity metadata route: activate only after admitted service rollout and authenticated smoke. + ('echo.certforge.identities', + 'Certification Forge: export reconciled canonical run identities after tenant authorization, digest verification, and metadata-only audit. Never reads evidence bytes.', + 'http', 'http://127.0.0.1:8309/v1/certifications/{run_id}/identities', 'GET', 'path', 'forge', + '{"type":"object","required":["command","run_id"],"properties":{"command":{"type":"string","minLength":1,"maxLength":64},"run_id":{"type":"string"}},"additionalProperties":false}'::jsonb, + 'certforge.read', 0, '{"X-Tenant-ID":"org-echo-sovereign","Authorization":"vault:certforge.desktop_admin_api_key"}'::jsonb, 15, 'active', 'unknown'), + ('echo.certforge.cancel', 'Certification Forge: cancel a certification run. Fail-closed — 409 once past the cancellable window; never re-opens a terminal run.', 'http', 'http://127.0.0.1:8309/v1/certifications/{run_id}/cancel', 'POST', 'path', 'forge', diff --git a/scripts/register_certforge_sdk_schemas.sql b/scripts/register_certforge_sdk_schemas.sql index fbfaae4..6b924f6 100644 --- a/scripts/register_certforge_sdk_schemas.sql +++ b/scripts/register_certforge_sdk_schemas.sql @@ -35,6 +35,7 @@ INSERT INTO certforge_sdk_contract(id, input_schema_json, output_schema_json) VA ('echo.certforge.governance.get', '{"additionalProperties":false,"properties":{"command":{"maxLength":64,"minLength":1,"type":"string"}},"required":["command"],"type":"object"}'::jsonb, '{"additionalProperties":true,"type":"object"}'::jsonb), ('echo.certforge.governance.update', '{"additionalProperties":false,"properties":{"command":{"maxLength":64,"minLength":1,"type":"string"},"config":{"additionalProperties":false,"properties":{"allowed_policy_ids":{"items":{"type":"string"},"type":"array"},"customer_managed_signing":{"type":"boolean"},"customer_signing_key_id":{"anyOf":[{"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$","type":"string"},{"type":"null"}]},"local_only_execution":{"type":"boolean"},"private_worker_only":{"type":"boolean"},"report_brand_name":{"anyOf":[{"type":"string"},{"type":"null"}]},"report_logo_url":{"anyOf":[{"type":"string"},{"type":"null"}]},"retention_days":{"type":"integer"}},"required":["allowed_policy_ids","retention_days","private_worker_only","report_brand_name","report_logo_url","customer_managed_signing","local_only_execution"],"type":"object"},"expected_version":{"minimum":1.0,"type":"integer"}},"required":["command","config","expected_version"],"type":"object"}'::jsonb, '{"additionalProperties":true,"type":"object"}'::jsonb), ('echo.certforge.health', '{"additionalProperties":false,"properties":{"command":{"maxLength":64,"minLength":1,"type":"string"}},"required":["command"],"type":"object"}'::jsonb, '{"additionalProperties":true,"type":"object"}'::jsonb), + ('echo.certforge.identities', '{"additionalProperties":false,"properties":{"command":{"maxLength":64,"minLength":1,"type":"string"},"run_id":{"type":"string"}},"required":["command","run_id"],"type":"object"}'::jsonb, '{"additionalProperties":false,"properties":{"environment_identity_digest":{"pattern":"^[0-9a-f]{64}$","type":"string"},"export_sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"},"identities":{"additionalProperties":false,"properties":{"environment":{"additionalProperties":false,"properties":{"adapter_set_sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"},"egress_policy_sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"},"harness_sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"},"model_route_sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"},"os_runtime_sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"},"policy_sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"},"prompt_set_sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"},"runner_image_sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"},"test_plan_sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"}},"required":["runner_image_sha256","adapter_set_sha256","test_plan_sha256","policy_sha256","harness_sha256","prompt_set_sha256","model_route_sha256","os_runtime_sha256","egress_policy_sha256"],"type":"object"},"target":{"additionalProperties":false,"properties":{"artifact_sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"},"canonical_ref":{"maxLength":2048,"minLength":1,"type":"string"},"configuration_sha256":{"anyOf":[{"pattern":"^[0-9a-f]{64}$","type":"string"},{"type":"null"}]},"dependency_sha256":{"anyOf":[{"pattern":"^[0-9a-f]{64}$","type":"string"},{"type":"null"}]},"source_commit":{"anyOf":[{"pattern":"^[0-9a-fA-F]{40}(?:[0-9a-fA-F]{24})?$","type":"string"},{"type":"null"}]},"target_type":{"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$","type":"string"},"tenant_id":{"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$","type":"string"}},"required":["tenant_id","target_type","canonical_ref","artifact_sha256","source_commit","dependency_sha256","configuration_sha256"],"type":"object"}},"required":["target","environment"],"type":"object"},"identities_sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"},"rule_manifest_digest":{"pattern":"^[0-9a-f]{64}$","type":"string"},"rule_manifest_id":{"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$","type":"string"},"run_id":{"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$","type":"string"},"schema_version":{"const":"certforge.canonical-identities.v1","type":"string"},"target_identity_digest":{"pattern":"^[0-9a-f]{64}$","type":"string"},"tenant_id":{"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$","type":"string"}},"required":["schema_version","run_id","tenant_id","target_identity_digest","environment_identity_digest","rule_manifest_id","rule_manifest_digest","identities","identities_sha256","export_sha256"],"type":"object"}'::jsonb), ('echo.certforge.list_runs', '{"additionalProperties":false,"properties":{"command":{"maxLength":64,"minLength":1,"type":"string"}},"required":["command"],"type":"object"}'::jsonb, '{"items":{"additionalProperties":true,"type":"object"},"type":"array"}'::jsonb), ('echo.certforge.members.activate', '{"additionalProperties":false,"properties":{"command":{"maxLength":64,"minLength":1,"type":"string"},"user_id":{"type":"string"}},"required":["command","user_id"],"type":"object"}'::jsonb, '{"type":"null"}'::jsonb), ('echo.certforge.members.deactivate', '{"additionalProperties":false,"properties":{"command":{"maxLength":64,"minLength":1,"type":"string"},"user_id":{"type":"string"}},"required":["command","user_id"],"type":"object"}'::jsonb, '{"type":"null"}'::jsonb), @@ -74,8 +75,8 @@ BEGIN FROM arcanum_sdk.sdk_capabilities WHERE id LIKE 'echo.certforge.%' OR id = 'echo.certification_forge.r5.run_negative_controls'; - IF live_count <> 60 THEN - RAISE EXCEPTION 'Certification Forge SDK surface drift: live %, contract 60', live_count; + IF live_count <> 61 THEN + RAISE EXCEPTION 'Certification Forge SDK surface drift: live %, contract 61', live_count; END IF; IF EXISTS ( SELECT 1 FROM certforge_sdk_contract c diff --git a/src/echo_certification_forge/identity_export.py b/src/echo_certification_forge/identity_export.py new file mode 100644 index 0000000..7b2d3bd --- /dev/null +++ b/src/echo_certification_forge/identity_export.py @@ -0,0 +1,162 @@ +"""Export reconciled stored identities without reading evidence or changing runs.""" +from __future__ import annotations + +import json +from dataclasses import fields +from typing import Annotated, Any, Literal +from urllib.parse import unquote, urlsplit + +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +from .canonical import require_identifier, sha256_json +from .models import EnvironmentIdentity, TargetIdentity + +_MAX_IDENTITY_BYTES = 16 * 1024 +_SCHEMA_VERSION = "certforge.canonical-identities.v1" +Digest = Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")] +Identifier = Annotated[str, Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")] +GitCommit = Annotated[str, Field(pattern=r"^[0-9a-fA-F]{40}(?:[0-9a-fA-F]{24})?$")] + + +class IdentityExportError(ValueError): + """A fixed public error code that never includes stored identity contents.""" + + +class ExportTarget(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + tenant_id: Identifier + target_type: Identifier + canonical_ref: str = Field(min_length=1, max_length=2048) + artifact_sha256: Digest + source_commit: GitCommit | None + dependency_sha256: Digest | None + configuration_sha256: Digest | None + + +class ExportEnvironment(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + runner_image_sha256: Digest + adapter_set_sha256: Digest + test_plan_sha256: Digest + policy_sha256: Digest + harness_sha256: Digest + prompt_set_sha256: Digest + model_route_sha256: Digest + os_runtime_sha256: Digest + egress_policy_sha256: Digest + + +class ExportIdentities(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + target: ExportTarget + environment: ExportEnvironment + + +class CanonicalIdentityExport(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + schema_version: Literal["certforge.canonical-identities.v1"] + run_id: Identifier + tenant_id: Identifier + target_identity_digest: Digest + environment_identity_digest: Digest + rule_manifest_id: Identifier + rule_manifest_digest: Digest + identities: ExportIdentities + identities_sha256: Digest + export_sha256: Digest + + +def _identity_json(raw: Any) -> dict[str, Any]: + def unique(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise IdentityExportError("identity_record_invalid") + result[key] = value + return result + + if not isinstance(raw, str) or len(raw.encode("utf-8")) > _MAX_IDENTITY_BYTES: + raise IdentityExportError("identity_record_invalid") + value = json.loads(raw, object_pairs_hook=unique) + if not isinstance(value, dict): + raise IdentityExportError("identity_record_invalid") + return value + + +def _reference_is_exportable(reference: str) -> None: + # Never redact and re-hash a canonical identity. Refuse credentials, query + # material, fragments, or controls instead of returning a changed identity. + decoded = reference + try: + for _ in range(4): + unquoted = unquote(decoded, errors="strict") + if unquoted == decoded: + break + decoded = unquoted + if unquote(decoded, errors="strict") != decoded: + raise IdentityExportError("identity_reference_not_exportable") + parsed = urlsplit(decoded) + except (ValueError, UnicodeError) as error: + raise IdentityExportError("identity_reference_not_exportable") from error + if ( + any(ord(character) < 32 or ord(character) == 127 for character in decoded) + or "?" in decoded + or "#" in decoded + or parsed.username is not None + or parsed.password is not None + or "%" in parsed.netloc + ): + raise IdentityExportError("identity_reference_not_exportable") + + +def export_canonical_identities(row: dict[str, Any], tenant_id: str) -> dict[str, Any]: + """Validate the stored objects and their commitments; do not infer any fields. + + The caller must first authenticate RUN_READ and obtain this row through the + tenant-scoped store. This pure projection neither authorizes access nor reads + artifact bytes. Successful HTTP export also requires an audited authorization + recheck in the service before any identities are returned. + """ + try: + target_data = _identity_json(row["target_identity_json"]) + environment_data = _identity_json(row["environment_identity_json"]) + if "declared_identity_digest" in target_data or "declared_identity_digest" in environment_data: + raise IdentityExportError("identity_not_reconciled") + if ( + set(target_data) != {field.name for field in fields(TargetIdentity)} + or set(environment_data) != {field.name for field in fields(EnvironmentIdentity)} + ): + raise IdentityExportError("identity_record_invalid") + if row["tenant_id"] != tenant_id or target_data["tenant_id"] != tenant_id: + raise IdentityExportError("identity_tenant_mismatch") + identities = ExportIdentities.model_validate({ + "target": target_data, "environment": environment_data, + }).model_dump(mode="json") + _reference_is_exportable(identities["target"]["canonical_ref"]) + target = TargetIdentity(**target_data) + environment = EnvironmentIdentity(**environment_data) + if ( + target.to_dict() != target_data or environment.to_dict() != environment_data + or target.identity_digest != row["target_identity_digest"] + or environment.identity_digest != row["environment_identity_digest"] + ): + raise IdentityExportError("identity_digest_mismatch") + require_identifier(row["run_id"], "run_id") + payload = { + "schema_version": _SCHEMA_VERSION, + "run_id": row["run_id"], + "tenant_id": tenant_id, + "target_identity_digest": target.identity_digest, + "environment_identity_digest": environment.identity_digest, + "rule_manifest_id": row["rule_manifest_id"], + "rule_manifest_digest": row["rule_manifest_digest"], + "identities": identities, + "identities_sha256": sha256_json(identities), + } + return CanonicalIdentityExport.model_validate({ + **payload, "export_sha256": sha256_json(payload), + }).model_dump(mode="json") + except IdentityExportError: + raise + except (KeyError, TypeError, ValueError, UnicodeError, ValidationError) as error: + raise IdentityExportError("identity_record_invalid") from error diff --git a/src/echo_certification_forge/service.py b/src/echo_certification_forge/service.py index 422cdf3..30e7698 100644 --- a/src/echo_certification_forge/service.py +++ b/src/echo_certification_forge/service.py @@ -34,6 +34,7 @@ project_run, submit, ) +from .identity_export import CanonicalIdentityExport, IdentityExportError, export_canonical_identities from .models import RunState, SignedVerdictEnvelope, VerdictLifecycleEvent from .openapi_auth import install_openapi_auth from .policy import RuleManifest @@ -611,6 +612,42 @@ def get_run( raise HTTPException(status_code=404, detail="run not found") from exc return project_run(context.store, row) + @app.get( + "/v1/certifications/{run_id}/identities", + response_model=CanonicalIdentityExport, + ) + def get_canonical_identities( + run_id: str, + x_tenant_id: str | None = Header(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + principal = subscriber_principal( + x_tenant_id, authorization, Permission.RUN_READ, + "certifications.identities.export", + ) + try: + row = context.store.get_run(run_id, principal.organization_id) + exported = export_canonical_identities(row, principal.organization_id) + # Reauthorizes the live principal and commits the metadata-only audit + # before returning any canonical object. Audit failure fails closed. + context.subscribers.audit_resource_event( + principal, + action="certifications.identities.exported", + resource_type="certification_run", + resource_id=run_id, + details={key: exported[key] for key in ( + "export_sha256", "identities_sha256", "target_identity_digest", + "environment_identity_digest", + )}, + ) + return exported + except KeyError as exc: + raise HTTPException(status_code=404, detail="run not found") from exc + except IdentityExportError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except SubscriberError as exc: + raise subscriber_error(exc) from exc + @app.post("/v1/certifications/{run_id}/cancel") def cancel_certification( run_id: str, diff --git a/tests/test_echo_sdk_manifest.py b/tests/test_echo_sdk_manifest.py index d3dfe13..9848804 100644 --- a/tests/test_echo_sdk_manifest.py +++ b/tests/test_echo_sdk_manifest.py @@ -13,7 +13,7 @@ def test_echo_sdk_manifest_matches_the_authoritative_contract() -> None: contract = json.loads(contract_path.read_text(encoding="utf-8")) assert manifest["version"] == 1 - assert len(manifest["capabilities"]) == contract["capability_count"] == 60 + assert len(manifest["capabilities"]) == contract["capability_count"] == 61 assert set(manifest["capabilities"]) == set(contract["capabilities"]) assert len(manifest["capabilities"]) == len(set(manifest["capabilities"])) assert all(capability.startswith("echo.") for capability in manifest["capabilities"]) diff --git a/tests/test_identity_export.py b/tests/test_identity_export.py new file mode 100644 index 0000000..d040b4d --- /dev/null +++ b/tests/test_identity_export.py @@ -0,0 +1,227 @@ +"""Canonical identity export boundaries over real local tenant/store state.""" +from __future__ import annotations + +import json +import sqlite3 +from dataclasses import replace +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from echo_certification_forge.canonical import canonical_json, sha256_json +from echo_certification_forge.evidence import EvidenceStore +from echo_certification_forge.identity_export import ( + IdentityExportError, + export_canonical_identities, +) +from echo_certification_forge.service import ServiceContext, create_app +from echo_certification_forge.signing import TrustedPublicKeyRegistry +from echo_certification_forge.subscriber import ( + Permission, + SubscriberGovernance, + SubscriberPolicy, +) + + +@pytest.fixture +def export_stack(tmp_path, manifest, target, environment): + store = EvidenceStore(tmp_path / "store.sqlite3", tmp_path / "evidence") + policy = SubscriberPolicy.load( + Path(__file__).parents[1] / "policies" / "subscriber-governance.v1.json" + ) + governance = SubscriberGovernance( + store.db_path, policy, b"synthetic-export-test-pepper-32-bytes-minimum" + ) + org = governance.provision_organization( + slug="export-alpha", display_name="Export Alpha", + owner_email="owner@export-alpha.example", owner_display_name="Owner", + plan_code="developer", + ) + target = replace(target, tenant_id=org.organization_id, source_commit="a" * 40, + canonical_ref="https://github.com/example/project@" + "a" * 40) + store.register_run("run-export", target, environment, manifest.manifest_id, manifest.digest) + client = TestClient(create_app(ServiceContext( + store, manifest, TrustedPublicKeyRegistry.empty(), governance, + ))) + headers = {"X-Tenant-ID": org.organization_id, "Authorization": f"Bearer {org.bootstrap_api_key}"} + return store, governance, org, target, environment, client, headers + + +def test_export_preserves_exact_objects_and_checksums_without_run_changes(export_stack): + store, governance, org, target, environment, client, headers = export_stack + before = store.get_run("run-export", org.organization_id) + first = client.get("/v1/certifications/run-export/identities", headers=headers) + second = client.get("/v1/certifications/run-export/identities", headers=headers) + assert first.status_code == second.status_code == 200 + exported = first.json() + assert exported == second.json() + assert exported["identities"] == {"target": target.to_dict(), "environment": environment.to_dict()} + assert exported["identities_sha256"] == sha256_json(exported["identities"]) + assert exported["export_sha256"] == sha256_json({k: v for k, v in exported.items() if k != "export_sha256"}) + assert exported["target_identity_digest"] == target.identity_digest + assert exported["environment_identity_digest"] == environment.identity_digest + assert first.headers["cache-control"] == "no-store" + assert store.get_run("run-export", org.organization_id) == before + assert store.list_evidence("run-export", org.organization_id) == [] + principal = governance.authenticate(org.bootstrap_api_key, tenant_hint=org.organization_id, + permission=Permission.AUDIT_READ, action="test.audit") + events = [item for item in governance.list_audit_events(principal) + if item["action"] == "certifications.identities.exported"] + assert len(events) == 2 + assert set(events[0]["details"]) == {"export_sha256", "identities_sha256", "target_identity_digest", "environment_identity_digest"} + assert target.canonical_ref not in json.dumps(events) + assert governance.verify_audit(principal)["valid"] is True + + +def test_export_never_reads_evidence_artifacts(export_stack, monkeypatch): + store, _, _, _, _, client, headers = export_stack + def forbidden(*_args, **_kwargs): + raise AssertionError("identity export must not read evidence bytes") + monkeypatch.setattr(store, "read_evidence_artifact", forbidden) + assert client.get("/v1/certifications/run-export/identities", headers=headers).status_code == 200 + assert client.get("/v1/subscriber/certifications/run-export/evidence/denied-artifact", headers=headers).status_code == 403 + + +@pytest.mark.parametrize("headers", [{}, {"X-Tenant-ID": "export-alpha"}]) +def test_identity_export_rejects_unauthenticated_headers(export_stack, headers): + client = export_stack[5] + response = client.get("/v1/certifications/run-export/identities", headers=headers) + assert response.status_code == 401 + assert "identities" not in response.json() + + +def test_identity_export_requires_run_read_permission(export_stack): + _, governance, org, _, _, client, _ = export_stack + owner = governance.authenticate(org.bootstrap_api_key, tenant_hint=org.organization_id, + permission=Permission.API_KEY_MANAGE, action="test.owner") + token = governance.create_api_key(owner, name="projects-only", scopes={Permission.PROJECT_READ}, + expires_at=datetime.now(UTC) + timedelta(days=1)) + response = client.get("/v1/certifications/run-export/identities", headers={ + "X-Tenant-ID": org.organization_id, "Authorization": f"Bearer {token}"}) + assert response.status_code == 403 + assert "identities" not in response.json() + + +def test_cross_tenant_export_is_indistinguishable_from_unknown_run(export_stack): + _, governance, _, _, _, client, _ = export_stack + other = governance.provision_organization(slug="export-beta", display_name="Beta", + owner_email="owner@export-beta.example", owner_display_name="Owner", plan_code="developer") + headers = {"X-Tenant-ID": other.organization_id, "Authorization": f"Bearer {other.bootstrap_api_key}"} + foreign = client.get("/v1/certifications/run-export/identities", headers=headers) + missing = client.get("/v1/certifications/absent-run/identities", headers=headers) + assert foreign.status_code == missing.status_code == 404 + assert foreign.json() == missing.json() + + +def test_governance_disabled_cannot_export_by_tenant_header(store, manifest, target, environment): + store.register_run("run-export", target, environment, manifest.manifest_id, manifest.digest) + client = TestClient(create_app(ServiceContext(store, manifest, TrustedPublicKeyRegistry.empty()))) + response = client.get("/v1/certifications/run-export/identities", headers={"X-Tenant-ID": target.tenant_id}) + assert response.status_code == 503 + assert response.json()["detail"] == "subscriber_governance_disabled" + + +def test_audit_failure_prevents_export_response(export_stack, monkeypatch): + _, governance, _, _, _, client, headers = export_stack + def failed_audit(*_args, **_kwargs): + raise sqlite3.OperationalError("synthetic audit outage") + monkeypatch.setattr(governance, "audit_resource_event", failed_audit) + response = client.get("/v1/certifications/run-export/identities", headers=headers) + assert response.status_code == 503 + assert "identities" not in response.json() + assert "synthetic audit outage" not in response.text + + +def test_key_revoked_after_initial_auth_is_rejected_before_export(export_stack, monkeypatch): + store, governance, org, _, _, client, headers = export_stack + owner = governance.authenticate(org.bootstrap_api_key, tenant_hint=org.organization_id, + permission=Permission.API_KEY_MANAGE, action="test.owner") + original = store.get_run + def revoke_after_read(*args, **kwargs): + row = original(*args, **kwargs) + governance.revoke_api_key(owner, owner.key_id) + return row + monkeypatch.setattr(store, "get_run", revoke_after_read) + response = client.get("/v1/certifications/run-export/identities", headers=headers) + assert response.status_code == 401 + assert "identities" not in response.json() + assert response.json()["detail"] == "api_key_inactive" + + +@pytest.mark.parametrize("field", ["target_identity_digest", "environment_identity_digest"]) +def test_digest_mismatch_fails_without_data_disclosure(export_stack, field): + store, _, org, _, _, client, headers = export_stack + with sqlite3.connect(store.db_path) as connection: + connection.execute(f"UPDATE runs SET {field} = ? WHERE run_id = ?", ("0" * 64, "run-export")) + response = client.get("/v1/certifications/run-export/identities", headers=headers) + assert response.status_code == 409 + assert response.json()["detail"] == "identity_digest_mismatch" + assert "identities" not in response.json() + + +@pytest.mark.parametrize("change,code", [ + ({"declared_identity_digest": "0" * 64}, "identity_not_reconciled"), + ({"extra": "synthetic-secret-value"}, "identity_record_invalid"), + ({"tenant_id": "wrong-tenant"}, "identity_tenant_mismatch"), + ({"source_commit": "synthetic-secret-value"}, "identity_record_invalid"), +]) +def test_invalid_stored_identity_fails_closed(export_stack, change, code): + store, _, org, _, _, _, _ = export_stack + row = store.get_run("run-export", org.organization_id) + data = json.loads(row["target_identity_json"]) + data.update(change) + row["target_identity_json"] = canonical_json(data) + with pytest.raises(IdentityExportError, match=code): + export_canonical_identities(row, org.organization_id) + + +@pytest.mark.parametrize("reference", [ + "https://user:synthetic-password@example.invalid/repo", + "https://example.invalid/repo?access_token=synthetic-token", + "https://example.invalid/repo#synthetic-token", + "https://example.invalid/repo\nAuthorization: synthetic-token", + "https://user%253Asynthetic-password%2540example.invalid/repo", + "https://example.invalid/repo%253Faccess_token%253Dsynthetic-token", + "https://example.invalid/repo%2523synthetic-token", +]) +def test_sensitive_canonical_references_are_not_exported(export_stack, reference): + store, _, org, _, _, _, _ = export_stack + row = store.get_run("run-export", org.organization_id) + data = json.loads(row["target_identity_json"]) + data["canonical_ref"] = reference + row["target_identity_json"] = canonical_json(data) + row["target_identity_digest"] = sha256_json(data) + with pytest.raises(IdentityExportError, match="identity_reference_not_exportable"): + export_canonical_identities(row, org.organization_id) + + +def test_duplicate_keys_and_unbounded_identity_records_are_rejected(export_stack): + store, _, org, _, _, _, _ = export_stack + row = store.get_run("run-export", org.organization_id) + for raw in ('{"tenant_id":"first","tenant_id":"second"}', '{"oversized":"' + "x" * 20000 + '"}'): + with pytest.raises(IdentityExportError, match="identity_record_invalid"): + export_canonical_identities({**row, "target_identity_json": raw}, org.organization_id) + + +def test_openapi_declares_protected_complete_export_schema(export_stack): + client = export_stack[5] + schema = client.get("/openapi.json").json() + operation = schema["paths"]["/v1/certifications/{run_id}/identities"]["get"] + assert operation["security"] == [{"BearerAuth": ["tenant"]}] + assert "401" in operation["responses"] and "403" in operation["responses"] + response = operation["responses"]["200"]["content"]["application/json"]["schema"] + model = schema["components"]["schemas"][response["$ref"].split("/")[-1]] + assert model["additionalProperties"] is False + assert {"identities", "identities_sha256", "export_sha256"} <= set(model["required"]) + + +def test_sdk_command_metadata_does_not_replace_bearer_or_tenant(export_stack): + client, headers = export_stack[5:] + response = client.get("/v1/certifications/run-export/identities", + params={"command": "identities"}, headers=headers) + assert response.status_code == 200 + denied = client.get("/v1/certifications/run-export/identities", + params={"command": "identities", "tenant_id": export_stack[2].organization_id}) + assert denied.status_code == 401 diff --git a/tests/test_sdk_capability_contract.py b/tests/test_sdk_capability_contract.py index adcb6ef..08c5d9f 100644 --- a/tests/test_sdk_capability_contract.py +++ b/tests/test_sdk_capability_contract.py @@ -32,7 +32,7 @@ def test_generated_sdk_contract_is_current() -> None: def test_every_certforge_capability_has_command_and_output_schema() -> None: contract = json.loads(CONTRACT.read_text(encoding="utf-8")) capabilities = contract["capabilities"] - assert contract["capability_count"] == 60 == len(capabilities) + assert contract["capability_count"] == 61 == len(capabilities) for capability, item in capabilities.items(): input_schema = item["input_schema"] output_schema = item["output_schema"]