|
| 1 | +# SPDX-License-Identifier: MPL-2.0 |
| 2 | +# Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> |
| 3 | +defmodule HttpCapabilityGateway.EgressPolicy do |
| 4 | + @moduledoc """ |
| 5 | + Declarative *outbound* (egress) policy schema for HTTP Capability Gateway. |
| 6 | +
|
| 7 | + Companion to the existing inbound `PolicyValidator` / `PolicyCompiler` pair. |
| 8 | + Where the inbound pipeline asks "may this client invoke this verb on this |
| 9 | + path of our backend?", the egress pipeline asks "may this estate |
| 10 | + application emit this verb to that *external* host?". |
| 11 | +
|
| 12 | + This is the contract surface needed by `hyperpolymath/neurophone` (#84-3.1) |
| 13 | + to bound what sensor-derived data leaves the device on cloud/LLM calls. |
| 14 | +
|
| 15 | + ## Status |
| 16 | +
|
| 17 | + Scaffold only. This module defines the schema, validates it, and answers |
| 18 | + `decide/3`. The actual outbound forwarder (`Proxy.egress/2`) is a separate |
| 19 | + follow-up — opening it as a thin seam here lets policy authoring start |
| 20 | + immediately without committing to a particular HTTP client. |
| 21 | +
|
| 22 | + ## Schema (DSL v1, egress section) |
| 23 | +
|
| 24 | + egress: |
| 25 | + # Deny-by-default. An empty allow list denies ALL outbound traffic. |
| 26 | + default: deny |
| 27 | + # Allow-list of egress destinations. Each entry binds a host + verb |
| 28 | + # set + capability label. The capability label is purely informational |
| 29 | + # for audit purposes today, but is the seam where chimichanga-style |
| 30 | + # attenuation will attach. |
| 31 | + allow: |
| 32 | + - host: "api.anthropic.com" |
| 33 | + verbs: [POST] |
| 34 | + capability: "llm:complete" |
| 35 | + classification: "redacted-sensor-summary" |
| 36 | +
|
| 37 | + - host: "api.openai.com" |
| 38 | + verbs: [POST] |
| 39 | + capability: "llm:complete" |
| 40 | + classification: "redacted-sensor-summary" |
| 41 | +
|
| 42 | + Hosts are matched by exact lowercase string. Wildcard / suffix matching is |
| 43 | + deliberately NOT supported in v1 — explicit listing is safer for an |
| 44 | + egress allowlist and is what neurophone's threat model wants. |
| 45 | + """ |
| 46 | + |
| 47 | + require Logger |
| 48 | + |
| 49 | + @valid_verbs ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"] |
| 50 | + |
| 51 | + defmodule Entry do |
| 52 | + @moduledoc "One allow-list entry in an egress policy." |
| 53 | + @enforce_keys [:host, :verbs] |
| 54 | + defstruct [:host, :verbs, :capability, :classification] |
| 55 | + |
| 56 | + @type t :: %__MODULE__{ |
| 57 | + host: String.t(), |
| 58 | + verbs: [String.t()], |
| 59 | + capability: String.t() | nil, |
| 60 | + classification: String.t() | nil |
| 61 | + } |
| 62 | + end |
| 63 | + |
| 64 | + @type t :: %{default: :allow | :deny, allow: [Entry.t()]} |
| 65 | + |
| 66 | + @doc """ |
| 67 | + Validate the `egress` subsection of a top-level policy map. |
| 68 | +
|
| 69 | + Returns `{:ok, normalised}` (with hosts lowercased) or `{:error, reason}`. |
| 70 | +
|
| 71 | + Accepts a missing/nil section as `{:ok, %{default: :deny, allow: []}}` so |
| 72 | + existing inbound-only policies keep working unchanged. |
| 73 | + """ |
| 74 | + @spec validate(map() | nil) :: {:ok, t()} | {:error, String.t()} |
| 75 | + def validate(nil), do: {:ok, %{default: :deny, allow: []}} |
| 76 | + |
| 77 | + def validate(egress) when is_map(egress) do |
| 78 | + with {:ok, default} <- validate_default(Map.get(egress, "default", "deny")), |
| 79 | + {:ok, entries} <- validate_allow(Map.get(egress, "allow", [])) do |
| 80 | + {:ok, %{default: default, allow: entries}} |
| 81 | + end |
| 82 | + end |
| 83 | + |
| 84 | + def validate(_), do: {:error, "egress: must be a map when present"} |
| 85 | + |
| 86 | + @doc """ |
| 87 | + Decide whether an outbound request is permitted. |
| 88 | +
|
| 89 | + Returns `{:allow, entry}` (carrying the matched allowlist entry so the |
| 90 | + caller can audit the capability label) or `{:deny, reason}`. |
| 91 | + """ |
| 92 | + @spec decide(t(), String.t(), String.t()) :: |
| 93 | + {:allow, Entry.t()} | {:deny, String.t()} |
| 94 | + def decide(policy, host, verb) when is_binary(host) and is_binary(verb) do |
| 95 | + host = String.downcase(host) |
| 96 | + |
| 97 | + case Enum.find(policy.allow, fn e -> e.host == host and verb in e.verbs end) do |
| 98 | + %Entry{} = entry -> |
| 99 | + {:allow, entry} |
| 100 | + |
| 101 | + nil -> |
| 102 | + case policy.default do |
| 103 | + :allow -> {:deny, "no allowlist entry; default=allow not yet supported"} |
| 104 | + :deny -> {:deny, "no allowlist entry for #{verb} #{host}; default=deny"} |
| 105 | + end |
| 106 | + end |
| 107 | + end |
| 108 | + |
| 109 | + # ── internals ─────────────────────────────────────────────────────────── |
| 110 | + |
| 111 | + defp validate_default("allow"), do: {:ok, :allow} |
| 112 | + defp validate_default("deny"), do: {:ok, :deny} |
| 113 | + defp validate_default(other), do: {:error, "egress.default: must be \"allow\" or \"deny\", got #{inspect(other)}"} |
| 114 | + |
| 115 | + defp validate_allow(list) when is_list(list) do |
| 116 | + list |
| 117 | + |> Enum.with_index() |
| 118 | + |> Enum.reduce_while({:ok, []}, fn {entry, idx}, {:ok, acc} -> |
| 119 | + case validate_entry(entry, idx) do |
| 120 | + {:ok, normalised} -> {:cont, {:ok, [normalised | acc]}} |
| 121 | + {:error, _} = err -> {:halt, err} |
| 122 | + end |
| 123 | + end) |
| 124 | + |> case do |
| 125 | + {:ok, entries} -> {:ok, Enum.reverse(entries)} |
| 126 | + err -> err |
| 127 | + end |
| 128 | + end |
| 129 | + |
| 130 | + defp validate_allow(_), do: {:error, "egress.allow: must be a list"} |
| 131 | + |
| 132 | + defp validate_entry(entry, idx) when is_map(entry) do |
| 133 | + with {:ok, host} <- fetch_host(entry, idx), |
| 134 | + {:ok, verbs} <- fetch_verbs(entry, idx) do |
| 135 | + {:ok, |
| 136 | + %Entry{ |
| 137 | + host: host, |
| 138 | + verbs: verbs, |
| 139 | + capability: Map.get(entry, "capability"), |
| 140 | + classification: Map.get(entry, "classification") |
| 141 | + }} |
| 142 | + end |
| 143 | + end |
| 144 | + |
| 145 | + defp validate_entry(_other, idx), do: {:error, "egress.allow[#{idx}]: must be a map"} |
| 146 | + |
| 147 | + defp fetch_host(entry, idx) do |
| 148 | + case Map.get(entry, "host") do |
| 149 | + h when is_binary(h) and h != "" -> {:ok, String.downcase(h)} |
| 150 | + _ -> {:error, "egress.allow[#{idx}].host: must be a non-empty string"} |
| 151 | + end |
| 152 | + end |
| 153 | + |
| 154 | + defp fetch_verbs(entry, idx) do |
| 155 | + case Map.get(entry, "verbs") do |
| 156 | + verbs when is_list(verbs) and verbs != [] -> |
| 157 | + case Enum.find(verbs, &(&1 not in @valid_verbs)) do |
| 158 | + nil -> {:ok, verbs} |
| 159 | + bad -> {:error, "egress.allow[#{idx}].verbs: invalid HTTP verb #{bad}"} |
| 160 | + end |
| 161 | + |
| 162 | + _ -> |
| 163 | + {:error, "egress.allow[#{idx}].verbs: must be a non-empty list"} |
| 164 | + end |
| 165 | + end |
| 166 | +end |
0 commit comments