Skip to content

Commit 9cad275

Browse files
hyperpolymathclaude
andcommitted
Add a2ml attestation module and circuit breaker, fix warnings
- a2ml.ex: Content-addressable audit records with SHA-256 hashing, sensitive data redaction, verify/1 for tamper detection. Adapted from HAR's implementation for HTTP access decisions. - circuit_breaker.ex: GenServer+ETS FSM (closed/open/half_open) with configurable thresholds and timeouts. Wired into K9Contract breach policy and gateway pipeline. - Wire CircuitBreaker into supervision tree and gateway pipeline - Wire K9Contract :circuit_break policy to actually trip circuits - Fix: remove @doc from private safe_verb/1 (compiler warning) - Fix: remove unused get_stealth_status_code/1 (compiler warning) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1bedc24 commit 9cad275

6 files changed

Lines changed: 1195 additions & 42 deletions

File tree

Lines changed: 392 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,392 @@
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

Comments
 (0)