diff --git a/adapters/terraform/policy/__init__.py b/adapters/terraform/policy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adapters/terraform/policy/checkov_adapter.py b/adapters/terraform/policy/checkov_adapter.py new file mode 100644 index 0000000..529bee5 --- /dev/null +++ b/adapters/terraform/policy/checkov_adapter.py @@ -0,0 +1,99 @@ +"""Translate Checkov JSON output to ACDL PolicyCheckResult records. + +Reads Checkov's JSON output (one framework key, e.g. terraform_plan), +emits a list of PolicyCheckResult dicts conforming to +schemas/policy_check_result.schema.json. Run Checkov with --soft-fail so +Checkov never exits non-zero; the confidence signal decides the gate, not +Checkov's exit code. + +Spike scope (D-043): tag/naming is a single SKIPPED record. A custom +Checkov YAML rule for tag presence lands in v1.2. +""" + +import datetime +import json +import sys + + +RULE_MAP = { + "CKV_AWS_41": ("secrets-in-plaintext", "high"), + "CKV_AWS_45": ("secrets-in-plaintext", "high"), + "CKV_AWS_46": ("secrets-in-plaintext", "high"), + "CKV_AWS_20": ("public-ingress", "high"), + "CKV_AWS_57": ("public-ingress", "high"), + "CKV_AWS_24": ("public-ingress", "medium"), + "CKV_AWS_25": ("public-ingress", "medium"), + "CKV_AWS_1": ("iam-wildcard", "high"), + "CKV_AWS_40": ("iam-wildcard", "medium"), + "CKV_AWS_7": ("kms-key-reference", "medium"), + "CKV_AWS_33": ("kms-key-reference", "medium"), +} + +_RESULT_MAP = {"PASSED": "pass", "FAILED": "fail", "SKIPPED": "skipped"} + + +def _iso8601_now(): + return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _to_pcr(checkov_record, contract_id, result_str): + check_id = checkov_record.get("check_id", "") + default_sev = RULE_MAP.get(check_id, (check_id, "info"))[1] + severity = checkov_record.get("severity", default_sev) + if isinstance(severity, str): + severity = severity.lower() + return { + "contractId": contract_id, + "evaluatedAt": _iso8601_now(), + "engine": "checkov", + "ruleId": check_id, + "severity": severity, + "result": _RESULT_MAP.get(result_str, "error"), + "message": checkov_record.get("check_name", ""), + "evidence": { + "file_path": checkov_record.get("file_path"), + "resource": checkov_record.get("resource"), + "resource_address": checkov_record.get("resource_address"), + "code_block": checkov_record.get("code_block"), + }, + "resourceRef": checkov_record.get("resource_address") or checkov_record.get("resource", ""), + } + + +def _emit_tag_naming_skipped(contract_id): + return { + "contractId": contract_id, + "evaluatedAt": _iso8601_now(), + "engine": "checkov", + "ruleId": "ACDL_TAG_NAMING", + "severity": "info", + "result": "skipped", + "message": "tag/naming check deferred to v1.2 (D-043)", + "evidence": {}, + "resourceRef": "", + } + + +def adapt(checkov_json_path, contract_id): + with open(checkov_json_path, "r", encoding="utf-8") as fh: + data = json.load(fh) + out = [] + for framework, body in data.items(): + results = body.get("results", body) if isinstance(body, dict) else {} + if not isinstance(results, dict): + continue + for rec in results.get("passed_checks", []): + out.append(_to_pcr(rec, contract_id, "PASSED")) + for rec in results.get("failed_checks", []): + out.append(_to_pcr(rec, contract_id, "FAILED")) + for rec in results.get("skipped_checks", []): + out.append(_to_pcr(rec, contract_id, "SKIPPED")) + out.append(_emit_tag_naming_skipped(contract_id)) + return out + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print("usage: checkov_adapter.py ", file=sys.stderr) + sys.exit(2) + print(json.dumps(adapt(sys.argv[1], sys.argv[2]), indent=2)) \ No newline at end of file diff --git a/platform/__init__.py b/platform/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/platform/audit_ledger_design.md b/platform/audit_ledger_design.md new file mode 100644 index 0000000..01ca0a2 --- /dev/null +++ b/platform/audit_ledger_design.md @@ -0,0 +1,103 @@ +# ACDL Tiered Audit Ledger Design (REQ-20) + +> **Status:** design authored in Phase 07 (milestone v1.1); the spike +> (Phases 08-10) implements the **v1.0 hash chain + DynamoDB outbox write** +> (D-041); the v1.2 build-out implements S3 Object Lock + JWS + async +> worker + DLQ + daily checkpoints. + +The audit stream is the platform's tamper-evident record of every delivery +action. The vision's "Audit truth lives outside the repository" bet [1] +and "Not a mutable audit log" anti-goal [1] are the binding constraints. +Version-control history does not satisfy regulatory evidence; the ledger +is the source of truth. + +## Three tiers + +- **Cold tier (source of truth):** S3 with **Object Lock in compliance + mode**, **7-year retention** (ARCHITECTURE.md §9). No one — including + root — can delete or overwrite until retention expires. The regulatory + record. +- **Hot tier (query index):** the `acdl-evidence` audit repo (unchanged + from the v1.0 demo). Not part of the chain; a queryable mirror the + evidence UI (`evidence-ui/index.html`) reads. Lightweight attestation + linkage lives in the repo; the regulatory event body lives in S3. +- **Outbox (write path):** DynamoDB, **RPO = 0** (synchronous write before + contract submission ack). Single-region in v1 (`us-east-1`). + +## Spike scope (D-041) — what Phases 08-10 implement + +- **DynamoDB outbox:** table `acdl-outbox`, `PAY_PER_REQUEST` (D-044), + PK `contractId`, SK `eventType#eventTs`, TTL `expire_at` = now + 365d + (1-year storage per ARCHITECTURE.md §8). +- **`prev_event_hash` chain:** SHA-256 over canonical JSON + (`json.dumps(event, sort_keys=True, separators=(",", ":"))`), lifted + from the v1.0 demo's `evidence_writer.py`. Auto-genesis: first event + has `prev_hash="GENESIS"`. +- **Synchronous write** via boto3 `put_item` (strong-consistent by + default). No separate async worker / DLQ in the spike (RTO = workflow + re-run). +- **Mirror to `acdl-evidence`:** unchanged from v1.0 — the finalize step + commits `audit.json` to the evidence repo (the hot tier). +- **Spike evidence event shape:** + `{seq, ts, stage, event, prev_hash, hash, contractId, environment, stack, score, band}`. + +## v1.2 build-out — what Phase 07 designs but the spike defers + +- **S3 Object Lock:** bucket `acdl-evidence-lock-`, Object + Lock enabled at creation, compliance mode, 7-yr retention + (`RetainUntilDate` = now + 7y). The outbox→S3 path is an async worker + that reads from the outbox and writes to Object Lock. +- **JWS detached signature (RFC 7515):** the event payload is + canonical-JSON-serialized, SHA-256 hashed, signed with a private key; + the signature is stored *detached* alongside the payload. Signing key = + **platform-level KMS key** (not per-contract — a per-contract key would + explode the key-management surface), rotated **quarterly**. The `jws` + field is added to the event shape in v1.2. +- **Async worker + DLQ:** a Lambda (or a Gitea Actions scheduled workflow) + reads the outbox, writes to S3 Object Lock, signs with KMS. DLQ = an + SQS dead-letter queue for failed writes. RTO = DLQ replay. +- **Daily checkpoints (§9):** a daily job reads the last event hash and + writes a "checkpoint" event to the ledger (+ optionally to a public + notarization service). The spike runs in minutes, not days — no + checkpoint in spike. + +## JWS vs chain — orthogonality note + +The `prev_event_hash` chain gives ordering/tamper-evidence *within* the +log (a deleted event breaks the chain visibly); JWS gives authenticity +*per event* (a forged event is detectable without re-reading the whole +chain). The chain is spike-scope; JWS is v1.2. Together they cover both +integrity properties the vision's "Not a mutable audit log" anti-goal +requires. + +## Outbox item shape (full, spike + v1.2) + +- PK `contractId` (UUID). +- SK `eventType#eventTs` (e.g. `POLICY_CHECKED#2026-07-21T12:00:00Z`). +- `payload` (the event body — hash-chained in spike, JWS-signed in v1.2). +- `prev_event_hash` (chain link; `GENESIS` for the first event). +- `hash` (this event's SHA-256 over canonical JSON). +- `approver_qa` (Gitea username of the QA approver; empty in dev-only + spike; populated on qa-promotion — D-042). +- `approver_prod` (SRE username; empty in spike). +- `environment`, `stack`, `score`, `band`. +- `expire_at` (TTL = now + 365d). +- **v1.2 only:** `jws` (detached signature), `checkpoint_ref`. + +## RPO / RTO table + +| Phase | RPO | RTO | +|-------|-----|-----| +| Spike (D-041) | 0 (sync outbox write) | workflow re-run | +| v1.2 | 0 (sync outbox) | async worker DLQ replay | + +## Decision trail + +- **D-041** — spike scope = hash chain + outbox write; Object Lock + JWS + + worker + DLQ are v1.2. +- **D-044** — outbox mode `PAY_PER_REQUEST`; PK/SK; TTL `expire_at` = + now + 365d; no separate async worker in spike. +- **D-042** — approver identities (`approver_qa`, `approver_prod`) live + in the outbox; the separation-of-duties check + (`platform/separation_of_duties.py`) reads `approver_qa` and compares + to the prod-dispatch `gitea.actor`. \ No newline at end of file diff --git a/platform/hitl_matrix_design.md b/platform/hitl_matrix_design.md new file mode 100644 index 0000000..20cda7a --- /dev/null +++ b/platform/hitl_matrix_design.md @@ -0,0 +1,115 @@ +# ACDL Human-in-the-Loop Matrix + Separation-of-Duties Design (REQ-21) + +> **Status:** design authored in Phase 07 (milestone v1.1); v1.2 wires the +> gates. The spike (Phases 08-10) is **dev-only**; HITL is not exercised +> (the spike contract has `environment: dev`). + +The vision's "Lower Environments are Autonomous; Higher Environments are +Attested" tenet [1] and the "deliberate human attestation — not as a +rubber stamp" requirement [1] are the binding constraints. + +## Gate model (ARCHITECTURE.md §10.1) + +**Pre-execution gates.** The contract is held in a "validated but not +applied" state until the human attests. qa, prod, dr are attestation +gates. No partial deployment to roll back on rejection (qa, prod); dr is +a separate deployment against a separate cluster/region. The +canary/deployment-rollback model is explicitly not in scope for v1. + +## Gitea-specific gate mechanics (D-042) + +Gitea has **no Environments API** and ignores `environment:` blocks +(v1.0 D-013; re-confirmed in RESEARCH TARGET 1). The pre-execution gate +is modeled as a `workflow_dispatch` with approval inputs: + +- **qa gate:** `workflow_dispatch` with `approve_qa: true`; the dispatch + run's `gitea.actor` is the QA approver. +- **prod gate:** `workflow_dispatch` with `approve_prod: true`; + `gitea.actor` is the SRE approver. +- **dr gate:** `workflow_dispatch` with `approve_dr: true`; same. + +The approver identity of record = `gitea.actor` of the dispatch run +(D-042). There is no other approval-identity signal in Gitea. The v1.2 +real-OIDC path (blocked on go-gitea/gitea#36988) does not change this — +OIDC authorizes the *runner* to AWS, it does not change how the platform +records the *human* approver. + +## Reviewer routing (ARCHITECTURE.md §10.2) + +Gitea CODEOWNERS routes the right reviewer to the right gate: + +- qa → QA team +- prod → SRE team +- dr → SRE team + +CODEOWNERS **routes**; it does **not** enforce identity distinctness (that +is the platform-internal outbox check in +`platform/separation_of_duties.py`). + +## Full 8-concern attestation matrix (§10.4, lifted verbatim) + +| Env | Concern | Evidence artifact | Freshness | Source | Attester | +|---|---|---|---|---|---| +| qa | Functional correctness | Last successful run of contract-declared validation.e2eSuite with pass rate ≥ 99% | Last 24h | Test runner declared in contract | QA | +| qa | Performance baseline | Load test report (k6 / Gatling / Locust) showing p99 latency < declared NFR and throughput > declared minimum | Last 7d | Load test runner declared in contract | QA | +| qa | Security posture | Vulnerability scan (Trivy, Snyk, or contract-declared equivalent) with no criticals/highs, signed by Security on-call | Last 24h | Security scanner + Security team signature | QA | +| qa | Contract NFRs | Platform-generated report: schema valid, NFR assertions (latency, throughput, error rate) within declared bounds | At submission | Platform contract validator | QA | +| prod | Operational readiness | Runbook published, dashboard exists, on-call rotation assigned, alerts configured | At submission, validated against last 30d history | Platform + SRE | SRE | +| prod | Incident response | Sev-1 runbook tabletop or live drill completed | Last 90d | SRE drill record | SRE | +| prod | Capacity / cost | FinOps forecast for next 30d within budget envelope, cost anomaly baseline stored, budget alert configured | Forecast valid for next 30d | FinOps + SRE | SRE | +| prod | Resilience | DR drill, chaos engineering report, backup verified | DR: 180d; chaos: 90d; backup: 30d | SRE + Platform | SRE | +| dr | dr-region deploy with the most recent prod-bound dr drill as canary evidence | dr drill report | Last 180d | SRE | SRE | + +## Timeout behavior (§10.5) + +| Time | State | Action | +|---|---|---| +| Submission | PENDING_ATTESTATION | Notify responsible team | +| 1 business day | PENDING_ATTESTATION_WARNING | Notify team + platform on-call (elevated path); emit `PENDING_ATTESTATION_TIMEOUT_WARNING` event | +| 2 business days | PENDING_ATTESTATION_AUTO_FREEZE | Auto-freeze; require re-submission; emit `PENDING_ATTESTATION_AUTO_FREEZE` event; new submission linked via `supersedes` | + +**Implementation:** a Gitea `on: schedule` workflow (runs hourly) that +scans the DynamoDB outbox for `PENDING_ATTESTATION` events with `ts` +older than 1/2 business days and emits the warn/freeze events. Not +implemented in the spike (dev-only). + +## Rejection and rollback (§10.6) + +Rejection returns the contract to a `HELD` state with the rejection +reason captured as a `PROMOTION_REJECTED` event. The consumer fixes the +cause and re-submits; the new submission is linked to the rejected one +via `supersedes` (a contract-schema field — `schemas/contract.schema.json`). +The audit chain is **extended, not torn up** (the "Not a mutable audit +log" anti-goal). No partial deployment to roll back at any v1 gate. + +## Separation of duties (§10.3) — pointer to the .py + +The identity-distinctness check is platform-internal, not GitHub-native, +not Kyverno (in v1). Sequence: + +1. On promotion dev → qa, the platform reads the QA approver's identity + from the `workflow_dispatch` run's `gitea.actor` and writes it to the + DynamoDB outbox keyed by `contractId` (attribute `approver_qa`). +2. On promotion qa → prod, the platform reads the stored `approver_qa` + from the outbox and the new SRE approver's `gitea.actor` from the + prod-dispatch run. +3. If `approver_qa == approver_prod`, the platform blocks the prod + promotion, writes a `SEPARATION_OF_DUTIES_VIOLATION` event to the + evidence stream, and routes a halt artifact to the SRE on-call. +4. The check is implemented in `platform/separation_of_duties.py` + (T-7.8). The platform is the only writer to the outbox; the check is + in the same process that has authority to block the promotion. + +## Spike scope note + +The spike is dev-only (REQ-27 contract has `environment: dev`), so HITL +is not exercised. Phase 07 authors the design; Phase 10's +`verify_phase10.sh` does not assert HITL behavior. v1.2 wires the gates +against this design. + +## Decision trail + +- **D-042** — approver identity = `gitea.actor` of the `workflow_dispatch` + run; no Environments API in Gitea. +- **D-013** (v1.0) — the `workflow_dispatch` approval-input fallback, + re-used for the real platform's pre-execution gate model. \ No newline at end of file diff --git a/platform/separation_of_duties.py b/platform/separation_of_duties.py new file mode 100644 index 0000000..5028311 --- /dev/null +++ b/platform/separation_of_duties.py @@ -0,0 +1,42 @@ +"""Check that qaApprover != prodApprover for a contract (ARCHITECTURE.md +§10.3, D-042). Reads `approver_qa` from the DynamoDB outbox for the +contractId, compares to the prod-dispatch `gitea.actor`. Blocks on +equality, emits `SEPARATION_OF_DUTIES_VIOLATION`, routes a halt artifact +to SRE on-call. + +Spike scope (A-8.1): the spike is dev-only (REQ-27 contract has +environment: dev); HITL is not exercised. This module is authored to its +full v1.2 shape but the spike calls it with current_prod_approver=None +and a None outbox_client — the check returns (True, 'no QA approver +recorded (dev-only spike)'). +""" + +from typing import Optional, Tuple + + +def check(outbox_client, contract_id: str, + current_prod_approver: Optional[str]) -> Tuple[bool, str]: + """Return (ok, reason). ok=False means block the prod promotion.""" + if outbox_client is None: + return (True, "no outbox client (dev-only spike)") + item = outbox_client.get(contract_id) + if item is None: + return (True, "no prior approver (first promotion)") + qa_approver = item.get("approver_qa") + if not qa_approver: + return (True, "no QA approver recorded (dev-only spike)") + if current_prod_approver is None: + return (True, "no prod approver supplied (dev-only spike)") + if qa_approver == current_prod_approver: + return (False, + f"SEPARATION_OF_DUTIES_VIOLATION: " + f"qaApprover==prodApprover=={qa_approver}") + return (True, "distinct") + + +def route_halt_artifact(contract_id: str, violation_reason: str, + oncall_client) -> None: + """Route a halt artifact to SRE on-call. Spike: stub that logs. v1.2 + wires a real pager.""" + print(f"[halt-artifact] contract={contract_id} reason={violation_reason} " + f"oncall={oncall_client}", flush=True) \ No newline at end of file diff --git a/schemas/policy_check_result.schema.json b/schemas/policy_check_result.schema.json new file mode 100644 index 0000000..f231e28 --- /dev/null +++ b/schemas/policy_check_result.schema.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://acdl.cloudinit.dev/schemas/policy_check_result.schema.json", + "title": "ACDL PolicyCheckResult", + "description": "Normalized policy check result — the contract between policy engines and the confidence signal. Engine-specific adapters (checkov_adapter.py, future kyverno_adapter) translate native engine output to this shape. The confidence signal consumes a list of these as its policy input; it is engine-agnostic. The severity enum drives the severity->penalty mapping (critical hard-override, high -0.2, medium -0.05, low -0.01, info 0.0).", + "$comment": "Canonical PolicyCheckResult (ARCHITECTURE.md §12.6). The confidence signal (platform/confidence_signal.py) consumes a list of these as its policy input; it is engine-agnostic. Adapters translate native output to this shape; the signal never reads engine-specific evidence.", + "type": "object", + "required": ["contractId", "evaluatedAt", "engine", "ruleId", "severity", "result", "message", "resourceRef"], + "properties": { + "contractId": { + "type": "string", + "format": "uuid", + "description": "The contract this check was evaluated against." + }, + "evaluatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO-8601 timestamp of evaluation." + }, + "engine": { + "type": "string", + "enum": ["checkov", "kyverno", "opa"], + "description": "Policy engine that produced this result." + }, + "ruleId": { + "type": "string", + "description": "Rule identifier (e.g. CKV_AWS_24, KYVERNO_NO_PRIVILEGED, ACDL_TAG_NAMING)." + }, + "severity": { + "type": "string", + "enum": ["critical", "high", "medium", "low", "info"], + "description": "Severity drives the confidence signal's penalty mapping (ARCHITECTURE.md §8)." + }, + "result": { + "type": "string", + "enum": ["pass", "fail", "skipped", "error"], + "description": "Check outcome." + }, + "message": { + "type": "string", + "description": "Human-readable result message." + }, + "evidence": { + "type": "object", + "additionalProperties": true, + "description": "Engine-specific payload (file_path, resource, code_block, etc.). Opaque to the confidence signal; present for audit/debug." + }, + "resourceRef": { + "type": "string", + "description": "IR-typed resource identifier (the resource this check evaluated)." + } + } +} \ No newline at end of file