Skip to content

Commit df60f12

Browse files
committed
feat(policy): optional capability field + opt-in fail-closed exposure (audit #31, P2)
Two structural weaknesses in the policy schema, fixed without breaking back-compat: 1. NO FIRST-CLASS CAPABILITY LABEL. Adds an optional `capability` field at the route level. When set, it propagates onto `PolicyCompiler.CompiledRule` so downstream consumers (audit log, future chimichanga attenuation, the new EgressPolicy seam in audit/egress-mode-scaffold) can read it. Validator rejects empty strings and non-strings; nil/omitted is back-compat. 2. parse_exposure WAS UNCONDITIONALLY FAIL-OPEN. Existing behaviour: `parse_exposure("typo") -> :public`. Documented as intentional but combined with the `Map.get(route, "exposure", "public")` compiler default this means an omitted-or-typoed exposure silently makes the route public. Adds an opt-in `:exposure_fail_closed = true` flag that flips unknown values to `:internal` (most restrictive). Default remains fail-open for back-compat; this is opt-in for environments where confidentiality is weighted above availability (e.g., neurophone egress). Test coverage: * PolicyValidator: capability accept (non-empty, omitted) / reject (empty, non-string) * PolicyCompiler: capability propagation onto CompiledRule * SafeTrust: default-fail-open, opt-in-fail-closed, known-values-unchanged Refs: #31 (self-audit, priority 2)
1 parent 4cda8d7 commit df60f12

4 files changed

Lines changed: 173 additions & 5 deletions

File tree

lib/http_capability_gateway/policy_compiler.ex

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ defmodule HttpCapabilityGateway.PolicyCompiler do
4444
:stealth_profile, # String profile name or nil
4545
:narrative, # Optional explanation string
4646
:backend, # Target backend URL
47-
:name # Unique rule name
47+
:name, # Unique rule name
48+
:capability # Optional capability label (e.g., "admin:read"); nil if not set
4849
]
4950

5051
@type t :: %__MODULE__{
@@ -53,7 +54,8 @@ defmodule HttpCapabilityGateway.PolicyCompiler do
5354
verb: atom(),
5455
exposure: String.t(),
5556
stealth_profile: String.t() | nil,
56-
narrative: String.t() | nil
57+
narrative: String.t() | nil,
58+
capability: String.t() | nil
5759
}
5860
end
5961

@@ -355,7 +357,8 @@ defmodule HttpCapabilityGateway.PolicyCompiler do
355357
stealth_profile: Map.get(route, "stealth_profile"),
356358
narrative: Map.get(route, "narrative"),
357359
backend: Map.get(route, "backend"),
358-
name: Map.get(route, "name", "route_#{path_pattern}_#{verb_str}")
360+
name: Map.get(route, "name", "route_#{path_pattern}_#{verb_str}"),
361+
capability: Map.get(route, "capability")
359362
}
360363

361364
if is_literal do

lib/http_capability_gateway/policy_validator.ex

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@ defmodule HttpCapabilityGateway.PolicyValidator do
7474

7575
defp validate_route(route, idx) when is_map(route) do
7676
with nil <- validate_route_path(route, idx),
77-
nil <- validate_route_verbs(route, idx) do
77+
nil <- validate_route_verbs(route, idx),
78+
nil <- validate_route_capability(route, idx) do
7879
nil
7980
else
8081
error -> error
@@ -83,6 +84,29 @@ defmodule HttpCapabilityGateway.PolicyValidator do
8384

8485
defp validate_route(_route, idx), do: "governance.routes[#{idx}]: must be a map"
8586

87+
# Validate the optional `capability` field at the route level.
88+
#
89+
# The capability field is a first-class label that travels with the
90+
# route's policy decision. It is the seam where chimichanga-style
91+
# capability attenuation (and downstream audit) attach. When present,
92+
# it MUST be a non-empty string.
93+
#
94+
# Allowed shape:
95+
#
96+
# - path: "/api/admin"
97+
# verbs: [GET]
98+
# capability: "admin:read" # optional
99+
#
100+
# When omitted, the route has no capability label (back-compat with the
101+
# existing DSL). When present-but-invalid, validation fails fast.
102+
defp validate_route_capability(route, idx) do
103+
case Map.get(route, "capability") do
104+
nil -> nil
105+
cap when is_binary(cap) and cap != "" -> nil
106+
_ -> "governance.routes[#{idx}].capability: must be a non-empty string when present"
107+
end
108+
end
109+
86110
defp validate_route_path(route, idx) do
87111
case Map.get(route, "path") do
88112
nil ->

lib/http_capability_gateway/safe_trust.ex

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,9 +182,28 @@ defmodule HttpCapabilityGateway.SafeTrust do
182182
:public
183183
"""
184184
@spec parse_exposure(String.t() | nil) :: exposure_level()
185+
def parse_exposure("public"), do: :public
185186
def parse_exposure("authenticated"), do: :authenticated
186187
def parse_exposure("internal"), do: :internal
187-
def parse_exposure(_), do: :public
188+
189+
def parse_exposure(_other) do
190+
# Opt-in fail-closed mode for environments where confidentiality is
191+
# weighted above availability (e.g., neurophone egress path).
192+
#
193+
# Default behaviour (back-compat): fail-open to :public.
194+
# `:exposure_fail_closed = true`: fail-closed to :internal (most
195+
# restrictive level), so a typo in a policy file blocks traffic instead
196+
# of silently making a route public.
197+
#
198+
# Configure via:
199+
#
200+
# config :http_capability_gateway, :exposure_fail_closed, true
201+
if Application.get_env(:http_capability_gateway, :exposure_fail_closed, false) do
202+
:internal
203+
else
204+
:public
205+
end
206+
end
188207

189208
@doc """
190209
Returns the list of valid trust levels in ascending order of privilege.

test/policy_capability_test.exs

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
# Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
defmodule HttpCapabilityGateway.PolicyCapabilityTest do
4+
use ExUnit.Case, async: false
5+
6+
alias HttpCapabilityGateway.PolicyValidator
7+
alias HttpCapabilityGateway.PolicyCompiler
8+
alias HttpCapabilityGateway.SafeTrust
9+
10+
describe "PolicyValidator route-level capability field" do
11+
test "accepts a non-empty capability string" do
12+
policy = %{
13+
"dsl_version" => "1",
14+
"governance" => %{
15+
"global_verbs" => ["GET"],
16+
"routes" => [%{"path" => "/api/admin", "verbs" => ["GET"], "capability" => "admin:read"}]
17+
}
18+
}
19+
20+
assert :ok = PolicyValidator.validate(policy)
21+
end
22+
23+
test "accepts omitted capability (back-compat)" do
24+
policy = %{
25+
"dsl_version" => "1",
26+
"governance" => %{
27+
"global_verbs" => ["GET"],
28+
"routes" => [%{"path" => "/api/admin", "verbs" => ["GET"]}]
29+
}
30+
}
31+
32+
assert :ok = PolicyValidator.validate(policy)
33+
end
34+
35+
test "rejects an empty capability string" do
36+
policy = %{
37+
"dsl_version" => "1",
38+
"governance" => %{
39+
"global_verbs" => ["GET"],
40+
"routes" => [%{"path" => "/api/admin", "verbs" => ["GET"], "capability" => ""}]
41+
}
42+
}
43+
44+
assert {:error, msg} = PolicyValidator.validate(policy)
45+
assert msg =~ "capability"
46+
end
47+
48+
test "rejects a non-string capability" do
49+
policy = %{
50+
"dsl_version" => "1",
51+
"governance" => %{
52+
"global_verbs" => ["GET"],
53+
"routes" => [%{"path" => "/api/admin", "verbs" => ["GET"], "capability" => 42}]
54+
}
55+
}
56+
57+
assert {:error, msg} = PolicyValidator.validate(policy)
58+
assert msg =~ "capability"
59+
end
60+
end
61+
62+
describe "PolicyCompiler propagation of capability" do
63+
test "compiled rule carries the capability label" do
64+
policy = %{
65+
"dsl_version" => "1",
66+
"governance" => %{
67+
"global_verbs" => ["GET"],
68+
"routes" => [%{"path" => "/api/admin", "verbs" => ["GET"], "capability" => "admin:read"}]
69+
}
70+
}
71+
72+
assert {:ok, table} = PolicyCompiler.compile(policy, table_name: :pc_test, atomic_swap: false)
73+
74+
assert {:ok, rule} = PolicyCompiler.lookup(table, "/api/admin", :GET)
75+
assert rule.capability == "admin:read"
76+
77+
:ets.delete(table)
78+
end
79+
80+
test "compiled rule has nil capability when omitted" do
81+
policy = %{
82+
"dsl_version" => "1",
83+
"governance" => %{
84+
"global_verbs" => ["GET"],
85+
"routes" => [%{"path" => "/api/public", "verbs" => ["GET"]}]
86+
}
87+
}
88+
89+
assert {:ok, table} = PolicyCompiler.compile(policy, table_name: :pc_test_nil, atomic_swap: false)
90+
91+
assert {:ok, rule} = PolicyCompiler.lookup(table, "/api/public", :GET)
92+
assert rule.capability == nil
93+
94+
:ets.delete(table)
95+
end
96+
end
97+
98+
describe "SafeTrust.parse_exposure/1 fail-closed opt-in" do
99+
setup do
100+
original = Application.get_env(:http_capability_gateway, :exposure_fail_closed, false)
101+
on_exit(fn -> Application.put_env(:http_capability_gateway, :exposure_fail_closed, original) end)
102+
:ok
103+
end
104+
105+
test "default fail-open (back-compat): unknown -> :public" do
106+
Application.put_env(:http_capability_gateway, :exposure_fail_closed, false)
107+
assert SafeTrust.parse_exposure("typo") == :public
108+
end
109+
110+
test "opt-in fail-closed: unknown -> :internal" do
111+
Application.put_env(:http_capability_gateway, :exposure_fail_closed, true)
112+
assert SafeTrust.parse_exposure("typo") == :internal
113+
end
114+
115+
test "known values still parse correctly under fail-closed" do
116+
Application.put_env(:http_capability_gateway, :exposure_fail_closed, true)
117+
assert SafeTrust.parse_exposure("public") == :public
118+
assert SafeTrust.parse_exposure("authenticated") == :authenticated
119+
assert SafeTrust.parse_exposure("internal") == :internal
120+
end
121+
end
122+
end

0 commit comments

Comments
 (0)