|
| 1 | +# SPDX-License-Identifier: PMPL-1.0-or-later |
| 2 | +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> |
| 3 | +# |
| 4 | +# HttpCapabilityGateway.A2ML — a2ml-format attestation generator for access decisions. |
| 5 | +# |
| 6 | +# Produces content-addressable audit records for every access decision made by |
| 7 | +# the gateway. Adapted from HAR.Attestation.A2ML for HTTP capability gateway |
| 8 | +# context, where the attested events are access control decisions (allow/deny) |
| 9 | +# rather than routing decisions (which backend to use). |
| 10 | + |
| 11 | +defmodule HttpCapabilityGateway.A2ML do |
| 12 | + @moduledoc """ |
| 13 | + Generates a2ml-format attestations for access control decisions. |
| 14 | +
|
| 15 | + Every access decision made by the HTTP Capability Gateway produces an attestation |
| 16 | + record that captures the full context of the decision in a verifiable, |
| 17 | + content-addressable format. This serves as the immutable audit trail for |
| 18 | + all access control activity, enabling post-hoc verification, compliance auditing, |
| 19 | + and forensic analysis of policy enforcement. |
| 20 | +
|
| 21 | + ## What Gets Attested |
| 22 | +
|
| 23 | + Each attestation captures the dimensions of an access decision: |
| 24 | +
|
| 25 | + - **Who** — the trust level of the requester (untrusted, authenticated, internal) |
| 26 | + - **What** — the HTTP method and path requested |
| 27 | + - **Why** — the exposure requirement and policy hash that governed the decision |
| 28 | + - **Verdict** — whether access was allowed or denied |
| 29 | + - **When** — RFC 3339 timestamp with UTC timezone |
| 30 | + - **Verification** — SHA-256 hash of the entire decision context |
| 31 | +
|
| 32 | + ## Attestation Types |
| 33 | +
|
| 34 | + Four types of decisions can be attested: |
| 35 | +
|
| 36 | + - `:access` — Standard access control decisions (trust vs exposure evaluation) |
| 37 | + - `:policy` — Policy load/reload events with policy hash |
| 38 | + - `:trust` — Trust level assignments and upgrades |
| 39 | + - `:rate_limit` — Rate limiting decisions (allow/deny with bucket state) |
| 40 | +
|
| 41 | + ## Content Addressability |
| 42 | +
|
| 43 | + Attestations are content-addressable: the same decision inputs always produce |
| 44 | + the same `decision_hash`. This property enables verification without requiring |
| 45 | + the original request context — you can recompute the hash from the payload and |
| 46 | + compare it to the declared hash to confirm integrity. |
| 47 | +
|
| 48 | + The hash is computed over deterministic JSON serialisation (sorted keys via Jason) |
| 49 | + of the decision payload. Sensitive fields (passwords, tokens, secrets, API keys) |
| 50 | + are redacted before hashing to prevent credential leakage into audit logs. |
| 51 | +
|
| 52 | + ## a2ml Envelope Format |
| 53 | +
|
| 54 | + The attestation follows the a2ml (Anthropic Agent Markup Language) envelope |
| 55 | + structure, providing a standardised wrapper for machine-readable declarations: |
| 56 | +
|
| 57 | + %{ |
| 58 | + "a2ml" => %{ |
| 59 | + "version" => "1.0", |
| 60 | + "type" => "access", |
| 61 | + "issued_at" => "2026-02-28T12:34:56.789Z", |
| 62 | + "issuer" => "http-capability-gateway", |
| 63 | + "decision_hash" => "sha256:abcdef..." |
| 64 | + }, |
| 65 | + "decision" => %{ |
| 66 | + "trust_level" => "authenticated", |
| 67 | + "exposure" => "public", |
| 68 | + "path" => "/api/v1/users", |
| 69 | + "method" => "GET", |
| 70 | + "verdict" => "allow", |
| 71 | + "policy_hash" => "sha256:..." |
| 72 | + } |
| 73 | + } |
| 74 | +
|
| 75 | + ## Security Considerations |
| 76 | +
|
| 77 | + - **Sensitive field redaction**: Decision data is sanitised before hashing — fields |
| 78 | + whose keys contain `password`, `token`, `secret`, `key`, `credential`, `api_key`, |
| 79 | + `access_key`, or `private_key` (case-insensitive) are replaced with `"[REDACTED]"`. |
| 80 | + This prevents credentials from leaking into audit logs while preserving enough |
| 81 | + context for meaningful verification. |
| 82 | + - **Deterministic hashing**: JSON encoding uses sorted keys (via Jason) to ensure |
| 83 | + the same logical payload always hashes identically, regardless of Elixir map key |
| 84 | + ordering. |
| 85 | + - **SHA-256**: The hash algorithm provides 128-bit collision resistance, sufficient |
| 86 | + for audit integrity (not used for cryptographic signatures). |
| 87 | +
|
| 88 | + ## Integration Points |
| 89 | +
|
| 90 | + - Called by `HttpCapabilityGateway.Gateway` after access decisions |
| 91 | + - Called by `HttpCapabilityGateway.K9Contract` after contract enforcement |
| 92 | + - Attestations can be forwarded to external audit stores (IPFS, VeriSimDB) |
| 93 | + - Consumed by monitoring dashboards for audit trail display |
| 94 | + """ |
| 95 | + |
| 96 | + require Logger |
| 97 | + |
| 98 | + # Sensitive key patterns that must be redacted before hashing. |
| 99 | + # Case-insensitive matching is applied by downcasing the key before comparison. |
| 100 | + # This list covers common credential field names across various APIs and |
| 101 | + # configuration formats. |
| 102 | + @sensitive_keys ~w(password token secret key credential api_key access_key private_key) |
| 103 | + |
| 104 | + # Valid attestation types. Using an allowlist prevents atom exhaustion from |
| 105 | + # external input — callers must use one of these atoms, never raw strings. |
| 106 | + @valid_types [:access, :policy, :trust, :rate_limit] |
| 107 | + |
| 108 | + @typedoc """ |
| 109 | + Attestation type discriminator. |
| 110 | +
|
| 111 | + - `:access` — Access control decision (trust level vs exposure requirement) |
| 112 | + - `:policy` — Policy load, reload, or compilation event |
| 113 | + - `:trust` — Trust level assignment or escalation event |
| 114 | + - `:rate_limit` — Rate limiter decision (bucket allow/deny) |
| 115 | + """ |
| 116 | + @type attestation_type :: :access | :policy | :trust | :rate_limit |
| 117 | + |
| 118 | + @typedoc """ |
| 119 | + A complete attestation envelope containing the a2ml metadata and the |
| 120 | + decision payload. The `decision_hash` in the a2ml section is the SHA-256 |
| 121 | + of the canonical JSON representation of the decision section. |
| 122 | + """ |
| 123 | + @type attestation :: %{ |
| 124 | + String.t() => %{ |
| 125 | + String.t() => String.t() |
| 126 | + } |
| 127 | + } |
| 128 | + |
| 129 | + @doc """ |
| 130 | + Generate an a2ml attestation for an access decision. |
| 131 | +
|
| 132 | + Takes a map describing the decision context and produces a content-addressable |
| 133 | + attestation envelope. The attestation includes a SHA-256 hash of the decision |
| 134 | + payload, making it independently verifiable. |
| 135 | +
|
| 136 | + ## Parameters |
| 137 | +
|
| 138 | + - `decision_data` — A map containing decision context. Expected keys: |
| 139 | + - `:type` — Attestation type atom (`:access`, `:policy`, `:trust`, `:rate_limit`). |
| 140 | + Defaults to `:access` if not provided. |
| 141 | + - `:trust_level` — Trust level atom or string (e.g., `:authenticated`, `"internal"`) |
| 142 | + - `:exposure` — Exposure requirement string (e.g., `"public"`, `"authenticated"`) |
| 143 | + - `:path` — HTTP request path (e.g., `"/api/v1/users"`) |
| 144 | + - `:method` — HTTP method string (e.g., `"GET"`, `"POST"`) |
| 145 | + - `:verdict` — Decision outcome (`:allow`, `:deny`, `"allow"`, `"deny"`) |
| 146 | + - `:policy_hash` — Hash of the policy that governed this decision |
| 147 | + - Any additional keys are included in the decision payload after redaction. |
| 148 | +
|
| 149 | + ## Returns |
| 150 | +
|
| 151 | + A map with two top-level keys: |
| 152 | + - `"a2ml"` — Envelope metadata (version, type, issuer, timestamp, hash) |
| 153 | + - `"decision"` — The decision data that was hashed (after redaction) |
| 154 | +
|
| 155 | + ## Examples |
| 156 | +
|
| 157 | + iex> data = %{ |
| 158 | + ...> type: :access, |
| 159 | + ...> trust_level: :authenticated, |
| 160 | + ...> exposure: "public", |
| 161 | + ...> path: "/api/v1/users", |
| 162 | + ...> method: "GET", |
| 163 | + ...> verdict: :allow, |
| 164 | + ...> policy_hash: "sha256:abc123" |
| 165 | + ...> } |
| 166 | + iex> attestation = A2ML.attest(data) |
| 167 | + iex> attestation["a2ml"]["type"] |
| 168 | + "access" |
| 169 | + iex> attestation["a2ml"]["issuer"] |
| 170 | + "http-capability-gateway" |
| 171 | + """ |
| 172 | + @spec attest(map()) :: attestation() |
| 173 | + def attest(decision_data) when is_map(decision_data) do |
| 174 | + # Step 1: Extract and validate the attestation type. |
| 175 | + # Default to :access for backward compatibility with callers that |
| 176 | + # don't specify a type explicitly. |
| 177 | + type = Map.get(decision_data, :type, :access) |
| 178 | + type_str = validate_and_stringify_type(type) |
| 179 | + |
| 180 | + # Step 2: Build the canonical decision payload. |
| 181 | + # This converts all values to strings for deterministic JSON serialisation, |
| 182 | + # removes the :type key (it lives in the envelope, not the payload), and |
| 183 | + # redacts sensitive fields to prevent credential leakage. |
| 184 | + payload = |
| 185 | + decision_data |
| 186 | + |> Map.delete(:type) |
| 187 | + |> stringify_decision() |
| 188 | + |> redact_sensitive() |
| 189 | + |
| 190 | + # Step 3: Compute the SHA-256 hash of the canonical JSON representation. |
| 191 | + # This hash becomes the content address — the same decision always produces |
| 192 | + # the same hash, enabling verification without the original context. |
| 193 | + decision_hash = hash_decision(payload) |
| 194 | + |
| 195 | + # Step 4: Wrap the payload in the a2ml envelope structure. |
| 196 | + # The envelope provides versioning, typing, and issuer identification |
| 197 | + # so consumers can parse attestations without knowing the producer. |
| 198 | + %{ |
| 199 | + "a2ml" => %{ |
| 200 | + "version" => "1.0", |
| 201 | + "type" => type_str, |
| 202 | + "issued_at" => DateTime.utc_now() |> DateTime.to_iso8601(), |
| 203 | + "issuer" => "http-capability-gateway", |
| 204 | + "decision_hash" => decision_hash |
| 205 | + }, |
| 206 | + "decision" => payload |
| 207 | + } |
| 208 | + end |
| 209 | + |
| 210 | + @doc """ |
| 211 | + Verify an attestation by recomputing the decision hash. |
| 212 | +
|
| 213 | + Takes a previously generated attestation and recomputes the SHA-256 hash |
| 214 | + of its decision payload. If the computed hash matches the declared |
| 215 | + `decision_hash` in the a2ml envelope, the attestation is valid — its |
| 216 | + payload has not been tampered with since generation. |
| 217 | +
|
| 218 | + This verification is independent of any external state: it only requires |
| 219 | + the attestation map itself. No database lookup, no network call, no |
| 220 | + original request context. |
| 221 | +
|
| 222 | + ## Parameters |
| 223 | +
|
| 224 | + - `attestation` — A map with `"a2ml"` and `"decision"` keys, as |
| 225 | + produced by `attest/1`. |
| 226 | +
|
| 227 | + ## Returns |
| 228 | +
|
| 229 | + - `:valid` — The decision hash matches; payload integrity confirmed. |
| 230 | + - `:tampered` — The hash does not match; payload has been modified. |
| 231 | +
|
| 232 | + ## Examples |
| 233 | +
|
| 234 | + iex> attestation = A2ML.attest(decision_data) |
| 235 | + iex> A2ML.verify(attestation) |
| 236 | + :valid |
| 237 | +
|
| 238 | + iex> tampered = put_in(attestation, ["decision", "verdict"], "allow") |
| 239 | + iex> A2ML.verify(tampered) |
| 240 | + :tampered |
| 241 | + """ |
| 242 | + @spec verify(map()) :: :valid | :tampered |
| 243 | + def verify(%{"a2ml" => %{"decision_hash" => declared_hash}, "decision" => decision}) do |
| 244 | + # Recompute the hash from the decision payload and compare with the |
| 245 | + # declared hash. If the payload has been modified after attestation |
| 246 | + # generation, the hashes will diverge, revealing tampering. |
| 247 | + computed_hash = hash_decision(decision) |
| 248 | + |
| 249 | + if computed_hash == declared_hash do |
| 250 | + :valid |
| 251 | + else |
| 252 | + :tampered |
| 253 | + end |
| 254 | + end |
| 255 | + |
| 256 | + # Catch-all for malformed attestation maps — return :tampered rather than |
| 257 | + # crashing, since verification is a query operation that should not raise |
| 258 | + # exceptions on bad input. |
| 259 | + def verify(_), do: :tampered |
| 260 | + |
| 261 | + @doc """ |
| 262 | + Redact sensitive fields from a decision map. |
| 263 | +
|
| 264 | + Replaces the values of keys that match sensitive patterns (password, token, |
| 265 | + secret, key, credential, api_key, access_key, private_key) with the string |
| 266 | + `"[REDACTED]"`. Matching is case-insensitive — keys like `"API_KEY"`, |
| 267 | + `"apiKey"`, `"Api_Key"`, `"access_key"`, and `"PRIVATE_KEY"` are all caught. |
| 268 | +
|
| 269 | + This function operates recursively on nested maps, ensuring that sensitive |
| 270 | + fields at any depth are redacted. |
| 271 | +
|
| 272 | + ## Parameters |
| 273 | +
|
| 274 | + - `data` — A map (typically the decision payload before hashing). |
| 275 | +
|
| 276 | + ## Returns |
| 277 | +
|
| 278 | + - The map with sensitive field values replaced by `"[REDACTED]"`. |
| 279 | +
|
| 280 | + ## Examples |
| 281 | +
|
| 282 | + iex> A2ML.redact_sensitive(%{"user" => "alice", "password" => "s3cret"}) |
| 283 | + %{"user" => "alice", "password" => "[REDACTED]"} |
| 284 | +
|
| 285 | + iex> A2ML.redact_sensitive(%{"api_key" => "abc123", "path" => "/api"}) |
| 286 | + %{"api_key" => "[REDACTED]", "path" => "/api"} |
| 287 | + """ |
| 288 | + @spec redact_sensitive(map()) :: map() |
| 289 | + def redact_sensitive(data) when is_map(data) do |
| 290 | + Map.new(data, fn {k, v} -> |
| 291 | + key_lower = k |> to_string() |> String.downcase() |
| 292 | + |
| 293 | + cond do |
| 294 | + is_sensitive_key?(key_lower) -> |
| 295 | + {k, "[REDACTED]"} |
| 296 | + |
| 297 | + is_map(v) -> |
| 298 | + {k, redact_sensitive(v)} |
| 299 | + |
| 300 | + true -> |
| 301 | + {k, v} |
| 302 | + end |
| 303 | + end) |
| 304 | + end |
| 305 | + |
| 306 | + def redact_sensitive(data), do: data |
| 307 | + |
| 308 | + # --------------------------------------------------------------------------- |
| 309 | + # Private Functions |
| 310 | + # --------------------------------------------------------------------------- |
| 311 | + |
| 312 | + # Validates the attestation type and converts it to a string. |
| 313 | + # Unknown types default to "access" with a warning log, ensuring the system |
| 314 | + # never crashes due to a bad type while still recording that something |
| 315 | + # unexpected happened. |
| 316 | + @spec validate_and_stringify_type(atom() | String.t()) :: String.t() |
| 317 | + defp validate_and_stringify_type(type) when is_atom(type) do |
| 318 | + if type in @valid_types do |
| 319 | + Atom.to_string(type) |
| 320 | + else |
| 321 | + Logger.warning("A2ML: unknown attestation type, defaulting to 'access'", |
| 322 | + provided: inspect(type) |
| 323 | + ) |
| 324 | + |
| 325 | + "access" |
| 326 | + end |
| 327 | + end |
| 328 | + |
| 329 | + defp validate_and_stringify_type(type) when is_binary(type), do: type |
| 330 | + defp validate_and_stringify_type(_), do: "access" |
| 331 | + |
| 332 | + # Converts all map keys and values to strings for deterministic JSON |
| 333 | + # serialisation. Atom keys become strings, atom values become strings, |
| 334 | + # numeric values are preserved (Jason handles them deterministically), |
| 335 | + # and nested maps are recursively stringified. |
| 336 | + # |
| 337 | + # This ensures that `%{trust_level: :authenticated}` and |
| 338 | + # `%{"trust_level" => "authenticated"}` produce identical hashes. |
| 339 | + @spec stringify_decision(map()) :: map() |
| 340 | + defp stringify_decision(data) when is_map(data) do |
| 341 | + Map.new(data, fn {k, v} -> |
| 342 | + key = if is_atom(k), do: Atom.to_string(k), else: to_string(k) |
| 343 | + |
| 344 | + value = |
| 345 | + cond do |
| 346 | + is_atom(v) -> Atom.to_string(v) |
| 347 | + is_map(v) -> stringify_decision(v) |
| 348 | + is_list(v) -> Enum.map(v, &stringify_value/1) |
| 349 | + true -> v |
| 350 | + end |
| 351 | + |
| 352 | + {key, value} |
| 353 | + end) |
| 354 | + end |
| 355 | + |
| 356 | + # Stringifies a single value for list elements within the decision payload. |
| 357 | + @spec stringify_value(term()) :: term() |
| 358 | + defp stringify_value(v) when is_atom(v), do: Atom.to_string(v) |
| 359 | + defp stringify_value(v) when is_map(v), do: stringify_decision(v) |
| 360 | + defp stringify_value(v), do: v |
| 361 | + |
| 362 | + # Checks whether a lowercase key string matches any sensitive key pattern. |
| 363 | + # The check uses String.contains?/2 so that compound keys like |
| 364 | + # "database_password" or "auth_token_value" are also caught. |
| 365 | + @spec is_sensitive_key?(String.t()) :: boolean() |
| 366 | + defp is_sensitive_key?(key_lower) do |
| 367 | + Enum.any?(@sensitive_keys, fn sensitive -> |
| 368 | + String.contains?(key_lower, sensitive) |
| 369 | + end) |
| 370 | + end |
| 371 | + |
| 372 | + # Compute SHA-256 hash of the decision payload. |
| 373 | + # |
| 374 | + # The payload is first encoded to JSON using Jason, which produces |
| 375 | + # deterministic output for maps (keys sorted lexicographically). |
| 376 | + # This ensures that the same logical payload always produces the |
| 377 | + # same hash, regardless of Elixir's internal map key ordering. |
| 378 | + # |
| 379 | + # The hash is prefixed with "sha256:" following the multihash |
| 380 | + # convention used by IPFS and OCI registries, making the algorithm |
| 381 | + # self-describing. If we ever need to migrate to SHA-3 or BLAKE3, |
| 382 | + # old attestations remain distinguishable from new ones. |
| 383 | + # |
| 384 | + # The hex encoding uses lowercase to match standard conventions |
| 385 | + # (e.g., Git commit hashes, Docker image digests). |
| 386 | + @spec hash_decision(map()) :: String.t() |
| 387 | + defp hash_decision(payload) do |
| 388 | + json = Jason.encode!(payload, pretty: false) |
| 389 | + hash = :crypto.hash(:sha256, json) |
| 390 | + "sha256:" <> Base.encode16(hash, case: :lower) |
| 391 | + end |
| 392 | +end |
0 commit comments