Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions lib/http_capability_gateway/policy_compiler.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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__{
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
26 changes: 25 additions & 1 deletion lib/http_capability_gateway/policy_validator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ->
Expand Down
21 changes: 20 additions & 1 deletion lib/http_capability_gateway/safe_trust.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
122 changes: 122 additions & 0 deletions test/policy_capability_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
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
Loading