From df60f12951cb227d6a4ad8a08371a9279712388e Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:51:44 +0100 Subject: [PATCH] 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) --- .../policy_compiler.ex | 9 +- .../policy_validator.ex | 26 +++- lib/http_capability_gateway/safe_trust.ex | 21 ++- test/policy_capability_test.exs | 122 ++++++++++++++++++ 4 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 test/policy_capability_test.exs diff --git a/lib/http_capability_gateway/policy_compiler.ex b/lib/http_capability_gateway/policy_compiler.ex index 313afdf..ac1826e 100644 --- a/lib/http_capability_gateway/policy_compiler.ex +++ b/lib/http_capability_gateway/policy_compiler.ex @@ -44,7 +44,8 @@ defmodule HttpCapabilityGateway.PolicyCompiler do :stealth_profile, # String profile name or nil :narrative, # Optional explanation string :backend, # Target backend URL - :name # Unique rule name + :name, # Unique rule name + :capability # Optional capability label (e.g., "admin:read"); nil if not set ] @type t :: %__MODULE__{ @@ -53,7 +54,8 @@ defmodule HttpCapabilityGateway.PolicyCompiler do verb: atom(), exposure: String.t(), stealth_profile: String.t() | nil, - narrative: String.t() | nil + narrative: String.t() | nil, + capability: String.t() | nil } end @@ -355,7 +357,8 @@ defmodule HttpCapabilityGateway.PolicyCompiler do stealth_profile: Map.get(route, "stealth_profile"), narrative: Map.get(route, "narrative"), backend: Map.get(route, "backend"), - name: Map.get(route, "name", "route_#{path_pattern}_#{verb_str}") + name: Map.get(route, "name", "route_#{path_pattern}_#{verb_str}"), + capability: Map.get(route, "capability") } if is_literal do diff --git a/lib/http_capability_gateway/policy_validator.ex b/lib/http_capability_gateway/policy_validator.ex index aa7f855..96371d8 100644 --- a/lib/http_capability_gateway/policy_validator.ex +++ b/lib/http_capability_gateway/policy_validator.ex @@ -74,7 +74,8 @@ defmodule HttpCapabilityGateway.PolicyValidator do defp validate_route(route, idx) when is_map(route) do with nil <- validate_route_path(route, idx), - nil <- validate_route_verbs(route, idx) do + nil <- validate_route_verbs(route, idx), + nil <- validate_route_capability(route, idx) do nil else error -> error @@ -83,6 +84,29 @@ defmodule HttpCapabilityGateway.PolicyValidator do defp validate_route(_route, idx), do: "governance.routes[#{idx}]: must be a map" + # Validate the optional `capability` field at the route level. + # + # The capability field is a first-class label that travels with the + # route's policy decision. It is the seam where chimichanga-style + # capability attenuation (and downstream audit) attach. When present, + # it MUST be a non-empty string. + # + # Allowed shape: + # + # - path: "/api/admin" + # verbs: [GET] + # capability: "admin:read" # optional + # + # When omitted, the route has no capability label (back-compat with the + # existing DSL). When present-but-invalid, validation fails fast. + defp validate_route_capability(route, idx) do + case Map.get(route, "capability") do + nil -> nil + cap when is_binary(cap) and cap != "" -> nil + _ -> "governance.routes[#{idx}].capability: must be a non-empty string when present" + end + end + defp validate_route_path(route, idx) do case Map.get(route, "path") do nil -> diff --git a/lib/http_capability_gateway/safe_trust.ex b/lib/http_capability_gateway/safe_trust.ex index 0531724..6b083fc 100644 --- a/lib/http_capability_gateway/safe_trust.ex +++ b/lib/http_capability_gateway/safe_trust.ex @@ -182,9 +182,28 @@ defmodule HttpCapabilityGateway.SafeTrust do :public """ @spec parse_exposure(String.t() | nil) :: exposure_level() + def parse_exposure("public"), do: :public def parse_exposure("authenticated"), do: :authenticated def parse_exposure("internal"), do: :internal - def parse_exposure(_), do: :public + + def parse_exposure(_other) do + # Opt-in fail-closed mode for environments where confidentiality is + # weighted above availability (e.g., neurophone egress path). + # + # Default behaviour (back-compat): fail-open to :public. + # `:exposure_fail_closed = true`: fail-closed to :internal (most + # restrictive level), so a typo in a policy file blocks traffic instead + # of silently making a route public. + # + # Configure via: + # + # config :http_capability_gateway, :exposure_fail_closed, true + if Application.get_env(:http_capability_gateway, :exposure_fail_closed, false) do + :internal + else + :public + end + end @doc """ Returns the list of valid trust levels in ascending order of privilege. diff --git a/test/policy_capability_test.exs b/test/policy_capability_test.exs new file mode 100644 index 0000000..d07e2ad --- /dev/null +++ b/test/policy_capability_test.exs @@ -0,0 +1,122 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +defmodule HttpCapabilityGateway.PolicyCapabilityTest do + use ExUnit.Case, async: false + + alias HttpCapabilityGateway.PolicyValidator + alias HttpCapabilityGateway.PolicyCompiler + alias HttpCapabilityGateway.SafeTrust + + describe "PolicyValidator route-level capability field" do + test "accepts a non-empty capability string" do + policy = %{ + "dsl_version" => "1", + "governance" => %{ + "global_verbs" => ["GET"], + "routes" => [%{"path" => "/api/admin", "verbs" => ["GET"], "capability" => "admin:read"}] + } + } + + assert :ok = PolicyValidator.validate(policy) + end + + test "accepts omitted capability (back-compat)" do + policy = %{ + "dsl_version" => "1", + "governance" => %{ + "global_verbs" => ["GET"], + "routes" => [%{"path" => "/api/admin", "verbs" => ["GET"]}] + } + } + + assert :ok = PolicyValidator.validate(policy) + end + + test "rejects an empty capability string" do + policy = %{ + "dsl_version" => "1", + "governance" => %{ + "global_verbs" => ["GET"], + "routes" => [%{"path" => "/api/admin", "verbs" => ["GET"], "capability" => ""}] + } + } + + assert {:error, msg} = PolicyValidator.validate(policy) + assert msg =~ "capability" + end + + test "rejects a non-string capability" do + policy = %{ + "dsl_version" => "1", + "governance" => %{ + "global_verbs" => ["GET"], + "routes" => [%{"path" => "/api/admin", "verbs" => ["GET"], "capability" => 42}] + } + } + + assert {:error, msg} = PolicyValidator.validate(policy) + assert msg =~ "capability" + end + end + + describe "PolicyCompiler propagation of capability" do + test "compiled rule carries the capability label" do + policy = %{ + "dsl_version" => "1", + "governance" => %{ + "global_verbs" => ["GET"], + "routes" => [%{"path" => "/api/admin", "verbs" => ["GET"], "capability" => "admin:read"}] + } + } + + assert {:ok, table} = PolicyCompiler.compile(policy, table_name: :pc_test, atomic_swap: false) + + assert {:ok, rule} = PolicyCompiler.lookup(table, "/api/admin", :GET) + assert rule.capability == "admin:read" + + :ets.delete(table) + end + + test "compiled rule has nil capability when omitted" do + policy = %{ + "dsl_version" => "1", + "governance" => %{ + "global_verbs" => ["GET"], + "routes" => [%{"path" => "/api/public", "verbs" => ["GET"]}] + } + } + + assert {:ok, table} = PolicyCompiler.compile(policy, table_name: :pc_test_nil, atomic_swap: false) + + assert {:ok, rule} = PolicyCompiler.lookup(table, "/api/public", :GET) + assert rule.capability == nil + + :ets.delete(table) + end + end + + describe "SafeTrust.parse_exposure/1 fail-closed opt-in" do + setup do + original = Application.get_env(:http_capability_gateway, :exposure_fail_closed, false) + on_exit(fn -> Application.put_env(:http_capability_gateway, :exposure_fail_closed, original) end) + :ok + end + + test "default fail-open (back-compat): unknown -> :public" do + Application.put_env(:http_capability_gateway, :exposure_fail_closed, false) + assert SafeTrust.parse_exposure("typo") == :public + end + + test "opt-in fail-closed: unknown -> :internal" do + Application.put_env(:http_capability_gateway, :exposure_fail_closed, true) + assert SafeTrust.parse_exposure("typo") == :internal + end + + test "known values still parse correctly under fail-closed" do + Application.put_env(:http_capability_gateway, :exposure_fail_closed, true) + assert SafeTrust.parse_exposure("public") == :public + assert SafeTrust.parse_exposure("authenticated") == :authenticated + assert SafeTrust.parse_exposure("internal") == :internal + end + end +end