diff --git a/docs/eval_results/PRIVATE_EXPORT_CONTRACT.md b/docs/eval_results/PRIVATE_EXPORT_CONTRACT.md index 10faafa..29f9568 100644 --- a/docs/eval_results/PRIVATE_EXPORT_CONTRACT.md +++ b/docs/eval_results/PRIVATE_EXPORT_CONTRACT.md @@ -16,16 +16,15 @@ below are blank. ## Why an approval is needed at all, and what it fixes The certificate already carries a `retention` block, and the importer already -validates its shape: every digest is well formed, the mode is Object Lock -`COMPLIANCE`, four verification booleans are true, the period is between one and -ten years, and the chronology runs `acceptance_verified_at <= retained_at < -retention_until`. +validates its shape: every digest is well formed, the retention commit is an +exact 40-character hash, four verification booleans are true, and the chronology +runs `acceptance_verified_at <= retained_at` and no later than now. Shape is not identity. Today the certificate supplies its own `storage_identity_sha256`, `kms_key_identity_sha256`, and `uploader_identity_sha256`, and the importer checks only that they look like -digests. A certificate that named some other bucket, some other key, and some -other uploader would pass every check in the file. +digests. A certificate that named some other repository, some other key, and +some other uploader would pass every check in the file. The approval closes that. It supplies the expected digests from outside the evidence, the way `--trusted-admission-signers` and @@ -33,39 +32,67 @@ evidence, the way `--trusted-admission-signers` and and the Cloud commit. After approval the evidence can no longer select its own destination, its own key, or its own uploader. +## What the store is, and why it is not S3 + +The retained evidence is a signed git commit in a private repository, not an +object in an S3 bucket with Object Lock. That choice was made deliberately, +before the first write, when changing it was still free. + +The reasoning, kept here so nobody has to reconstruct it: + +- The Cloud writer already encrypts the envelope client-side with an AES-256-GCM + data key wrapped by KMS. The store holds ciphertext either way, so GitHub + cannot read the evidence and confidentiality does not depend on the medium. +- What makes the claim credible to a reader is the signature chain plus the + public Rekor entry, not the storage. Object Lock stops neither the suppression + of an unpublished result nor the alteration of a published one; the signature + and the transparency log already do both. +- Git is content-addressed. A commit hash covers the whole tree and history, so + it binds the relationships between certificate, admission and campaign. A + per-object lock binds only each blob. +- Object Lock does buy one thing git does not: if a third party is granted + direct read access to the store, they can trust nothing was pruned before they + looked. No buyer has asked for that. If one does, this decision should be + revisited before the next write rather than patched around. + +Object Lock in COMPLIANCE mode is also irreversible for its full term. Choosing +it commits storage for years and cannot be undone by anyone, including the +account root. + ## The bindings -The approval fixes seven destination fields plus the uploader, the importer, and +The approval fixes six destination fields plus the uploader, the importer, and the authority. **Every value is blank in this repository.** They are deployment facts, and only the account owner can supply them. | Contract field | What it fixes | Value | | --- | --- | --- | -| `destination.account_id` | the AWS account holding the evidence | **TO BE SUPPLIED** | -| `destination.region` | the region | **TO BE SUPPLIED** | -| `destination.bucket` | the S3 bucket | **TO BE SUPPLIED** | -| `destination.object_prefix` | the prefix inside it | **TO BE SUPPLIED** | -| `destination.kms_key_arn` | the encryption key | **TO BE SUPPLIED** | -| `destination.retention_mode` | Object Lock mode | `COMPLIANCE`, fixed | -| `destination.retention_days` | the immutable period | default 2555, inside 365 to 3650 | +| `destination.account_id` | the AWS account holding the KMS key | **TO BE SUPPLIED** | +| `destination.region` | the region of that key | **TO BE SUPPLIED** | +| `destination.repository` | the private repository holding the ciphertext | **TO BE SUPPLIED** | +| `destination.ref` | the branch it lands on | `refs/heads/main` | +| `destination.path_prefix` | the path inside it | **TO BE SUPPLIED** | +| `destination.kms_key_arn` | the key wrapping each data key | **TO BE SUPPLIED** | +| `destination.retention_commitment_days` | how long you commit to keep it | default 2555 | | `uploader_arn` | the principal allowed to write | **TO BE SUPPLIED** | | `importer_workflow_ref` | the one workflow allowed to import | pre-filled | | `approval_authority`, `approved_at` | who approved, and when | **TO BE SUPPLIED** | -These are the same seven destination fields the Cloud retention writer hashes, -in the same order, so the approval digest derived here is the one that writer -already requires. +`retention_commitment_days` is a commitment the mechanism records and does not +enforce. A git commit has no expiry. Enforcing a number nothing can hold would +be theatre, so the verifier does not police it, and this document says so +plainly rather than leaving a reader to assume otherwise. Two things the certificate carries are not fixed by the approval, because they -differ per export: the ciphertext, envelope, and candidate digests, and the -object and locator version digests. The approval fixes where evidence may go and -who may put it there, not the content of any one export. +differ per export: the ciphertext, envelope and candidate digests, and the +commit and locator versions. The approval fixes where evidence may go and who +may put it there, not the content of any one export. ## Where the filled copy lives -This repository is public. The filled contract names an account, a bucket, a -prefix, a key ARN, and an uploader ARN, which are deployment-derived facts and -fall under the source-availability boundary. +This repository is public. The filled contract names an account, a repository, a +path prefix, a key ARN, and an uploader ARN, which are deployment-derived facts +and fall under the source-availability boundary. So: the shape stays here, the values do not. The approved instance belongs in `openadapt-internal`, and this repository receives only its digest, supplied to @@ -104,7 +131,7 @@ values from the contract, so one approval governs both repositories: | Digest | Domain | Preimage | | --- | --- | --- | -| `storage_identity_sha256` | `retention store` | `destination.bucket` | +| `storage_identity_sha256` | `retention store` | `destination.repository` | | `kms_key_identity_sha256` | `retention KMS key` | `destination.kms_key_arn` | | `uploader_identity_sha256` | `AWS retention uploader` | `uploader_arn` | | `destination_approval_sha256` | `Execute acceptance retention destination` | canonical JSON of all seven `destination` fields | @@ -120,11 +147,12 @@ contract produces it. The contract carries values and never a digest, so an approval cannot assert a hash whose input nobody can see. `verify_retention_against_contract` compares -all three identities against the certificate and refuses a retention period -shorter than the approved one. A longer lock is allowed: the approval sets a -floor. `verify_importer_identity` requires `GITHUB_WORKFLOW_REF` to equal the +all three identities against the certificate. `verify_importer_identity` requires `GITHUB_WORKFLOW_REF` to equal the approved ref exactly. The destination is checked the way the Cloud writer checks it, including that the KMS key lives in the approved account and region. +The certificate must also carry an exact 40-character retention commit and prove +that the push, the commit read-back, and the transparency-log entry were all +verified. ## Open items that block approval diff --git a/docs/eval_results/private-export-contract.template.json b/docs/eval_results/private-export-contract.template.json index fa318fc..f58b912 100644 --- a/docs/eval_results/private-export-contract.template.json +++ b/docs/eval_results/private-export-contract.template.json @@ -5,10 +5,13 @@ "_README key, and approve the result there. This public repository keeps", "the shape; the filled instance and its values stay private.", "", - "The destination fields are the same seven the Cloud retention writer", - "hashes in scripts/retain-execute-private-evidence.mjs. Its approval digest", - "EXECUTE_ACCEPTANCE_RETENTION_DESTINATION_APPROVAL_SHA256 is derived from", - "exactly these values, so one approval governs both repositories.", + "The retained evidence is a signed git commit, not an S3 object. The Cloud", + "writer still encrypts the envelope client-side with an AES-256-GCM data", + "key wrapped by KMS, so the repository holds ciphertext and GitHub cannot", + "read it. The KMS key is the only part that still costs money.", + "", + "retention_commitment_days is a commitment this mechanism records and does", + "not enforce. A git commit carries no expiry. Nothing here pretends it does.", "", "Validate a filled copy with:", " python scripts/import_production_acceptance.py --private-export-contract ...", @@ -19,11 +22,11 @@ "destination": { "account_id": "FILL_AWS_ACCOUNT_ID", "region": "FILL_AWS_REGION", - "bucket": "FILL_RETENTION_BUCKET", - "object_prefix": "FILL_RETENTION_OBJECT_PREFIX", - "kms_key_arn": "FILL_RETENTION_KMS_KEY_ARN", - "retention_mode": "COMPLIANCE", - "retention_days": 2555 + "repository": "FILL_EVIDENCE_REPOSITORY", + "ref": "refs/heads/main", + "path_prefix": "FILL_EVIDENCE_PATH_PREFIX", + "kms_key_arn": "FILL_DATA_KEY_KMS_ARN", + "retention_commitment_days": 2555 }, "uploader_arn": "FILL_UPLOADER_ROLE_OR_USER_ARN", "importer_workflow_ref": "OpenAdaptAI/openadapt-evals/.github/workflows/import-production-acceptance.yml@refs/heads/main", diff --git a/scripts/import_production_acceptance.py b/scripts/import_production_acceptance.py index 4cc0c95..fceb2ff 100644 --- a/scripts/import_production_acceptance.py +++ b/scripts/import_production_acceptance.py @@ -85,6 +85,7 @@ ) GITHUB_OIDC_ISSUER = "https://token.actions.githubusercontent.com" RETENTION_PROVENANCE_ROUTE = "sigstore-public-good-slsa-provenance-v1" +RETENTION_MEDIUM = "signed-git-commit-v1" PRIVATE_EXPORT_CONTRACT_SCHEMA = "openadapt.private-export-contract/v1" GITHUB_HOSTNAME = "github.com" PUBLIC_TRANSPARENCY_LOG = "https://rekor.sigstore.dev" @@ -234,16 +235,15 @@ "private_envelope_sha256", "store_attestation_sha256", "storage_identity_sha256", - "object_version_sha256", + "retention_commit", "private_locator_version_sha256", "kms_key_identity_sha256", "uploader_identity_sha256", - "retention_mode", - "retention_until", + "transparency_log_entry_sha256", "retained_at", - "upload_verified", - "head_verified", - "object_lock_verified", + "push_verified", + "commit_verified", + "transparency_logged", "private_locator_recorded", "acceptance_verified_at", "provenance_attestation", @@ -826,7 +826,7 @@ def production_acceptance_policy() -> dict[str, Any]: "minimum_trials_per_condition": 3, "excluded_trial_count": 0, "zero_failure_counts_required": sorted(_PRODUCTION_FAILURES), - "required_retention_mode": "COMPLIANCE", + "required_retention_medium": RETENTION_MEDIUM, "required_retention_provenance": RETENTION_PROVENANCE_ROUTE, "minimum_retention_days": 365, "maximum_retention_days": 3650, @@ -874,11 +874,11 @@ def file_sha256(path: Path) -> str: _RETENTION_DESTINATION_KEYS = { "account_id", "region", - "bucket", - "object_prefix", + "repository", + "ref", + "path_prefix", "kms_key_arn", - "retention_mode", - "retention_days", + "retention_commitment_days", } _PRIVATE_EXPORT_CONTRACT_KEYS = { "schema_version", @@ -893,8 +893,9 @@ def file_sha256(path: Path) -> str: ) _AWS_ACCOUNT_ID = re.compile(r"^[0-9]{12}$") _AWS_REGION = re.compile(r"^[a-z]{2}(-gov)?-[a-z]+-[0-9]$") -_S3_BUCKET = re.compile(r"^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$") -_S3_PREFIX = re.compile(r"^[A-Za-z0-9._/-]{1,256}$") +_REPOSITORY = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$") +_GIT_REF = re.compile(r"^refs/heads/[A-Za-z0-9._/-]+$") +_PATH_PREFIX = re.compile(r"^[A-Za-z0-9._/-]{1,256}$") _KMS_KEY_ARN = re.compile( r"^arn:aws:kms:([a-z0-9-]+):([0-9]{12}):key/[A-Za-z0-9-]+$" ) @@ -938,23 +939,26 @@ def _validate_retention_destination(value: Any) -> dict[str, Any]: destination = _closed(value, _RETENTION_DESTINATION_KEYS, "retention destination") account_id = _nonempty(destination["account_id"], "retention destination account_id") region = _nonempty(destination["region"], "retention destination region") - bucket = _nonempty(destination["bucket"], "retention destination bucket") - prefix = _nonempty(destination["object_prefix"], "retention destination object_prefix") + repository = _nonempty(destination["repository"], "retention destination repository") + ref = _nonempty(destination["ref"], "retention destination ref") + prefix = _nonempty(destination["path_prefix"], "retention destination path_prefix") key_arn = _nonempty(destination["kms_key_arn"], "retention destination kms_key_arn") if _AWS_ACCOUNT_ID.fullmatch(account_id) is None: raise AcceptanceError("retention destination account is not an AWS account ID") if _AWS_REGION.fullmatch(region) is None: raise AcceptanceError("retention destination region is invalid") - if _S3_BUCKET.fullmatch(bucket) is None: - raise AcceptanceError("retention destination bucket is invalid") + if _REPOSITORY.fullmatch(repository) is None: + raise AcceptanceError("retention destination repository is invalid") + if _GIT_REF.fullmatch(ref) is None: + raise AcceptanceError("retention destination ref is invalid") if ( - _S3_PREFIX.fullmatch(prefix) is None + _PATH_PREFIX.fullmatch(prefix) is None or prefix.startswith("/") or prefix.endswith("/") or ".." in prefix or "//" in prefix ): - raise AcceptanceError("retention destination object prefix is invalid") + raise AcceptanceError("retention destination path prefix is invalid") key_match = _KMS_KEY_ARN.fullmatch(key_arn) if key_match is None: raise AcceptanceError("retention destination KMS key ARN is invalid") @@ -962,14 +966,14 @@ def _validate_retention_destination(value: Any) -> dict[str, Any]: raise AcceptanceError( "retention destination KMS key is outside the approved account or region" ) - if destination["retention_mode"] != "COMPLIANCE": - raise AcceptanceError("retention destination mode is not Object Lock COMPLIANCE") - days = destination["retention_days"] + days = destination["retention_commitment_days"] if not isinstance(days, int) or isinstance(days, bool): - raise AcceptanceError("retention destination retention_days must be an integer") + raise AcceptanceError( + "retention destination retention_commitment_days must be an integer" + ) policy = production_acceptance_policy() if not policy["minimum_retention_days"] <= days <= policy["maximum_retention_days"]: - raise AcceptanceError("retention destination retention period is outside policy") + raise AcceptanceError("retention destination retention commitment is outside policy") return dict(destination) @@ -1002,7 +1006,7 @@ def validate_private_export_contract(contract: Any) -> dict[str, Any]: "destination_approval_sha256": retention_destination_approval_sha256(destination), "storage_identity_sha256": retention_binding_sha256( "retention store", - destination["bucket"], + destination["repository"], ), "kms_key_identity_sha256": retention_binding_sha256( "retention KMS key", @@ -1013,7 +1017,7 @@ def validate_private_export_contract(contract: Any) -> dict[str, Any]: uploader_arn, ), "importer_workflow_ref": workflow_ref, - "retention_days": destination["retention_days"], + "retention_commitment_days": destination["retention_commitment_days"], "approval_authority": document["approval_authority"], "approved_at": approved_at, "contract_sha256": canonical_sha256(document), @@ -1044,8 +1048,13 @@ def verify_retention_against_contract( ) -> None: """Refuse unless the retained evidence went where the approval says. - Without this the certificate names its own destination, its own key, and its + Without this the certificate names its own repository, its own key, and its own uploader, and the importer only checks that those look like digests. + + There is no period check here, and that is deliberate rather than an + omission. A git commit carries no expiry, so retention_commitment_days is a + commitment this mechanism records and does not enforce. Enforcing a number + nothing can hold would be theatre. """ for key in ( @@ -1055,13 +1064,8 @@ def verify_retention_against_contract( ): if retention.get(key) != contract[key]: raise AcceptanceError(f"retained evidence {key} is not the approved identity") - retained_at = _timestamp(retention["retained_at"], "certificate retention retained_at") - expires_at = _timestamp( - retention["retention_until"], - "certificate retention retention_until", - ) - if expires_at - retained_at < timedelta(days=contract["retention_days"]): - raise AcceptanceError("retained evidence period is shorter than the approved contract") + + def opaque_binding_sha256(domain: str, value: str) -> str: payload = f"OpenAdapt acceptance {domain} v1\0".encode("utf-8") + value.encode( "utf-8" @@ -1938,22 +1942,25 @@ def _validate_certificate( "private_envelope_sha256", "store_attestation_sha256", "storage_identity_sha256", - "object_version_sha256", "private_locator_version_sha256", "kms_key_identity_sha256", "uploader_identity_sha256", + "transparency_log_entry_sha256", ): _digest(retention[key], f"certificate retention {key}") if not isinstance(retention["receipt_id"], str) or re.fullmatch( r"retention:[a-f0-9]{32}", retention["receipt_id"] ) is None: raise AcceptanceError("certificate retention receipt ID is invalid") - if retention["retention_mode"] != "COMPLIANCE": - raise AcceptanceError("certificate retention mode is not Object Lock COMPLIANCE") + if ( + not isinstance(retention["retention_commit"], str) + or _HEX_40.fullmatch(retention["retention_commit"]) is None + ): + raise AcceptanceError("certificate retention commit is not exact") for key in ( - "upload_verified", - "head_verified", - "object_lock_verified", + "push_verified", + "commit_verified", + "transparency_logged", "private_locator_recorded", ): if retention[key] is not True: @@ -1965,21 +1972,12 @@ def _validate_certificate( "certificate retention acceptance_verified_at", ) retained_at = _timestamp(retention["retained_at"], "certificate retention retained_at") - expires_at = _timestamp( - retention["retention_until"], - "certificate retention retention_until", - ) - retention_period = expires_at - retained_at - if not timedelta(days=365) <= retention_period <= timedelta(days=3650): - raise AcceptanceError("certificate Object Lock retention period is outside policy") - if not acceptance_verified_at <= retained_at < expires_at: + if not acceptance_verified_at <= retained_at: raise AcceptanceError("certificate retention chronology is invalid") if now.tzinfo is None: raise AcceptanceError("import time must include a timezone") - if now.astimezone(timezone.utc) >= expires_at: - raise AcceptanceError( - "certificate Object Lock retention has expired" - ) + if retained_at > now.astimezone(timezone.utc): + raise AcceptanceError("certificate retention is dated in the future") return { "product": {"cloud": dict(cloud), "flow": dict(flow), "managed_runtime": dict(runtime)}, @@ -3624,22 +3622,25 @@ def _validated_manifest_source(value: Mapping[str, Any]) -> dict[str, Any]: "private_envelope_sha256", "store_attestation_sha256", "storage_identity_sha256", - "object_version_sha256", "private_locator_version_sha256", "kms_key_identity_sha256", "uploader_identity_sha256", + "transparency_log_entry_sha256", ): _digest(retention[key], f"production acceptance source retention {key}") for key in ( - "upload_verified", - "head_verified", - "object_lock_verified", + "push_verified", + "commit_verified", + "transparency_logged", "private_locator_recorded", ): if retention[key] is not True: raise AcceptanceError(f"production acceptance source retention {key} is false") - if retention["retention_mode"] != "COMPLIANCE": - raise AcceptanceError("production acceptance source retention is not COMPLIANCE") + if ( + not isinstance(retention["retention_commit"], str) + or _HEX_40.fullmatch(retention["retention_commit"]) is None + ): + raise AcceptanceError("production acceptance source retention commit is not exact") if not isinstance(retention["receipt_id"], str) or re.fullmatch( r"retention:[a-f0-9]{32}", retention["receipt_id"] ) is None: @@ -3656,16 +3657,7 @@ def _validated_manifest_source(value: Mapping[str, Any]) -> dict[str, Any]: retention["retained_at"], "production acceptance source retention retained_at", ) - retention_until = _timestamp( - retention["retention_until"], - "production acceptance source retention retention_until", - ) - retention_period = retention_until - retained_at - if not timedelta(days=365) <= retention_period <= timedelta(days=3650): - raise AcceptanceError( - "production acceptance source retention period is outside policy" - ) - if not acceptance_verified_at <= retained_at < retention_until: + if not acceptance_verified_at <= retained_at: raise AcceptanceError("production acceptance source retention chronology is invalid") return source diff --git a/tests/fixtures/production_acceptance/live-certificate.json b/tests/fixtures/production_acceptance/live-certificate.json index d668f19..c29a00e 100644 --- a/tests/fixtures/production_acceptance/live-certificate.json +++ b/tests/fixtures/production_acceptance/live-certificate.json @@ -96,21 +96,20 @@ "acceptance_verified_at": "2026-08-18T12:00:00.000Z", "candidate_sha256": "sha256:6262626262626262626262626262626262626262626262626262626262626262", "ciphertext_sha256": "sha256:6161616161616161616161616161616161616161616161616161616161616161", - "head_verified": true, + "commit_verified": true, "kms_key_identity_sha256": "sha256:6868686868686868686868686868686868686868686868686868686868686868", - "object_lock_verified": true, - "object_version_sha256": "sha256:6666666666666666666666666666666666666666666666666666666666666666", "private_envelope_sha256": "sha256:6363636363636363636363636363636363636363636363636363636363636363", "private_locator_recorded": true, "private_locator_version_sha256": "sha256:6767676767676767676767676767676767676767676767676767676767676767", "provenance_attestation": "sigstore-public-good-slsa-provenance-v1", + "push_verified": true, "receipt_id": "retention:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "retained_at": "2026-08-18T12:01:00.000Z", - "retention_mode": "COMPLIANCE", - "retention_until": "2027-08-18T12:01:00.000Z", + "retention_commit": "ffffffffffffffffffffffffffffffffffffffff", "storage_identity_sha256": "sha256:6565656565656565656565656565656565656565656565656565656565656565", "store_attestation_sha256": "sha256:6464646464646464646464646464646464646464646464646464646464646464", - "upload_verified": true, + "transparency_log_entry_sha256": "sha256:6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a", + "transparency_logged": true, "uploader_identity_sha256": "sha256:6969696969696969696969696969696969696969696969696969696969696969" }, "schema_version": "openadapt.execute-live-acceptance-record/v2", diff --git a/tests/test_import_production_acceptance.py b/tests/test_import_production_acceptance.py index 5e9cbf5..ef635e4 100644 --- a/tests/test_import_production_acceptance.py +++ b/tests/test_import_production_acceptance.py @@ -966,11 +966,27 @@ def test_rejects_expired_object_lock_retention() -> None: _derive(now=expired) -def test_rejects_noncanonical_or_inconsistent_retention() -> None: +def test_rejects_retention_dated_before_acceptance() -> None: certificate = _certificate() - certificate["retention"]["retention_until"] = "2026-08-19T12:01:00.000Z" + certificate["retention"]["retained_at"] = "2026-08-18T11:59:59.000Z" - with pytest.raises(MODULE.AcceptanceError, match="outside policy"): + with pytest.raises(MODULE.AcceptanceError, match="chronology is invalid"): + _derive(certificate) + + +def test_rejects_retention_dated_in_the_future() -> None: + certificate = _certificate() + certificate["retention"]["retained_at"] = "2099-01-01T00:00:00.000Z" + + with pytest.raises(MODULE.AcceptanceError, match="dated in the future"): + _derive(certificate) + + +def test_rejects_an_inexact_retention_commit() -> None: + certificate = _certificate() + certificate["retention"]["retention_commit"] = "f" * 39 + + with pytest.raises(MODULE.AcceptanceError, match="retention commit is not exact"): _derive(certificate) @@ -2465,7 +2481,7 @@ def test_production_acceptance_target_scope_map_is_closed() -> None: "openadapt": "qualified_workflow_launcher_release", } assert MODULE.production_acceptance_policy_sha256() == ( - "sha256:bc013d6533b13288ae996b413519ac4b185da94ee0b1cc9727b662e04b989b31" + "sha256:77130f494453c2443ed824a863069bd055a831da483880386e95b092eabaf5bb" ) @@ -2942,29 +2958,17 @@ def test_private_source_reliability_schema_is_closed() -> None: "field,replacement,expected", [ ("receipt_id", "retention:not-a-receipt", "receipt ID"), - ("retention_mode", "GOVERNANCE", "not COMPLIANCE"), ("provenance_attestation", "unreviewed-v1", "provenance"), - ("head_verified", False, "head_verified is false"), + ("commit_verified", False, "commit_verified is false"), + ("push_verified", False, "push_verified is false"), + ("transparency_logged", False, "transparency_logged is false"), + ("retention_commit", "not-a-commit", "retention commit is not exact"), ( "acceptance_verified_at", "2026-08-18T12:00:00Z", "canonical millisecond UTC form", ), - ( - "retained_at", - "2026-08-18T11:59:59.000Z", - "chronology", - ), - ( - "retention_until", - "2026-08-19T12:01:00.000Z", - "period is outside policy", - ), - ( - "retention_until", - "2037-08-18T12:01:00.000Z", - "period is outside policy", - ), + ("retained_at", "2026-08-18T11:59:59.000Z", "chronology"), ], ) def test_private_source_retention_mutation_refuses( diff --git a/tests/test_private_export_contract.py b/tests/test_private_export_contract.py index 261942d..e967ffb 100644 --- a/tests/test_private_export_contract.py +++ b/tests/test_private_export_contract.py @@ -28,13 +28,13 @@ def _contract() -> dict[str, Any]: "destination": { "account_id": "123456789012", "region": "us-east-1", - "bucket": "openadapt-retained-evidence", - "object_prefix": "production-acceptance", + "repository": "OpenAdaptAI/openadapt-retained-evidence", + "ref": "refs/heads/main", + "path_prefix": "production-acceptance", "kms_key_arn": ( "arn:aws:kms:us-east-1:123456789012:key/1111-2222" ), - "retention_mode": "COMPLIANCE", - "retention_days": 2555, + "retention_commitment_days": 2555, }, "uploader_arn": "arn:aws:iam::123456789012:role/openadapt-retention-writer", "importer_workflow_ref": WORKFLOW_REF, @@ -60,7 +60,7 @@ def cloud(domain: str, value: str) -> str: return "sha256:" + hashlib.sha256(payload).hexdigest() assert facts["storage_identity_sha256"] == cloud( - "retention store", destination["bucket"] + "retention store", destination["repository"] ) assert facts["kms_key_identity_sha256"] == cloud( "retention KMS key", destination["kms_key_arn"] @@ -82,10 +82,10 @@ def test_the_retention_separator_is_not_the_acceptance_separator() -> None: exactly the defect this file exists to prevent recurring. """ - bucket = _contract()["destination"]["bucket"] + repository = _contract()["destination"]["repository"] - assert MODULE.retention_binding_sha256("retention store", bucket) != ( - MODULE.opaque_binding_sha256("retention store", bucket) + assert MODULE.retention_binding_sha256("retention store", repository) != ( + MODULE.opaque_binding_sha256("retention store", repository) ) @@ -118,10 +118,11 @@ def test_the_contract_carries_values_not_digests() -> None: (lambda c: c["destination"].pop("region"), "retention destination keys differ"), (lambda c: c["destination"].__setitem__("account_id", "12345"), "AWS account ID"), (lambda c: c["destination"].__setitem__("region", "nowhere"), "region is invalid"), - (lambda c: c["destination"].__setitem__("bucket", "Bad_Bucket"), "bucket is invalid"), - (lambda c: c["destination"].__setitem__("object_prefix", "/x"), "object prefix"), - (lambda c: c["destination"].__setitem__("object_prefix", "a//b"), "object prefix"), - (lambda c: c["destination"].__setitem__("object_prefix", "a/../b"), "object prefix"), + (lambda c: c["destination"].__setitem__("repository", "nope"), "repository is invalid"), + (lambda c: c["destination"].__setitem__("ref", "refs/tags/v1"), "ref is invalid"), + (lambda c: c["destination"].__setitem__("path_prefix", "/x"), "path prefix"), + (lambda c: c["destination"].__setitem__("path_prefix", "a//b"), "path prefix"), + (lambda c: c["destination"].__setitem__("path_prefix", "a/../b"), "path prefix"), (lambda c: c["destination"].__setitem__("kms_key_arn", "arn:aws:kms:x"), "KMS key ARN"), # The key must live in the approved account and region, the same rule # the Cloud writer enforces before it retains anything. @@ -137,10 +138,9 @@ def test_the_contract_carries_values_not_digests() -> None: ), "outside the approved account or region", ), - (lambda c: c["destination"].__setitem__("retention_mode", "GOVERNANCE"), "COMPLIANCE"), - (lambda c: c["destination"].__setitem__("retention_days", 30), "outside policy"), - (lambda c: c["destination"].__setitem__("retention_days", 4000), "outside policy"), - (lambda c: c["destination"].__setitem__("retention_days", "2555"), "must be an integer"), + (lambda c: c["destination"].__setitem__("retention_commitment_days", 30), "outside policy"), + (lambda c: c["destination"].__setitem__("retention_commitment_days", 4000), "outside policy"), + (lambda c: c["destination"].__setitem__("retention_commitment_days", "2555"), "must be an integer"), (lambda c: c.__setitem__("uploader_arn", "not-an-arn"), "uploader ARN is invalid"), ( lambda c: c.__setitem__( @@ -163,8 +163,8 @@ def test_retention_days_must_sit_inside_the_fixed_policy() -> None: policy = MODULE.production_acceptance_policy() facts = MODULE.validate_private_export_contract(_contract()) - assert policy["minimum_retention_days"] <= facts["retention_days"] - assert facts["retention_days"] <= policy["maximum_retention_days"] + assert policy["minimum_retention_days"] <= facts["retention_commitment_days"] + assert facts["retention_commitment_days"] <= policy["maximum_retention_days"] def test_importer_identity_must_be_the_approved_workflow_and_ref() -> None: @@ -230,7 +230,7 @@ def test_retention_must_match_the_approved_destination_key_and_uploader() -> Non def test_a_certificate_cannot_choose_its_own_destination() -> None: facts = MODULE.validate_private_export_contract(_contract()) elsewhere = copy.deepcopy(_contract()) - elsewhere["destination"]["bucket"] = "attacker-bucket" + elsewhere["destination"]["repository"] = "attacker/evidence" forged = MODULE.validate_private_export_contract(elsewhere) retention = _retention(facts) @@ -240,23 +240,27 @@ def test_a_certificate_cannot_choose_its_own_destination() -> None: MODULE.verify_retention_against_contract(retention, facts) -def test_a_shorter_retention_period_than_approved_is_refused() -> None: - facts = MODULE.validate_private_export_contract(_contract()) - retention = _retention(facts) - retention["retention_until"] = "2026-09-18T12:00:00.000Z" - - with pytest.raises(MODULE.AcceptanceError, match="shorter than the approved"): - MODULE.verify_retention_against_contract(retention, facts) - +def test_the_commitment_is_recorded_and_not_enforced() -> None: + """A git commit has no expiry, so the period is a commitment, not a lock. -def test_a_longer_retention_period_than_approved_is_allowed() -> None: - """An approval sets a floor. Locking evidence for longer is never a fault.""" + Enforcing a number nothing can hold would be theatre. The contract records + retention_commitment_days and the verifier does not police it; what it does + police is that the evidence went to the approved repository, key and + uploader. + """ facts = MODULE.validate_private_export_contract(_contract()) - retention = _retention(facts) - retention["retention_until"] = "2044-08-18T12:00:00.000Z" - MODULE.verify_retention_against_contract(retention, facts) + assert facts["retention_commitment_days"] == 2555 + # No period is compared, so no retained_at or retention_until is needed. + MODULE.verify_retention_against_contract( + { + "storage_identity_sha256": facts["storage_identity_sha256"], + "kms_key_identity_sha256": facts["kms_key_identity_sha256"], + "uploader_identity_sha256": facts["uploader_identity_sha256"], + }, + facts, + ) def test_cli_refuses_a_contract_it_is_not_authorised_to_use(