From fe828ac385bdc76fedfe375707d7533a9e042528 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Thu, 8 Oct 2026 10:22:48 -0400 Subject: [PATCH 1/4] feat(s136): complete slice 136 (page authentication, and a regulated boot that refuses to serve without it) The web pages carried no authentication: anyone who could reach the port could answer approvals, start an MCP stdio command from /mcp, download the keys from the settings export and edit the gateway allow-list, and the hardened image binds every interface. - TRINITY_WEB_AUTH in {none, oidc, local_token}. Unset is none on a loopback bind and a boot refusal on any other bind, in every profile. :regulated refuses none and local_token on a non-loopback bind, and refuses x-forwarded-proto trust without TRINITY_TRUSTED_PROXY. - oidc: an OpenID Connect login (code + PKCE S256, state, nonce, iss) with oidcc 3.9 and oidcc_plug 0.5.1 pinned past their 2026 CVEs; roles view, approve and administer mapped from Keycloak, Entra (with overage refused), Okta and generic claims. - Every route and live_session is guarded (a plug before the router and on_mount), held by a route census; privileged routes need administer. - A Host allow-list refuses a foreign Host before anything routes (DNS rebinding); check_origin is an explicit list in every profile. - Approval and privileged-route receipts carry the principal's sub, iss and role, on a new access chain scope. - local_token for a LAN outside :regulated: exchanged once for a SameSite=Strict cookie, constant-time, rate-limited, with a shared-token banner. Signed-off-by: Ayla Croft --- CHANGELOG.md | 25 ++ README.md | 7 + THIRD_PARTY_LICENSES.md | 5 +- VERSIONS.md | 2 + ci/headless/Containerfile | 7 +- config/config.exs | 7 + config/dev.exs | 1 - config/runtime.exs | 43 ++- config/test.exs | 12 + docs/07-security-model.md | 71 ++++- docs/mcp-server.md | 17 +- .../customer-responsibility-matrix.md | 5 + lib/trinity.ex | 2 + lib/trinity/application.ex | 69 +++++ lib/trinity/effects/boot.ex | 5 +- lib/trinity/permissions/gate.ex | 46 ++- lib/trinity/profile.ex | 58 ++++ lib/trinity/receipts.ex | 10 + lib/trinity/skills/manager.ex | 9 +- lib/trinity/versions.ex | 14 + lib/trinity/web_auth.ex | 166 +++++++++++ lib/trinity_web.ex | 2 +- lib/trinity_web/auth.ex | 211 ++++++++++++++ lib/trinity_web/auth/claims.ex | 105 +++++++ lib/trinity_web/auth/gate.ex | 75 +++++ lib/trinity_web/auth/oidc.ex | 126 ++++++++ lib/trinity_web/auth/policy.ex | 83 ++++++ lib/trinity_web/auth/principal.ex | 74 +++++ lib/trinity_web/auth/require_role.ex | 68 +++++ lib/trinity_web/auth/sessions.ex | 121 ++++++++ lib/trinity_web/components/layouts.ex | 22 ++ .../controllers/auth_controller.ex | 147 ++++++++++ lib/trinity_web/controllers/auth_html.ex | 8 + .../controllers/auth_html/denied.html.heex | 11 + .../controllers/auth_html/token.html.heex | 30 ++ lib/trinity_web/endpoint.ex | 13 +- lib/trinity_web/live/permissions_live.ex | 10 +- lib/trinity_web/live/session_live/show.ex | 10 +- lib/trinity_web/live/skills_live.ex | 14 +- lib/trinity_web/plugs/host_allow_list.ex | 71 +++++ lib/trinity_web/plugs/session.ex | 43 +++ lib/trinity_web/router.ex | 62 +++- mix.exs | 5 + mix.lock | 3 + test/support/fake_oidc_issuer.ex | 169 +++++++++++ test/support/raw_http.ex | 67 +++++ test/support/web_auth_helpers.ex | 59 ++++ test/trinity/web_auth_test.exs | 133 +++++++++ .../trinity_web/auth/access_receipts_test.exs | 139 +++++++++ .../auth/boot_refusal_node_test.exs | 202 +++++++++++++ .../trinity_web/auth/host_and_origin_test.exs | 127 ++++++++ test/trinity_web/auth/local_token_test.exs | 128 ++++++++ test/trinity_web/auth/oidc_login_test.exs | 230 +++++++++++++++ test/trinity_web/auth/policy_census_test.exs | 102 +++++++ test/trinity_web/auth/roles_test.exs | 136 +++++++++ test/trinity_web/auth/route_census_test.exs | 273 ++++++++++++++++++ .../auth/session_lifecycle_test.exs | 101 +++++++ test/trinity_web/live/approval_live_test.exs | 4 +- test/trinity_web/live/skill_changes_test.exs | 10 +- 59 files changed, 3735 insertions(+), 40 deletions(-) create mode 100644 lib/trinity/web_auth.ex create mode 100644 lib/trinity_web/auth.ex create mode 100644 lib/trinity_web/auth/claims.ex create mode 100644 lib/trinity_web/auth/gate.ex create mode 100644 lib/trinity_web/auth/oidc.ex create mode 100644 lib/trinity_web/auth/policy.ex create mode 100644 lib/trinity_web/auth/principal.ex create mode 100644 lib/trinity_web/auth/require_role.ex create mode 100644 lib/trinity_web/auth/sessions.ex create mode 100644 lib/trinity_web/controllers/auth_controller.ex create mode 100644 lib/trinity_web/controllers/auth_html.ex create mode 100644 lib/trinity_web/controllers/auth_html/denied.html.heex create mode 100644 lib/trinity_web/controllers/auth_html/token.html.heex create mode 100644 lib/trinity_web/plugs/host_allow_list.ex create mode 100644 lib/trinity_web/plugs/session.ex create mode 100644 test/support/fake_oidc_issuer.ex create mode 100644 test/support/raw_http.ex create mode 100644 test/support/web_auth_helpers.ex create mode 100644 test/trinity/web_auth_test.exs create mode 100644 test/trinity_web/auth/access_receipts_test.exs create mode 100644 test/trinity_web/auth/boot_refusal_node_test.exs create mode 100644 test/trinity_web/auth/host_and_origin_test.exs create mode 100644 test/trinity_web/auth/local_token_test.exs create mode 100644 test/trinity_web/auth/oidc_login_test.exs create mode 100644 test/trinity_web/auth/policy_census_test.exs create mode 100644 test/trinity_web/auth/roles_test.exs create mode 100644 test/trinity_web/auth/route_census_test.exs create mode 100644 test/trinity_web/auth/session_lifecycle_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 39a9690..2298ae9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,31 @@ evidence for each increment is retained by the maintainers and summarised here. ## 2026-10-08 +### `slice/136`: the web pages ask who is there + +Every page and route now answers only someone who has signed in with the role it needs, or, on the +desktop, a browser on the same machine. Three roles: `view` reads and converses, `approve` decides +approvals, `administer` changes what Trinity is (MCP servers, gateways, settings and keys, the +export, the dashboards). Signing in is an OpenID Connect login at your identity provider, with roles +read from Keycloak, Microsoft Entra ID, Okta or a claim you name; a sign-in whose roles cannot be +read is refused with a page that says why. On a home network a single shared token can stand in, +and every page then says that it identifies a token and not a person. + +A request whose Host is not this machine's own is refused before anything else sees it, and a +websocket from another site's page is refused; this is what keeps the desktop, which has no login, +from being read by a web page through DNS rebinding. A server bound to anything but the loopback +refuses to start until it is told how the pages authenticate, and the regulated profile refuses a +shared token or no login there, and refuses to believe a forwarded protocol header unless a trusted +proxy is declared. Sessions end on the server when revoked, after 30 idle minutes, after 12 hours, +or on sign-out. Every approval now records who gave it, and every approval and privileged action is +a signed receipt carrying the person's subject, issuer and the role used. + +Verified by the test suite: a census of every route and every page event, an unauthenticated sweep +of every route, the login against an identity provider running inside the test, the role and +receipt checks through the pages, and the refusals at boot in separate operating system processes. +An Entra tenant with a user in more than 200 groups, and the desktop build refusing a foreign Host, +are listed for manual verification. + ### `slice/100`: Trinity as a desktop application The Tauri window now does what a desktop application is expected to do. A tray icon shows whether diff --git a/README.md b/README.md index f53a544..a2c3d1e 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,13 @@ enforces it. protected server's `401` with "authorize" on the `/mcp` page and keeps the token it obtains. Trinity issues no production authority: the personal issuer refuses to start under an external authority adapter, by construction (`docs/07-security-model.md`). +- **Asks who is at the page.** Every page answers only someone signed in with the role it needs + (`view`, `approve`, `administer`) through an OpenID Connect login at your identity provider, + or, on the desktop, a browser on the same machine: a request naming any other host is refused + before it is routed, and a websocket from another site's page is refused. A server bound + beyond the loopback will not start until it is told how the pages authenticate. Every approval + records who gave it, as a signed receipt with their subject, issuer and role + (`docs/07-security-model.md`, "Web pages"). - **Reachable from elsewhere.** A gateway is a channel Trinity answers from: the adapter carries text, and everything else (the session, the gate, the receipts) is the same machinery the desktop uses. A sender Trinity does not know gets a pairing code shown on the `/gateways` page diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index b56273d..0595d6a 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -4,7 +4,7 @@ hand: the gate regenerates it and fails when this file and the bill disagree. --> # Third-party licences -134 components, derived from `sbom.cdx.json`, the CycloneDX bill this project's +137 components, derived from `sbom.cdx.json`, the CycloneDX bill this project's quality gate generates on every commit. This file and that bill are the same data rendered twice, so they cannot disagree. @@ -97,6 +97,8 @@ it, and a reader should not infer from an entry here that the component is in a | `oban` | `2.24.1` | Apache-2.0 | bom | | `oban_met` | `1.3.1` | Apache-2.0 | bom | | `oban_web` | `2.13.0` | Apache-2.0 | bom | +| `oidcc` | `3.9.0` | Apache-2.0 | bom | +| `oidcc_plug` | `0.5.1` | Apache-2.0 | bom | | `optimus` | `0.6.1` | MIT | bom | | `owl` | `0.13.1` | Apache-2.0 | bom | | `pgvector` | `0.4.1` | MIT | bom | @@ -137,6 +139,7 @@ it, and a reader should not infer from an entry here that the component is in a | `telemetry` | `1.4.2` | Apache-2.0 | bom | | `telemetry_metrics` | `1.2.0` | Apache-2.0 | bom | | `telemetry_poller` | `1.3.0` | Apache-2.0 | bom | +| `telemetry_registry` | `0.3.2` | Apache-2.0 | bom | | `text_diff` | `0.1.0` | MIT | bom | | `texture` | `1.2.1` | Apache-2.0 | bom | | `thousand_island` | `1.5.0` | MIT | bom | diff --git a/VERSIONS.md b/VERSIONS.md index 4cf769e..88d8755 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -93,6 +93,8 @@ never pin a version hex marks as retired or vulnerable. | `luerl` | ~> 1.5 | ✅ in `mix.lock` | Lua 5.3 implemented in Erlang, for the in-VM sandbox: a skill's script runs inside this node with no OS process, no file handle and no socket. Apache-2.0, **no dependencies of its own**; 1.5.1 measured on hex.pm 2026-09-24, released 2025-12-03. `:luerl_sandbox` is a module of this package and not a package of its own, which slice 110's SLICE.md assumed wrongly. Its `max_reductions` is polled with a 100 ms sleep between checks, so it cannot bound a tight loop below tens of millions of reductions; Trinity runs the interpreter under its own process with `max_heap_size` and a timeout instead, and reports reductions as a statistic (slice 110 NOTES F1). Added at Slice 110. | | `stream_data` | ~> 1.4 | ✅ in `mix.lock` | Property-based testing: generated input for `test/property`. Apache-2.0; 1.4.0 measured on hex.pm 2026-09-24, released 2026-07-14. Test only, so it ships in no artifact. Elixir has no focused fuzzing library and property-based testing is what the OpenSSF gold criterion `dynamic_analysis` accepts in its place, which is why this is a dependency rather than a habit. Added at Slice 004. | | `jose` | ~> 1.11 | ✅ in `mix.lock` | JOSE (JWK, JWS, JWKS) over OTP's crypto, for the MCP resource server's token validation and the personal profile's embedded authorization server. MIT; 1.11.12 measured on hex.pm 2026-09-22. Not joken, a layer over it this tree does not need. Added at Slice 062. | +| `oidcc` | ~> 3.9 | ✅ in `mix.lock` | OpenID Connect relying party for the web pages' login (`TrinityWeb.Auth.OIDC`): authorization code with PKCE S256, `state`, `nonce`, ID token validation. EEF Security WG, OpenID Certified; Apache-2.0. 3.9.0 measured on hex.pm 2026-10-08, released 2026-08-30; it fixes CVE-2026-75759 (GHSA-533g-4vf3-xwrj, high: an encrypted but unsigned ID token was accepted, affected >= 3.2.0-beta.1 and < 3.9.0), so the floor is 3.9 and a test asserts it. Brings `telemetry_registry` 0.3.2 (Apache-2.0). Added at Slice 136. | +| `oidcc_plug` | ~> 0.5.1 | ✅ in `mix.lock` | Plugs over oidcc for the login's redirect and callback. Apache-2.0. 0.5.1 measured on hex.pm 2026-10-08, released 2026-08-04; 0.5.0 fixed CVE-2026-66884 (GHSA-fg66-w5gp-22cr, state check broken) and CVE-2026-66883 (GHSA-w5r8-m75h-98fc, user agent binding inert) and is itself retired on hex.pm (invalid igniter requirement), so the floor is 0.5.1 and a test asserts it. Ships no LiveView `on_mount`; `TrinityWeb.Auth` is that. Added at Slice 136. | | `oban_web` | ~> 2.13 | ✅ in `mix.lock` | The Oban dashboard, a LiveView mounted at /oban (dev, and behind `config :trinity, :oban_web, true` elsewhere). Apache-2.0 on hex.pm since the 2.12 line (2.13.0 measured 2026-09-22); the plan of 2026-09-05 wrote it in when it was commercial. Added at Slice 050. | | `req` | ~> 0.5 | ✅ in `mix.lock` | HTTP client. | | `req_llm` | ~> 1.22 | ✅ in `mix.lock` | Provider layer (streaming, tools, structured output, usage). ⚠️ The pin was `~> 1.10` against a recorded latest of 1.10.0; the real latest was twelve minors ahead. Check event shapes against the current version at Slice 011, not against this file's prose. Added at Slice 011. | diff --git a/ci/headless/Containerfile b/ci/headless/Containerfile index 293be5d..b932283 100644 --- a/ci/headless/Containerfile +++ b/ci/headless/Containerfile @@ -7,10 +7,13 @@ # the libc it was built against. No desktop shell, no Burrito: `mix release headless`. # # docker build -f ci/headless/Containerfile -t trinity-headless:local . -# docker run --rm -p 4000:4000 -e TRINITY_MCP_SERVER_TOKEN=... trinity-headless:local +# docker run --rm -p 4000:4000 -e TRINITY_MCP_SERVER_TOKEN=... -e TRINITY_WEB_AUTH=oidc \ +# -e TRINITY_WEB_AUTH_ISSUER=... -e TRINITY_WEB_AUTH_CLIENT_ID=... trinity-headless:local # # The container binds 0.0.0.0 inside its own network namespace (TRINITY_BIND) so the published -# port reaches it; the bearer is required on /mcp whatever the bind. The data directory is +# port reaches it; the bearer is required on /mcp whatever the bind. Slice 136: 0.0.0.0 is not the +# loopback, so the node refuses to boot until TRINITY_WEB_AUTH says how the web pages +# authenticate (docs/mcp-server.md). The data directory is # /data/trinity (XDG_DATA_HOME=/data, a volume): the databases, the receipt keys, the # envelope key, the generated token. FROM hexpm/elixir:1.20.4-erlang-28.5.0.5-debian-bookworm-20260824-slim AS build diff --git a/config/config.exs b/config/config.exs index 2d4819d..f8fa9e6 100644 --- a/config/config.exs +++ b/config/config.exs @@ -194,8 +194,15 @@ if System.get_env("TRINITY_DB", "sqlite") == "sqlite" do end # Configure the endpoint +# +# Slice 136: `check_origin` is an explicit list in every environment, never `false` and never +# `:conn` (which compares the Origin with the request's own Host, a value a DNS rebinding page +# controls). The loopback names here; config/runtime.exs adds the configured host in :prod. A +# list entry with no scheme and no port matches any scheme and port, which the desktop's +# ephemeral port needs. config :trinity, TrinityWeb.Endpoint, url: [host: "localhost"], + check_origin: ["//localhost", "//127.0.0.1", "//[::1]"], adapter: Bandit.PhoenixAdapter, render_errors: [ formats: [html: TrinityWeb.ErrorHTML, json: TrinityWeb.ErrorJSON], diff --git a/config/dev.exs b/config/dev.exs index 90d86a3..619e750 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -23,7 +23,6 @@ config :trinity, TrinityWeb.Endpoint, # Binding to loopback ipv4 address prevents access from other machines. # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. http: [ip: {127, 0, 0, 1}], - check_origin: false, code_reloader: true, debug_errors: true, secret_key_base: "pEceiX84jrYwBFOP3ahGMX2Fwkza/3kU0AHhXBIi2JNnbu7muM20VzLmWLCddSwi", diff --git a/config/runtime.exs b/config/runtime.exs index a50bc2f..489376a 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -109,6 +109,42 @@ if config_env() != :test and auth_profile not in [nil, ""] do client_metadata_url: present.("TRINITY_MCP_AUTH_CLIENT_METADATA_URL") end +# Slice 136: who the web pages answer (`Trinity.WebAuth`). `TRINITY_WEB_AUTH` is `none`, `oidc` or +# `local_token`; unset, it is `none` on a loopback bind and a refusal to boot on any other. +# `oidc` logs in at `TRINITY_WEB_AUTH_ISSUER` (default: the MCP authorization issuer, the one +# `:regulated` already requires) as `TRINITY_WEB_AUTH_CLIENT_ID` with `TRINITY_WEB_AUTH_CLIENT_SECRET` +# when the client is confidential; the callback is `TRINITY_WEB_AUTH_REDIRECT_URI`, else +# `https:///auth/callback`. `TRINITY_WEB_AUTH_ROLE_CLAIM` names the claim roles are read +# from when it is not one of the four known shapes. `local_token` takes `TRINITY_WEB_AUTH_TOKEN` +# (at least 22 characters). The suite sets `config :trinity, :web_auth` itself. +if config_env() != :test do + present = fn name -> + case System.get_env(name) do + nil -> nil + "" -> nil + value -> value + end + end + + int = fn name, default -> + case present.(name) do + nil -> default + value -> String.to_integer(value) + end + end + + config :trinity, :web_auth, + mode: present.("TRINITY_WEB_AUTH"), + issuer: present.("TRINITY_WEB_AUTH_ISSUER") || present.("TRINITY_MCP_AUTH_ISSUER"), + client_id: present.("TRINITY_WEB_AUTH_CLIENT_ID"), + client_secret: present.("TRINITY_WEB_AUTH_CLIENT_SECRET"), + redirect_uri: present.("TRINITY_WEB_AUTH_REDIRECT_URI"), + role_claim: present.("TRINITY_WEB_AUTH_ROLE_CLAIM"), + token: present.("TRINITY_WEB_AUTH_TOKEN"), + idle_timeout_ms: int.("TRINITY_WEB_AUTH_IDLE_TIMEOUT_MS", 1_800_000), + absolute_timeout_ms: int.("TRINITY_WEB_AUTH_ABSOLUTE_TIMEOUT_MS", 43_200_000) +end + # Slice 013. `TRINITY_FAKE_PROVIDER=1 mix phx.server` runs the chat on the scripted provider: # the registry becomes the fake's two entries and a fresh stream answers with its markdown # demo, so the UI can be exercised and screenshotted with no key and no egress. Development @@ -214,8 +250,8 @@ if config_env() == :prod do # default: a server on a LAN is the operator's decision, made by setting it) on `PORT`, # 4000 by default, since nobody reads an ephemeral port off a headless machine. The bearer # on /mcp (`TRINITY_MCP_SERVER_TOKEN`, or the generated token file) is required whatever - # the bind; the web pages carry no authentication yet, which is why the default stays on - # the loopback (docs/mcp-server.md). + # the bind. Slice 136: a bind other than the loopback needs `TRINITY_WEB_AUTH` set, or the + # node refuses to boot (`Trinity.WebAuth`). {bind_ip, bind_port} = if System.get_env("TRINITY_MODE") == "headless" do ip = @@ -234,9 +270,12 @@ if config_env() == :prod do {{127, 0, 0, 1}, desktop_port} end + # Slice 136: the websocket origins are the loopback names and the configured host, an explicit + # list, in every profile (config/config.exs says why never `false` and never `:conn`). config :trinity, TrinityWeb.Endpoint, url: [host: host, port: 443, scheme: "https"], http: [ip: bind_ip, port: bind_port], + check_origin: Enum.uniq(["//localhost", "//127.0.0.1", "//[::1]", "//" <> host]), secret_key_base: secret_key_base # ## SSL Support diff --git a/config/test.exs b/config/test.exs index f9c1319..c0d7e53 100644 --- a/config/test.exs +++ b/config/test.exs @@ -229,6 +229,18 @@ config :trinity, TrinityWeb.Endpoint, secret_key_base: "PAlioLgquSvnIrD4YjwqUt4LEP1x1E5d56Z7KY/QhgmyRUFR9ynpk3oz/hITAYwN", server: true +# Slice 136: the suite binds the loopback, so the web pages run in `:none` unless a test says +# otherwise (those tests are synchronous and put their own `:web_auth`). `www.example.com` is the +# Host `Phoenix.ConnTest.build_conn/0` sends; it is the suite's configured host for the Host +# allow-list, as `PHX_HOST` is a deployment's. The OIDC tests run their issuer on a loopback +# port over plain HTTP, which oidcc refuses unless told; nothing outside this file tells it. +config :trinity, :web_auth, + extra_hosts: ["www.example.com"], + allow_unsafe_http: true, + idle_timeout_ms: 1_800_000, + absolute_timeout_ms: 43_200_000, + token_rate_window_ms: 2_000 + # Slice 090: the LiveDashboard routes are compiled in the test environment too. They are # `compile_env`-gated and were dev-only, which meant the custom sessions page had no test and no # way to be screenshotted without running a dev server against a dev database. A route that exists diff --git a/docs/07-security-model.md b/docs/07-security-model.md index 3a7aa42..b075eee 100644 --- a/docs/07-security-model.md +++ b/docs/07-security-model.md @@ -151,8 +151,9 @@ root loads as any other and is not scanned (a follow-up in the slice's NOTES). run's summary shows the denial. A rule the owner writes beforehand is what lets a scheduled task write. - **The curator deletes nothing.** Marking stale is a query receipt; archiving is an effect receipt written by the curator itself (as 041's promotion writes its own); both on the persona's memory scope. -- **Oban's dashboard** at `/oban` is in the browser pipeline with no authentication, as every page is - until 062; it is mounted in development and where `config :trinity, :oban_web` says so. +- **Oban's dashboard** at `/oban` is mounted in development and where `config :trinity, :oban_web` + says so. Since slice 136 it needs the `administer` role, in its pipeline and again on mount (see + "Web pages"). ## MCP client (Slice 060, as built) @@ -201,7 +202,8 @@ root loads as any other and is not scanned (a follow-up in the slice's NOTES). nothing. Since 062 that module is `Trinity.MCP.Auth.Local`, the `:local` profile behind the authorization boundary (next section), and the plug authorizes ahead of the transport; a refusal is `401` with a challenge rather than 061's `403`. Loopback is the default bind; a wider bind is the - operator's setting and belongs behind a proxy that authenticates the web pages too. + operator's setting, and since slice 136 it needs the web pages' own authentication configured + (see "Web pages"). - **Approvals over the wire are the owner's, never the client's.** A held call answers `input_required` with a `requestState` sealed by `Trinity.MCP.Server.Envelope`: AES-256-GCM under `/mcp-state.key`, binding the approval id, the session, the call id, the tool, a digest of @@ -279,6 +281,69 @@ regulated effect. `client_id` (pre-registered at the enterprise server), by a Client ID Metadata Document URL, or by registering once when the server offers it and `dcr: true`. +## Web pages (Slice 136, as built) + +Every page and route answers only an authenticated principal with the right role, or, on a loopback +node with no login, a request whose Host is the machine's own. NIST SP 800-53 IA-2, AC-3, AC-6 and +AC-6(9) are the controls; finding F-132-1 (slice 132) is what opened it. + +- **Three modes** (`Trinity.WebAuth`, `TRINITY_WEB_AUTH`). `none` on a loopback bind (the desktop, + development, the suite): the principal is the machine's owner with every role. `oidc`: a login at + an external issuer (by default the one `:regulated` already requires for the MCP server). + `local_token`, outside `:regulated` only: one shared token of at least 128 bits. Unset is `none` + on the loopback and **a refusal to boot on any other bind, in every profile**; `none` set + explicitly on a wider bind outside `:regulated` boots with a warning and the boot receipt says so. +- **Before routing.** `TrinityWeb.Plugs.HostAllowList` is first in the endpoint: a `Host` that is not + `localhost`, `127.0.0.1`, `::1` or the configured host is `403` before static files, the MCP server + or the router see it (DNS rebinding; Phoenix and Bandit check no Host). `TrinityWeb.Auth.Gate` + follows the session: no principal is a redirect to `/auth/login` for a page and `401` for anything + else, before routing, so a `POST` to a path with only a `GET` route says nothing about which paths + exist. The exemptions are the login itself and slice 062's protocol endpoints (`/.well-known/*`, + `POST /oauth/token`, `POST /oauth/register`), each named in the route census test. +- **Roles** (`TrinityWeb.Auth.Principal`): `view` reads and converses; `approve` decides approvals; + `administer` changes what Trinity is or what leaves the machine (MCP servers, gateways, settings + and keys, personas, rules, tasks, the export, the dashboards, the OAuth owner pages). `approve` and + `administer` imply `view`; `administer` does not imply `approve`. Routes carry their role in the + router (`TrinityWeb.Auth.RequireRole`); every `live_session` mounts through `TrinityWeb.Auth`, + which checks the session again on every `handle_params` and every `handle_event`, the event's role + from `TrinityWeb.Auth.Policy` (an event it does not name needs `administer`; a census test fails on + an event nobody classified). +- **Sessions are the server's.** The cookie (signed and encrypted) holds a random id; the principal, + login time and last-seen time live in `TrinityWeb.Auth.Sessions`. Revocation marks the row and + broadcasts `disconnect` to the session's sockets; idle (30 minutes) and absolute (12 hours) + timeouts are checked there; logout revokes. A restart ends every session. +- **OpenID Connect** on `oidcc` 3.9 and `oidcc_plug` 0.5.1 (floors asserted by a test; both carry + 2026 security fixes): authorization code with PKCE S256 only (a login is refused when the issuer + offers no S256, rather than falling back to `plain`), `state`, `nonce`, `iss` checked on the ID + token and on the response when the issuer advertises RFC 9207, a callback with no stored + authorization session refused before the code is redeemed. Roles come from the ID token's claims + only: Keycloak `realm_access.roles` and `resource_access..roles`, Entra `roles`, Okta + `groups`, generic `roles`, or one claim the operator names. A token with no Trinity role, or whose + role claim was moved out by Entra's group overage (`_claim_names`), is a refused login with a page + that says why, never a session with no roles. +- **The shared token** is exchanged once for a session cookie that is `SameSite=Strict` (and + `Secure` over TLS); the comparison is over SHA-256 digests with `Plug.Crypto.secure_compare/2`; + one guess per address per window, `429` inside it; the parameter is `secret`, which the log + filter names. Every page carries the banner "Shared token, no per-user identity", and receipts + name the principal `local_token`, not a person. +- **Who approved.** An approval decided on a page records the principal on its row (`decided_by`) + and on a signed receipt on the `access` chain with `sub`, `iss`, the mode and the role used, + written before the row changes. Privileged routes and events (everything needing more than + `view`) are receipted there too, used and refused. Outside `none`, an act that cannot be receipted + is refused; on the loopback with no login the failure is logged and the act stands, so the + desktop stays usable with its signer down. +- **Websockets.** `check_origin` is an explicit list in every environment (the loopback names, and + the configured host in a release), never `false` and never `:conn`; `:regulated` refuses to boot + otherwise. Phoenix dispatches sockets ahead of every endpoint plug, so the origin list, not the + Host plug, is what refuses a foreign page there. +- **`:regulated` refuses to boot** on a non-loopback bind with `none` or `local_token`, and with + `force_ssl`'s `rewrite_on: [:x_forwarded_proto]` while `TRINITY_TRUSTED_PROXY` is not `true` + (believing that header is only safe behind a proxy that strips and sets it). +- **What is not here.** TLS termination and mTLS (the deployment's); a bare identity header from a + proxy (never trusted); a login on the desktop. A reverse proxy on the same host that forwards to a + loopback `none` node with the configured host passes the Host check, which is why a wider audience + belongs on `oidc` and why `:regulated` asks for it. + ## Secrets - Env vars in dev; OS keychain via `Trinity.Secrets` from Slice 100 (Tauri stronghold/store or a keychain NIF). diff --git a/docs/mcp-server.md b/docs/mcp-server.md index 37846f9..486638c 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -147,9 +147,12 @@ the same data directory (docs/07 says what it binds and what the replay defence `mix release headless` assembles the tree as an ordinary OTP release: no desktop shell, no self-extracting binary. `TRINITY_MODE=headless` makes it a server: it binds `TRINITY_BIND` (the loopback by default; a LAN address is your decision, made by setting it) on `PORT` (4000 by default) -and serves the web pages and `/mcp`. The bearer is required on `/mcp` whatever the bind; the web -pages carry no authentication yet, which is why the default bind is the loopback and why a LAN bind -belongs behind a reverse proxy that authenticates. +and serves the web pages and `/mcp`. The bearer is required on `/mcp` whatever the bind. The web +pages answer only a principal (slice 136, docs/07 "Web pages"): on the loopback with no login set +they answer this machine's own browser, held to it by the Host allow-list and the websocket origin +list; on any other bind `TRINITY_WEB_AUTH` must be set, to `oidc` (a login at your identity +provider) or, outside `TRINITY_PROFILE=regulated`, `local_token` (one shared token), or the node +refuses to boot and names the variable. A container: `ci/headless/Containerfile` builds the release on the Elixir image whose toolchain is the tree's and runs it on a slim Debian, the data directory on the `/data` volume: @@ -157,9 +160,15 @@ the tree's and runs it on a slim Debian, the data directory on the `/data` volum ``` docker build -f ci/headless/Containerfile -t trinity-headless:local . docker run --rm -p 4000:4000 -v trinity-data:/data \ - -e TRINITY_MCP_SERVER_TOKEN= trinity-headless:local + -e TRINITY_MCP_SERVER_TOKEN= \ + -e TRINITY_WEB_AUTH=oidc -e TRINITY_WEB_AUTH_ISSUER=https://idp.example \ + -e TRINITY_WEB_AUTH_CLIENT_ID=trinity -e TRINITY_WEB_AUTH_CLIENT_SECRET= \ + -e PHX_HOST=trinity.example trinity-headless:local ``` +The image binds `0.0.0.0` inside its own network namespace, which is not the loopback, so it +refuses to start until `TRINITY_WEB_AUTH` says how the pages authenticate. + A systemd unit for the release on a host: ```ini diff --git a/docs/regulated/customer-responsibility-matrix.md b/docs/regulated/customer-responsibility-matrix.md index 74ff00a..6189fed 100644 --- a/docs/regulated/customer-responsibility-matrix.md +++ b/docs/regulated/customer-responsibility-matrix.md @@ -18,6 +18,11 @@ should not plan as though it does. | Choosing the profile | **Yes**, three exist: `:local`, `:production`, `:personal` at `config/runtime.exs:92-98` | **Yes.** `TRINITY_MCP_AUTH_PROFILE` is yours to set | No | | Mapping a subject to an authorization decision | Scopes only, `lib/trinity/mcp/auth/scopes.ex` | **Yes.** Entitlement policy is yours | No | | Token revocation and session lifetime | Introspection when enabled, `config/runtime.exs:105` | **Yes** | No | +| Authenticating a person on the web pages (IA-2) | **Yes**, since slice 136: an OpenID Connect login, `lib/trinity_web/auth/oidc.ex`; `:regulated` refuses to boot on a non-loopback bind without it, `lib/trinity/profile.ex` (`check_web_auth/3`) | **Yes.** Provide the issuer, register the client (`TRINITY_WEB_AUTH_*`), and grant the roles `view`, `approve`, `administer` in a claim the ID token carries | No | +| Enforcing a role on each page and action (AC-3, AC-6) | **Yes.** `lib/trinity_web/auth/require_role.ex`, `lib/trinity_web/auth/policy.ex` | **Yes.** Who holds which role is your entitlement policy | No | +| Logging the use of privileged functions (AC-6(9)) | **Yes.** Every approval and every privileged route or event is a signed receipt on the `access` chain with `sub`, `iss` and the role used, `lib/trinity_web/auth.ex` | **Yes.** Retain and review the chain | No | +| Refusing a request whose Host is not this deployment's | **Yes.** `lib/trinity_web/plugs/host_allow_list.ex`; set `PHX_HOST` | **Yes.** Set the host your users reach | No | +| Believing `x-forwarded-proto` | Only when `TRINITY_TRUSTED_PROXY=true`; `:regulated` refuses to boot otherwise | **Yes.** Your proxy must strip and set the header | **Yes** | ## Model diff --git a/lib/trinity.ex b/lib/trinity.ex index 71e9864..41c2f6e 100644 --- a/lib/trinity.ex +++ b/lib/trinity.ex @@ -39,6 +39,8 @@ defmodule Trinity do # starts anything. Pure, so the refusals are unit testable and so this boundary does not # grow a dependency on Authority or Receipts to ask a question about a value. Profile, + # Slice 136: the web authentication mode, its rules and the mode in force. + WebAuth, UUID, Config, # Slice 100: the keychain-first secrets, the non-secret desktop settings, the first-run diff --git a/lib/trinity/application.ex b/lib/trinity/application.ex index 42a01dc..b5c00bb 100644 --- a/lib/trinity/application.ex +++ b/lib/trinity/application.ex @@ -22,6 +22,8 @@ defmodule Trinity.Application do use Application + require Logger + @impl true def start(_type, _args) do # Slice 090. Installed before anything can log, and as a primary filter so it applies to every @@ -34,6 +36,14 @@ defmodule Trinity.Application do # without reading anything. verify_regulated_configuration!() + # Slice 136: who the web pages answer. Every profile, before any child: a page on a bind other + # machines can reach with no login is refused here rather than served, and `:regulated` refuses + # a shared token or no login there too. The mode resolved is recorded for the request path and + # for the boot receipt. + verify_web_auth!() + + # Slice 136: the web sessions, and under `:oidc` the issuer's configuration worker, + # before the endpoint that asks them. children = desktop_children() ++ [ @@ -95,6 +105,7 @@ defmodule Trinity.Application do {Oban, Application.fetch_env!(:trinity, Oban)} ] ++ Trinity.Smoke.probe(Trinity.Smoke.argv()) ++ + TrinityWeb.Auth.children() ++ [ # Start to serve requests, typically the last entry TrinityWeb.Endpoint @@ -152,6 +163,64 @@ defmodule Trinity.Application do :ok end + # Slice 136 AC4, AC6 and AC13. The decisions are `Trinity.WebAuth`'s and `Trinity.Profile`'s, + # both pure; this reads the endpoint's configuration and hands them the values. + defp verify_web_auth! do + profile = Trinity.Profile.current() + endpoint = Application.get_env(:trinity, TrinityWeb.Endpoint, []) + # An `http:` list with no `:ip` is Bandit's default, every interface; never assume loopback. + bind = endpoint |> Keyword.get(:http, []) |> Keyword.get(:ip, {0, 0, 0, 0}) + config = Trinity.WebAuth.config() + + refuse = fn reason -> raise "Trinity refuses to boot: #{inspect(reason)}" end + + mode = + case Trinity.WebAuth.resolve(Keyword.get(config, :mode), bind) do + {:ok, mode} -> mode + {:error, reason} -> refuse.(reason) + end + + rewrite_on = endpoint |> Keyword.get(:force_ssl, []) |> Keyword.get(:rewrite_on) + + [ + Trinity.Profile.check_web_auth(profile, bind, mode), + Trinity.Profile.check_trusted_proxy( + profile, + rewrite_on, + System.get_env(Trinity.Profile.trusted_proxy_env()) + ), + Trinity.Profile.check_origin(profile, Keyword.get(endpoint, :check_origin, true)), + mode_requirements(mode, config) + ] + |> Enum.each(fn + :ok -> :ok + {:error, reason} -> refuse.(reason) + end) + + loopback? = Trinity.WebAuth.loopback?(bind) + + if mode == :none and not loopback? do + Logger.warning( + "web pages: #{Trinity.WebAuth.env()}=none on #{Trinity.WebAuth.format_ip(bind)}: " <> + "every page answers whoever reaches this address" + ) + end + + Application.put_env( + :trinity, + :web_auth, + Keyword.merge(config, + mode_in_force: mode, + bind_in_force: Trinity.WebAuth.format_ip(bind), + loopback_in_force: loopback? + ) + ) + end + + defp mode_requirements(:oidc, config), do: Trinity.WebAuth.check_oidc(config) + defp mode_requirements(:local_token, config), do: Trinity.WebAuth.check_token(config[:token]) + defp mode_requirements(:none, _config), do: :ok + # AC5's input. `Trinity.Authority.Selection.select/1` is the same pure rule the selection child # uses, asked here before that child exists rather than duplicated. # diff --git a/lib/trinity/effects/boot.ex b/lib/trinity/effects/boot.ex index 5feac62..93dd2f6 100644 --- a/lib/trinity/effects/boot.ex +++ b/lib/trinity/effects/boot.ex @@ -24,7 +24,10 @@ defmodule Trinity.Effects.Boot do "key_custody" => key_custody_subject(), "otp_release" => List.to_string(:erlang.system_info(:otp_release)), "fips" => Atom.to_string(:crypto.info_fips()), - "node" => Atom.to_string(node()) + "node" => Atom.to_string(node()), + # Slice 136: who the web pages answer on this run, and on what address, so a reader of the + # chain can tell a loopback node with no login from a LAN node with a shared token. + "web" => Trinity.WebAuth.in_force() } meta = %{ diff --git a/lib/trinity/permissions/gate.ex b/lib/trinity/permissions/gate.ex index fbbe70c..429f811 100644 --- a/lib/trinity/permissions/gate.ex +++ b/lib/trinity/permissions/gate.ex @@ -128,7 +128,8 @@ defmodule Trinity.Permissions.Gate do status = Keyword.get(opts, :status, if(decision == :deny, do: "denied", else: "allowed")) - with :ok <- side_effect(approval, decision, opts, now, by), + with :ok <- approval_receipt(approval, decision, Keyword.get(opts, :principal)), + :ok <- side_effect(approval, decision, opts, now, by), {:ok, decided} <- Store.update_approval(approval, %{ status: status, @@ -145,6 +146,49 @@ defmodule Trinity.Permissions.Gate do end end + # Slice 136 AC9: a decision made by a person on the web pages is receipted on the access chain + # with who made it (`sub`, `iss`) and the role it needed, before the row changes, so a decision + # that cannot be receipted is not made. Decisions with no principal (an expiry, a gateway, the + # skills auto-approval) are recorded as before; the gateway's own receipts are slice 070's. On a + # loopback node with no login (`"mode" => "none"`) a receipt failure is logged and the decision + # stands, so the desktop is usable with its signer down; every other mode fails closed. + defp approval_receipt(_approval, _decision, nil), do: :ok + + defp approval_receipt(approval, decision, %{} = principal) do + attrs = %{ + kind: "decision", + subject: %{ + "phase" => "approval", + "approval_id" => approval.id, + "session_id" => approval.session_id, + "tool" => approval.tool, + "risk" => approval.risk, + "principal" => principal + }, + decision: %{ + "outcome" => Atom.to_string(decision), + "basis" => "web_auth", + "role" => Map.get(principal, "role") + }, + fingerprint: approval.fingerprint, + subject_ref: "approval:" <> approval.id + } + + case Trinity.Receipts.append(Trinity.Receipts.access_scope(), attrs) do + {:ok, _} -> + :ok + + {:error, reason} -> + Logger.error( + "permissions gate: approval #{approval.id} not receipted: #{inspect(reason)}" + ) + + if Map.get(principal, "mode") == "none", + do: :ok, + else: {:error, {:approval_not_receipted, reason}} + end + end + # "Allow for this session" is a grant bound to the fingerprint with an expiry; "always # allow" is a global rule with the pattern the user confirmed. Once and deny write no rule. defp side_effect(approval, :session, _opts, now, by) do diff --git a/lib/trinity/profile.ex b/lib/trinity/profile.ex index 63c24ca..d6fd332 100644 --- a/lib/trinity/profile.ex +++ b/lib/trinity/profile.ex @@ -136,6 +136,64 @@ defmodule Trinity.Profile do def check_receipts(:regulated, {:error, reason}), do: {:error, {:regulated_requires_receipts, reason}} + @doc """ + Slice 136 AC4. Under `:regulated` a non-loopback bind needs real user authentication. + + `bind` is the address the endpoint listens on and `mode` the web authentication mode in force + (`Trinity.WebAuth.resolve/2`). `:none` and `:local_token` on a bind other machines can reach are + refused: NIST SP 800-53 IA-2 asks for users to be uniquely identified, and neither a shared token + nor no login identifies anyone. On the loopback `:none` is allowed: the only principal is whoever + holds this host, and the Host allow-list holds the browser to that. + """ + @spec check_web_auth(t(), :inet.ip_address(), atom()) :: :ok | {:error, term()} + def check_web_auth(:default, _bind, _mode), do: :ok + + def check_web_auth(:regulated, bind, mode) when mode in [:none, :local_token] do + if Trinity.WebAuth.loopback?(bind), + do: :ok, + else: + {:error, + {:regulated_requires_user_authentication, mode, Trinity.WebAuth.format_ip(bind), + Trinity.WebAuth.env()}} + end + + def check_web_auth(:regulated, _bind, _mode), do: :ok + + @trusted_proxy_env "TRINITY_TRUSTED_PROXY" + + @doc """ + Slice 136 AC13. Under `:regulated`, believing `x-forwarded-proto` needs the operator to say a + proxy strips and sets it. + + `rewrite_on` is the endpoint's `force_ssl` `:rewrite_on` list and `raw` is + `#{@trusted_proxy_env}`. `Plug.RewriteOn` is safe only behind a proxy that strips the header from + every incoming request; without one, any client can claim it arrived over HTTPS. Plug has no + source address check, so the operator's statement is the only thing that can be asked for. + """ + @spec check_trusted_proxy(t(), [atom()] | nil, String.t() | nil) :: :ok | {:error, term()} + def check_trusted_proxy(:default, _rewrite_on, _raw), do: :ok + + def check_trusted_proxy(:regulated, rewrite_on, raw) do + if is_list(rewrite_on) and :x_forwarded_proto in rewrite_on and raw not in ["1", "true"], + do: {:error, {:regulated_rewrite_on_without_trusted_proxy, @trusted_proxy_env}}, + else: :ok + end + + @doc "The name of the trusted proxy variable." + @spec trusted_proxy_env() :: String.t() + def trusted_proxy_env, do: @trusted_proxy_env + + @doc """ + Slice 136 AC6. Under `:regulated` the websocket origin check must be an explicit list. + + `false` checks nothing and `:conn` compares the origin with the request's own Host, which a DNS + rebinding page controls, so neither is a check against a hostile page. + """ + @spec check_origin(t(), term()) :: :ok | {:error, term()} + def check_origin(:default, _check_origin), do: :ok + def check_origin(:regulated, list) when is_list(list) and list != [], do: :ok + def check_origin(:regulated, other), do: {:error, {:regulated_requires_origin_list, other}} + @doc """ Under `:regulated` every configured gateway adapter must be named on the allow-list. diff --git a/lib/trinity/receipts.ex b/lib/trinity/receipts.ex index c6425eb..57a51f3 100644 --- a/lib/trinity/receipts.ex +++ b/lib/trinity/receipts.ex @@ -33,6 +33,7 @@ defmodule Trinity.Receipts do @boot_key {__MODULE__, :boot_hash} @boot_scope "boot" @policy_scope "policy" + @access_scope "access" @doc "The scope of a session's chain." @spec session_scope(String.t()) :: String.t() @@ -52,6 +53,15 @@ defmodule Trinity.Receipts do @spec policy_scope() :: String.t() def policy_scope, do: @policy_scope + @doc """ + The chain for what a person did through the web pages (slice 136): an approval decided, a + privileged route used or refused. Each carries the principal (`sub`, `iss`) and the role the act + needed, so "who approved this, and on what authority" is answered in one place rather than by + walking every session's chain (NIST SP 800-53 AC-6(9), AU-2). + """ + @spec access_scope() :: String.t() + def access_scope, do: @access_scope + @doc "Appends a receipt to a scope, starting its writer if needed. See `ChainWriter.append/2`." @spec append(String.t(), map()) :: {:ok, Receipt.t()} | {:error, term()} def append(scope, attrs) when is_binary(scope) and is_map(attrs) do diff --git a/lib/trinity/skills/manager.ex b/lib/trinity/skills/manager.ex index 2bc08d8..34b4367 100644 --- a/lib/trinity/skills/manager.ex +++ b/lib/trinity/skills/manager.ex @@ -15,7 +15,7 @@ defmodule Trinity.Skills.Manager do alias Trinity.Repo alias Trinity.Skills.{Change, Promotion, Staging} - @doc "Approves and promotes a pending change; `opts`: `by:` (default `\"ui\"`), `comment:`, `session_id:`." + @doc "Approves and promotes a pending change; `opts`: `by:` (default `\"ui\"`), `comment:`, `session_id:`, `principal:` (slice 136: the decider's receipt form, for the approval receipt)." @spec approve(Change.t(), keyword()) :: {:ok, Change.t()} | {:error, term()} def approve(%Change{status: "pending"} = change, opts \\ []) do by = Keyword.get(opts, :by, "ui") @@ -26,7 +26,12 @@ defmodule Trinity.Skills.Manager do with {:ok, approval} <- Permissions.request_approval(session_id, Promotion.tool(), args, risk: :write), - {:ok, _} <- Permissions.decide_request(approval.id, :once, by: by) do + {:ok, _} <- + Permissions.decide_request( + approval.id, + :once, + [by: by] ++ Keyword.take(opts, [:principal]) + ) do Promotion.swap(change, approval.id, by, Keyword.get(opts, :comment)) end end diff --git a/lib/trinity/versions.ex b/lib/trinity/versions.ex index 5b984be..d30682a 100644 --- a/lib/trinity/versions.ex +++ b/lib/trinity/versions.ex @@ -181,6 +181,20 @@ defmodule Trinity.Versions do note: "JOSE (JWK, JWS, JWKS) over OTP's crypto, for the MCP resource server's token validation and the personal profile's embedded authorization server. MIT; 1.11.12 measured on hex.pm 2026-09-22. Not joken, a layer over it this tree does not need. Added at Slice 062." }, + %{ + name: "oidcc", + pin: "~> 3.9", + lock: "oidcc", + note: + "OpenID Connect relying party for the web pages' login (`TrinityWeb.Auth.OIDC`): authorization code with PKCE S256, `state`, `nonce`, ID token validation. EEF Security WG, OpenID Certified; Apache-2.0. 3.9.0 measured on hex.pm 2026-10-08, released 2026-08-30; it fixes CVE-2026-75759 (GHSA-533g-4vf3-xwrj, high: an encrypted but unsigned ID token was accepted, affected >= 3.2.0-beta.1 and < 3.9.0), so the floor is 3.9 and a test asserts it. Brings `telemetry_registry` 0.3.2 (Apache-2.0). Added at Slice 136." + }, + %{ + name: "oidcc_plug", + pin: "~> 0.5.1", + lock: "oidcc_plug", + note: + "Plugs over oidcc for the login's redirect and callback. Apache-2.0. 0.5.1 measured on hex.pm 2026-10-08, released 2026-08-04; 0.5.0 fixed CVE-2026-66884 (GHSA-fg66-w5gp-22cr, state check broken) and CVE-2026-66883 (GHSA-w5r8-m75h-98fc, user agent binding inert) and is itself retired on hex.pm (invalid igniter requirement), so the floor is 0.5.1 and a test asserts it. Ships no LiveView `on_mount`; `TrinityWeb.Auth` is that. Added at Slice 136." + }, %{ name: "oban_web", pin: "~> 2.13", diff --git a/lib/trinity/web_auth.ex b/lib/trinity/web_auth.ex new file mode 100644 index 0000000..1b45aa3 --- /dev/null +++ b/lib/trinity/web_auth.ex @@ -0,0 +1,166 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.WebAuth do + @moduledoc """ + How the web pages know who is asking (slice 136): the mode in force and the rules that choose it. + + Three modes. + + * `:none`: no login. Allowed only when the endpoint binds the loopback, where the Host + allow-list (`TrinityWeb.Plugs.HostAllowList`) and the websocket origin list stand in for + identity: the desktop app, development and the suite. The principal is the machine's owner. + * `:oidc`: an OpenID Connect login (authorization code with PKCE) against an external issuer, + the one `:regulated` already requires for the MCP server unless another is named. Roles come + from the ID token. + * `:local_token`: one shared token, exchanged once for a session cookie. A LAN convenience + outside `:regulated`: it identifies a token, not a person, and the pages say so. + + ## The default is chosen by the bind, and only on the loopback + + `TRINITY_WEB_AUTH` unset on a loopback bind is `:none`. Unset on any other bind is a refusal to + boot in **every** profile: a page that can be reached from the network and answers anyone is the + finding this module exists to close (F-132-1), and an operator who has not said which mode they + want has not said they want none. Setting `TRINITY_WEB_AUTH=none` on a wider bind outside + `:regulated` is allowed, logged as a warning and named on the boot receipt; `:regulated` refuses + it (`Trinity.Profile.check_web_auth/3`). + + Every function here is pure except `config/0`, `mode/0` and `in_force/0`, which read the + application environment and nothing else. + """ + + @type mode :: :none | :oidc | :local_token + + @modes [:none, :oidc, :local_token] + @env "TRINITY_WEB_AUTH" + + @doc "The three modes." + @spec modes() :: [mode()] + def modes, do: @modes + + @doc "The name of the variable that selects the mode, so a refusal and a test agree on it." + @spec env() :: String.t() + def env, do: @env + + @doc """ + Parses a configured mode: an atom from config, a string from the environment, or nothing. + + Unset is `:unset`, not `:none`, because what unset means depends on the bind (`resolve/2`). An + unknown value is an error rather than a fallback: a typo must not produce the open mode. + """ + @spec parse_mode(atom() | String.t() | nil) :: {:ok, mode() | :unset} | {:error, term()} + def parse_mode(nil), do: {:ok, :unset} + def parse_mode(""), do: {:ok, :unset} + def parse_mode(mode) when mode in @modes, do: {:ok, mode} + def parse_mode("none"), do: {:ok, :none} + def parse_mode("oidc"), do: {:ok, :oidc} + def parse_mode("local_token"), do: {:ok, :local_token} + def parse_mode(other), do: {:error, {:web_auth_unknown_mode, other, @env}} + + @doc """ + The mode in force for a configured value and the address the endpoint binds. + + Unset on a loopback bind is `:none`; unset on any other bind is + `{:error, {:web_auth_unset_on_non_loopback_bind, address, "TRINITY_WEB_AUTH"}}`. + """ + @spec resolve(atom() | String.t() | nil, :inet.ip_address()) :: {:ok, mode()} | {:error, term()} + def resolve(configured, ip) do + case parse_mode(configured) do + {:ok, :unset} -> + if loopback?(ip), + do: {:ok, :none}, + else: {:error, {:web_auth_unset_on_non_loopback_bind, format_ip(ip), @env}} + + other -> + other + end + end + + @doc """ + True for an address only this host can reach: `127.0.0.0/8`, `::1`, and `::ffff:127.0.0.0/104`. + + The unspecified addresses (`0.0.0.0`, `::`) are every interface and are not loopback. + """ + @spec loopback?(term()) :: boolean() + def loopback?({127, _, _, _}), do: true + def loopback?({0, 0, 0, 0, 0, 0, 0, 1}), do: true + def loopback?({0, 0, 0, 0, 0, 0xFFFF, hi, _lo}), do: Bitwise.bsr(hi, 8) == 127 + def loopback?(_), do: false + + @doc "An address as text, for a refusal or a receipt." + @spec format_ip(term()) :: String.t() + def format_ip(ip) when is_tuple(ip) do + case :inet.ntoa(ip) do + {:error, _} -> inspect(ip) + chars -> List.to_string(chars) + end + end + + def format_ip(other), do: inspect(other) + + @doc """ + What a `:local_token` token must be: at least 128 bits. A token is accepted as text of at least + 22 characters (22 base64url characters carry 132 bits), which is the shortest string that can + hold 128 random bits in the encodings an operator is likely to use. + """ + @spec check_token(String.t() | nil) :: :ok | {:error, term()} + def check_token(token) when is_binary(token) and byte_size(token) >= 22, do: :ok + def check_token(nil), do: {:error, {:web_auth_local_token_unset, "TRINITY_WEB_AUTH_TOKEN"}} + + def check_token(_short), + do: {:error, {:web_auth_local_token_too_short, "TRINITY_WEB_AUTH_TOKEN"}} + + @doc """ + What `:oidc` needs before it can start: an issuer and a client id. Missing ones are named. + """ + @spec check_oidc(keyword()) :: :ok | {:error, term()} + def check_oidc(config) do + missing = + for {key, var} <- [ + issuer: "TRINITY_WEB_AUTH_ISSUER", + client_id: "TRINITY_WEB_AUTH_CLIENT_ID" + ], + Keyword.get(config, key) in [nil, ""], + do: var + + if missing == [], do: :ok, else: {:error, {:web_auth_oidc_unconfigured, missing}} + end + + @doc "The `config :trinity, :web_auth` keyword list." + @spec config() :: keyword() + def config, do: Application.get_env(:trinity, :web_auth, []) + + @doc """ + The mode the running node resolved at boot (`Trinity.Application` writes it under + `:mode_in_force`), else the configured one, else `:none`. + + Read on every request. The suite changes it with `Application.put_env/3` in tests that are not + async; nothing in production changes it after boot. + """ + @spec mode() :: mode() + def mode do + config = config() + + case Keyword.get(config, :mode_in_force) do + mode when mode in @modes -> + mode + + _ -> + case parse_mode(Keyword.get(config, :mode)) do + {:ok, mode} when mode in @modes -> mode + _ -> :none + end + end + end + + @doc "What the boot receipt records about the web pages: the mode and the bind." + @spec in_force() :: map() + def in_force do + config = config() + + %{ + "mode" => Atom.to_string(mode()), + "bind" => Keyword.get(config, :bind_in_force, "unknown"), + "loopback" => Keyword.get(config, :loopback_in_force, false) + } + end +end diff --git a/lib/trinity_web.ex b/lib/trinity_web.ex index 228994b..f938c31 100644 --- a/lib/trinity_web.ex +++ b/lib/trinity_web.ex @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 defmodule TrinityWeb do # Slice 060: the MCP page reads the servers and their clients. - use Boundary, deps: [Trinity, Trinity.MCP], exports: [Endpoint] + use Boundary, deps: [Trinity, Trinity.MCP], exports: [Endpoint, Auth] @moduledoc """ The entrypoint for defining your web interface, such diff --git a/lib/trinity_web/auth.ex b/lib/trinity_web/auth.ex new file mode 100644 index 0000000..ffbedb6 --- /dev/null +++ b/lib/trinity_web/auth.ex @@ -0,0 +1,211 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.Auth do + @moduledoc """ + Who is asking, on every web request and every LiveView step (slice 136). + + Three checks, each where the request is: + + * `TrinityWeb.Auth.Gate`, in the endpoint ahead of the router: no principal, no route. A `GET` + for a page is sent to `/auth/login`; anything else is `401`. This is what makes a `POST` to + a path that has only a `GET` route a `401` rather than a `404` that says the path exists. + * `TrinityWeb.Auth.RequireRole`, in the router's pipelines: the role a route needs, and a + receipt for every privileged route used or refused. + * `on_mount/4`, on every `live_session`: "Performing authorization on mount is important + because navigates do not go through the plug pipeline" (the LiveView docs). It checks the + session on mount, again on every `handle_params` (a revoked session is halted on its next + patch, AC3) and on every `handle_event`, where the event's role comes from + `TrinityWeb.Auth.Policy`. + + In `:none` (a loopback node) every check passes with `TrinityWeb.Auth.Principal.owner/0`; the + Host allow-list and the origin list are what stand in for identity there. + """ + import Plug.Conn + + require Logger + + alias Phoenix.LiveView + alias TrinityWeb.Auth.{Policy, Principal, Sessions} + + @session_key "web_sid" + + @doc """ + The processes the web login needs, started by the application ahead of the endpoint: the + session store always, the issuer's configuration worker under `:oidc`. + """ + @spec children() :: [Supervisor.child_spec() | module()] + def children, do: [Sessions | TrinityWeb.Auth.OIDC.children()] + + @doc "The session key holding the web session id." + @spec session_key() :: String.t() + def session_key, do: @session_key + + @doc """ + The principal of a session map (a `Plug.Conn` session, or the session a LiveView receives). + `:none` answers the owner without looking. + """ + @spec principal(map()) :: {:ok, Principal.t()} | {:error, atom()} + def principal(session) when is_map(session) do + case Trinity.WebAuth.mode() do + :none -> {:ok, Principal.owner()} + _mode -> Sessions.fetch(session[@session_key]) + end + end + + @doc """ + Logs a principal in: a new server-side session, the cookie renewed (a fixed session id from + before the login is not carried across it), and the LiveView socket id set so `log_out/1` and + `Sessions.revoke/1` reach every open page. + """ + @spec log_in(Plug.Conn.t(), Principal.t()) :: Plug.Conn.t() + def log_in(conn, %Principal{} = principal) do + sid = Sessions.create(principal) + + conn + |> configure_session(renew: true) + |> clear_session() + |> put_session(@session_key, sid) + |> put_session(:live_socket_id, Sessions.socket_id(sid)) + end + + @doc "Ends the request's session on the server and in the cookie." + @spec log_out(Plug.Conn.t()) :: Plug.Conn.t() + def log_out(conn) do + case get_session(conn, @session_key) do + sid when is_binary(sid) -> Sessions.revoke(sid) + _ -> :ok + end + + conn |> configure_session(drop: true) + end + + ## LiveView + + @doc """ + The `on_mount` guard: `{TrinityWeb.Auth, role}` on a `live_session` requires `role` to mount. + + Attaches two hooks. `handle_params` re-checks the session, so a revoked or expired one is + redirected to the login on its next patch. `handle_event` re-checks it and asks + `TrinityWeb.Auth.Policy` which role the event needs; an event the principal may not send is + halted with a flash and, when the role is not `view`, a receipt. + """ + @spec on_mount(Principal.role(), map(), map(), LiveView.Socket.t()) :: + {:cont, LiveView.Socket.t()} | {:halt, LiveView.Socket.t()} + def on_mount(role, _params, session, socket) when role in [:view, :approve, :administer] do + with {:ok, principal} <- principal(session), + true <- Principal.has_role?(principal, role) do + socket = + socket + |> Phoenix.Component.assign(:web_principal, principal) + |> Phoenix.Component.assign(:web_auth_mode, Trinity.WebAuth.mode()) + |> LiveView.attach_hook(:web_auth_params, :handle_params, &recheck_params(&1, &2, &3)) + |> LiveView.attach_hook(:web_auth_events, :handle_event, &check_event(&1, &2, &3)) + + {:cont, socket} + else + false -> {:halt, LiveView.redirect(socket, to: "/auth/forbidden")} + {:error, _reason} -> {:halt, LiveView.redirect(socket, to: "/auth/login")} + end + end + + defp recheck_params(_params, _uri, socket) do + case recheck(socket) do + {:ok, _principal} -> {:cont, socket} + {:error, _} -> {:halt, LiveView.redirect(socket, to: "/auth/login")} + end + end + + defp check_event(event, _params, socket) do + role = Policy.event_role(socket.view, event) + + case recheck(socket) do + {:ok, principal} -> + decide_event(socket, principal, role, Principal.has_role?(principal, role), event) + + {:error, _} -> + {:halt, LiveView.redirect(socket, to: "/auth/login")} + end + end + + defp decide_event(socket, _principal, :view, true, _event), do: {:cont, socket} + + defp decide_event(socket, principal, role, true, event) do + case privileged_receipt(principal, role, "allow", event_detail(socket, event)) do + :ok -> + {:cont, socket} + + {:error, _} -> + {:halt, LiveView.put_flash(socket, :error, "Not done: it could not be receipted.")} + end + end + + defp decide_event(socket, principal, role, false, event) do + _ = privileged_receipt(principal, role, "deny", event_detail(socket, event)) + {:halt, LiveView.put_flash(socket, :error, "Not permitted: this needs the #{role} role.")} + end + + defp event_detail(socket, event), do: %{"view" => inspect(socket.view), "event" => event} + + # The session as it stands now, not as it stood at mount. + defp recheck(socket) do + case socket.assigns[:web_principal] do + %Principal{mode: :none} = p -> + if Trinity.WebAuth.mode() == :none, do: {:ok, p}, else: {:error, :mode_changed} + + %Principal{sid: sid} when is_binary(sid) -> + Sessions.fetch(sid) + + _ -> + {:error, :unknown} + end + end + + @doc """ + What an approval decided from a page carries to `Trinity.Permissions.decide_request/3`: the + decider's name for the row (`by:`) and the principal's receipt form for the approval receipt + (`principal:`, with the role `approve`). Every approval then says who gave it. + """ + @spec decider_opts(LiveView.Socket.t()) :: keyword() + def decider_opts(socket) do + case socket.assigns[:web_principal] do + %Principal{} = p -> [by: Principal.label(p), principal: Principal.receipt(p, :approve)] + _ -> [] + end + end + + ## Receipts + + @doc """ + Appends a receipt of a privileged act (an approval decided, a privileged route or event used or + refused) to the access chain, carrying the principal's `sub` and `iss` and the role the act + needed (AC9). + + When the receipt cannot be written the act is refused, except on a loopback node with no login, + where the desktop must stay usable with its signer down (slice 100: the keychain helper may be + absent); there the failure is logged. + """ + @spec privileged_receipt(Principal.t(), Principal.role(), String.t(), map()) :: + :ok | {:error, term()} + def privileged_receipt(%Principal{} = principal, role, outcome, detail) do + subject = + detail + |> Map.put("phase", Map.get(detail, "phase", "privileged")) + |> Map.put("principal", Principal.receipt(principal, role)) + + attrs = %{ + kind: "decision", + subject: subject, + decision: %{"outcome" => outcome, "basis" => "web_auth", "role" => Atom.to_string(role)}, + subject_ref: "access:" <> principal.sub + } + + case Trinity.Receipts.append(Trinity.Receipts.access_scope(), attrs) do + {:ok, _} -> + :ok + + {:error, reason} = error -> + Logger.error("web auth: privileged act not receipted: #{inspect(reason)}") + if principal.mode == :none, do: :ok, else: error + end + end +end diff --git a/lib/trinity_web/auth/claims.ex b/lib/trinity_web/auth/claims.ex new file mode 100644 index 0000000..d6633e4 --- /dev/null +++ b/lib/trinity_web/auth/claims.ex @@ -0,0 +1,105 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.Auth.Claims do + @moduledoc """ + Trinity's roles from an ID token's claims (slice 136). Pure. + + Four shapes are read unless the operator names one claim (`role_claim`, a dotted path): + + * Keycloak: `realm_access.roles`, and `resource_access..roles`; + * Microsoft Entra ID: `roles` (app roles); + * Okta: `groups` (the claim name Okta's groups claim usually carries); + * generic: `roles`. + + A value names a role when, lower-cased and with an optional `trinity` prefix (`trinity:`, + `trinity.`, `trinity-`, `trinity_`) removed, it is `view`, `approve` or `administer`. Anything + else is ignored: an IdP's own groups are not Trinity's roles. + + **Two outcomes are refusals, never an empty role set.** A token with no Trinity role is + `{:error, {:no_trinity_role, claims_read}}`. A token whose role-bearing claim was moved out of it + (Entra's group overage: past 200 groups, `groups` is replaced by `_claim_names` pointing at + Microsoft Graph) is `{:error, {:claim_overage, claim}}`: the roles cannot be known from the + token, and a login that silently went ahead with whatever was left would be guessing. + """ + + @roles ~w(view approve administer) + @atoms %{"view" => :view, "approve" => :approve, "administer" => :administer} + @prefixes ["trinity:", "trinity.", "trinity-", "trinity_"] + + @doc "The roles a token's claims grant, or why there are none." + @spec roles(map(), String.t() | nil, String.t() | nil) :: + {:ok, [TrinityWeb.Auth.Principal.role()]} | {:error, term()} + def roles(claims, client_id, role_claim \\ nil) when is_map(claims) do + paths = paths(client_id, role_claim) + + with :ok <- check_overage(claims, paths) do + found = + paths + |> Enum.flat_map(&values(claims, &1)) + |> Enum.flat_map(&role/1) + |> Enum.uniq() + |> Enum.sort_by(fn r -> Enum.find_index(@roles, &(&1 == r)) end) + |> Enum.map(&Map.fetch!(@atoms, &1)) + + if found == [], + do: {:error, {:no_trinity_role, Enum.map(paths, &Enum.join(&1, "."))}}, + else: {:ok, found} + end + end + + @doc "A refusal as a sentence for the person who was refused, naming what to change." + @spec describe(term()) :: String.t() + def describe({:claim_overage, claim}) do + "Your identity provider left the #{claim} claim out of the token because there are too " <> + "many values (Entra ID does this past 200 groups). Trinity cannot read your roles from " <> + "it. Ask the administrator to grant Trinity's roles as app roles (the roles claim), or to " <> + "name a claim that carries them (TRINITY_WEB_AUTH_ROLE_CLAIM)." + end + + def describe({:no_trinity_role, read}) do + "No Trinity role (view, approve or administer) was found in the claims read: " <> + Enum.join(read, ", ") <> "." + end + + def describe(other), do: "Login refused: #{inspect(other)}" + + defp paths(_client_id, role_claim) when is_binary(role_claim) and role_claim != "", + do: [String.split(role_claim, ".")] + + defp paths(client_id, _none) do + keycloak_client = + if is_binary(client_id), do: [["resource_access", client_id, "roles"]], else: [] + + [["realm_access", "roles"]] ++ keycloak_client ++ [["roles"], ["groups"]] + end + + # Entra's distributed claims: `_claim_names` maps a claim to a source, and the claim itself is + # absent. Only a claim this login would read counts. + defp check_overage(claims, paths) do + names = Map.get(claims, "_claim_names", %{}) + names = if is_map(names), do: names, else: %{} + + case Enum.find(paths, fn [first | _] -> Map.has_key?(names, first) end) do + nil -> :ok + [first | _] -> {:error, {:claim_overage, first}} + end + end + + defp values(claims, path) do + case get_in_path(claims, path) do + list when is_list(list) -> Enum.filter(list, &is_binary/1) + value when is_binary(value) -> String.split(value, ~r/[\s,]+/, trim: true) + _ -> [] + end + end + + defp get_in_path(value, []), do: value + defp get_in_path(%{} = map, [key | rest]), do: get_in_path(Map.get(map, key), rest) + defp get_in_path(_, _), do: nil + + defp role(value) do + value = String.downcase(value) + bare = Enum.reduce(@prefixes, value, &String.replace_prefix(&2, &1, "")) + if bare in @roles, do: [bare], else: [] + end +end diff --git a/lib/trinity_web/auth/gate.ex b/lib/trinity_web/auth/gate.ex new file mode 100644 index 0000000..4fa35bd --- /dev/null +++ b/lib/trinity_web/auth/gate.ex @@ -0,0 +1,75 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.Auth.Gate do + @moduledoc """ + No principal, no route (slice 136, AC2). In the endpoint, after the session and before the + router. + + A request with a principal continues with it in `conn.assigns.web_principal`. A request without + one is answered here, before routing: a `GET` or `HEAD` that asks for HTML is redirected to + `/auth/login`, anything else is `401`. Answering before routing is deliberate: a `POST` to a path + that has only a `GET` route would otherwise be a `404` naming no route, which tells an + unauthenticated caller which paths exist and is not what AC2 asks for. + + **What passes without a principal**, and why each: + + * `/auth/*`: the login itself. + * `/.well-known/*`, `POST /oauth/token`, `POST /oauth/register`: the MCP authorization + surfaces (slice 062), which a client or another server reads without a browser and which + authenticate by their own protocol. + + `POST /mcp` never reaches this plug: `Trinity.MCP.Server.Plug` answers it earlier with its own + bearer check. Static assets are served earlier still. + """ + @behaviour Plug + + import Plug.Conn + + alias TrinityWeb.Auth + + @impl Plug + def init(opts), do: opts + + @impl Plug + def call(conn, _opts) do + conn = fetch_session(conn) + + case Auth.principal(get_session(conn)) do + {:ok, principal} -> + assign(conn, :web_principal, principal) + + {:error, reason} -> + if exempt?(conn), do: conn, else: refuse(conn, reason) + end + end + + @doc "True for the paths that pass without a principal (see the moduledoc)." + @spec exempt?(Plug.Conn.t()) :: boolean() + def exempt?(%Plug.Conn{path_info: ["auth" | _]}), do: true + def exempt?(%Plug.Conn{path_info: [".well-known" | _]}), do: true + def exempt?(%Plug.Conn{method: "POST", path_info: ["oauth", "token"]}), do: true + def exempt?(%Plug.Conn{method: "POST", path_info: ["oauth", "register"]}), do: true + def exempt?(_conn), do: false + + defp refuse(conn, _reason) do + if conn.method in ["GET", "HEAD"] and wants_html?(conn) do + conn + |> put_resp_header("location", "/auth/login") + |> put_resp_content_type("text/plain") + |> send_resp(302, "") + |> halt() + else + conn + |> put_resp_content_type("text/plain") + |> send_resp(401, "authentication required\n") + |> halt() + end + end + + defp wants_html?(conn) do + case get_req_header(conn, "accept") do + [] -> true + [accept | _] -> accept =~ "text/html" or accept =~ "*/*" + end + end +end diff --git a/lib/trinity_web/auth/oidc.ex b/lib/trinity_web/auth/oidc.ex new file mode 100644 index 0000000..0c9cd40 --- /dev/null +++ b/lib/trinity_web/auth/oidc.ex @@ -0,0 +1,126 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.Auth.OIDC do + @moduledoc """ + The web pages' OpenID Connect login (slice 136), on `oidcc` and `oidcc_plug`. + + `oidcc` (EEF Security WG, OpenID Certified) does the protocol; this module only configures it. + What the configuration guarantees, each asserted by a test against an in-process issuer: + + * **PKCE S256 on every request** (`require_pkce: true`; the login refuses an issuer that does + not offer S256 rather than falling back to `plain`), with `state` and `nonce`; + * **a callback with no stored authorization session is refused** (`oidcc_plug` 0.5's + `:missing_authorize_session`; 0.4 and earlier skipped the checks there, CVE-2026-66884); + * **`iss` checked** on the ID token always, and on the authorization response whenever the + issuer advertises RFC 9207; + * **the ID token is signed** (oidcc 3.9 refuses an encrypted but unsigned one, CVE-2026-75759), + and roles come from its claims alone: no userinfo call, so the token that was verified is + the token that was read. + + The plugs' options are built per request from the application environment and the plugs are + called from `TrinityWeb.AuthController`, not mounted with `plug`, so nothing is evaluated at + compile time under a release (slice 062 NOTES, F5). + """ + + @provider __MODULE__.Provider + + @doc "The registered name of the issuer's configuration worker." + @spec provider() :: atom() + def provider, do: @provider + + @doc """ + The issuer's configuration worker, which fetches the discovery document and the JWKS and keeps + them fresh. Only under `:oidc`. + """ + @spec children() :: [Supervisor.child_spec()] + def children do + if Trinity.WebAuth.mode() == :oidc, do: [worker_spec(Trinity.WebAuth.config())], else: [] + end + + @doc "The worker's child spec for a configuration (the suite starts it against its issuer)." + @spec worker_spec(keyword()) :: Supervisor.child_spec() + def worker_spec(config) do + Oidcc.ProviderConfiguration.Worker.child_spec(%{ + issuer: Keyword.fetch!(config, :issuer), + name: @provider, + # An issuer that cannot be reached is retried with backoff rather than stopping the worker, + # whose restarts would otherwise exhaust the application supervisor and take the node down + # with it. The login says the issuer is unreachable in the meantime. + backoff_type: :random_exponential, + backoff_min: 1_000, + backoff_max: 30_000, + provider_configuration_opts: %{ + quirks: %{allow_unsafe_http: Keyword.get(config, :allow_unsafe_http, false) == true} + } + }) + end + + @doc """ + Whether the issuer's discovery document offers PKCE with S256; `{:error, reason}` when its + configuration cannot be read. + """ + @spec s256_offered?() :: boolean() | {:error, term()} + def s256_offered? do + case Oidcc.ProviderConfiguration.Worker.get_provider_configuration(@provider) do + %Oidcc.ProviderConfiguration{code_challenge_methods_supported: methods} -> + is_list(methods) and "S256" in methods + + other -> + {:error, other} + end + rescue + # oidcc raises converting a configuration it has not loaded yet (the issuer unreachable). + e in ArgumentError -> {:error, {:not_loaded, Exception.message(e)}} + catch + :exit, reason -> {:error, {:exit, reason}} + end + + @doc "`Oidcc.Plug.Authorize`'s options." + @spec authorize_opts(Plug.Conn.t()) :: keyword() + def authorize_opts(conn) do + config = Trinity.WebAuth.config() + + Oidcc.Plug.Authorize.init( + provider: @provider, + client_id: Keyword.fetch!(config, :client_id), + client_secret: client_secret(config), + redirect_uri: redirect_uri(conn, config), + scopes: ["openid"], + require_pkce: true + ) + end + + @doc "`Oidcc.Plug.AuthorizationCallback`'s options." + @spec callback_opts(Plug.Conn.t()) :: keyword() + def callback_opts(conn) do + config = Trinity.WebAuth.config() + + Oidcc.Plug.AuthorizationCallback.init( + provider: @provider, + client_id: Keyword.fetch!(config, :client_id), + client_secret: client_secret(config), + redirect_uri: redirect_uri(conn, config), + retrieve_userinfo: false + ) + end + + defp client_secret(config) do + case Keyword.get(config, :client_secret) do + secret when is_binary(secret) and secret != "" -> secret + _ -> :unauthenticated + end + end + + defp redirect_uri(_conn, config) do + case Keyword.get(config, :redirect_uri) do + uri when is_binary(uri) and uri != "" -> + uri + + _ -> + TrinityWeb.Endpoint.url() + |> URI.parse() + |> Map.merge(%{path: "/auth/callback", query: nil}) + |> URI.to_string() + end + end +end diff --git a/lib/trinity_web/auth/policy.ex b/lib/trinity_web/auth/policy.ex new file mode 100644 index 0000000..7a855f2 --- /dev/null +++ b/lib/trinity_web/auth/policy.ex @@ -0,0 +1,83 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.Auth.Policy do + @moduledoc """ + The role each LiveView event needs (slice 136). + + **An event not named here needs `administer`.** The table lists what a lesser role may send, so + a new event that nobody classified fails closed rather than open; a census test + (`test/trinity_web/auth/policy_census_test.exs`) reads every `handle_event` clause under + `lib/trinity_web/live/` and fails on one this table does not name explicitly, so the default is + a backstop and not the way events get classified. + + The three roles (`TrinityWeb.Auth.Principal`): + + * `view`: reading, filtering, navigating, and conversing. Sending a message can make the agent + ask for an effect, but asking is not deciding: the effect still waits on an `approve`. + * `approve`: deciding an approval request, and the decisions that are the same act on another + object (a skill change, a changed tool definition, a memory proposal). + * `administer`: everything that changes what Trinity is or what leaves the machine: MCP + servers, gateways, settings and keys, personas, standing rules, memory edits, tasks, the + first-run setup, a session's project root. + """ + + @view %{ + TrinityWeb.ActivityLive => ~w(filter session), + TrinityWeb.MCPLive => ~w(transport view hide), + TrinityWeb.MemoryLive => ~w(cancel cancel_edit edit new_session pick_persona semantic_search), + TrinityWeb.PermissionsLive => ~w(cancel new_session), + TrinityWeb.PersonasLive => ~w(cancel new_session), + TrinityWeb.ReceiptsLive => ~w(refresh verify), + TrinityWeb.SearchLive => ~w(cancel new_session search), + TrinityWeb.SessionLive.Index => ~w(cancel new_session pick_persona), + TrinityWeb.SessionLive.Show => + ~w(cancel cancel_subagent dismiss new_session retry send set_model), + TrinityWeb.SettingsLive => ~w(cancel new_session), + TrinityWeb.SkillsLive => ~w(cancel close hide_change new_session show_change view), + TrinityWeb.TasksLive => ~w(cancel_edit change edit hide seen seen_all suggest view) + } + + @approve %{ + TrinityWeb.MemoryLive => ~w(apply_proposal reject_proposal), + TrinityWeb.PermissionsLive => + ~w(approval_answer approval_decide approval_pattern drift_accept drift_dismiss), + TrinityWeb.SessionLive.Show => ~w(approval_answer approval_decide approval_pattern), + TrinityWeb.SkillsLive => ~w(approve_change reject_change) + } + + @administer %{ + # Clearing the activity buffer removes what the page shows for everyone. + TrinityWeb.ActivityLive => ~w(clear), + TrinityWeb.GatewaysLive => ~w(allow revoke), + TrinityWeb.MCPLive => ~w(authorize create disable enable reconnect remove), + TrinityWeb.MemoryLive => ~w(add delete download_model save semantic_delete semantic_pin), + TrinityWeb.PermissionsLive => ~w(proposal_accept revoke), + TrinityWeb.PersonasLive => ~w(create memory_rule save), + TrinityWeb.SessionLive.Show => ~w(set_project_root), + TrinityWeb.SettingsLive => + ~w(add_root pick_root remove_key remove_root save_hotkey save_key toggle_autostart toggle_gateways toggle_muted), + TrinityWeb.SetupLive => + ~w(add_root choose_model confirm_data_dir finish pick_root remove_root), + TrinityWeb.SkillsLive => ~w(learn reindex set_status), + TrinityWeb.TasksLive => ~w(remove run_now save toggle) + } + + @doc "The role `event` needs on `view`; `administer` when the table does not name it." + @spec event_role(module(), String.t()) :: TrinityWeb.Auth.Principal.role() + def event_role(view, event) do + cond do + event in Map.get(@view, view, []) -> :view + event in Map.get(@approve, view, []) -> :approve + true -> :administer + end + end + + @doc "Every `{view, event, role}` the table names explicitly, for the census." + @spec named() :: [{module(), String.t(), TrinityWeb.Auth.Principal.role()}] + def named do + for {role, table} <- [view: @view, approve: @approve, administer: @administer], + {view, events} <- table, + event <- events, + do: {view, event, role} + end +end diff --git a/lib/trinity_web/auth/principal.ex b/lib/trinity_web/auth/principal.ex new file mode 100644 index 0000000..b176396 --- /dev/null +++ b/lib/trinity_web/auth/principal.ex @@ -0,0 +1,74 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.Auth.Principal do + @moduledoc """ + Who a web request is (slice 136): a subject at an issuer, with roles. + + Three roles. `view` reads the pages and converses; `approve` decides approval requests; + `administer` changes what Trinity is (MCP servers, gateways, settings, keys, the export, the + dashboards). `approve` and `administer` each imply `view`. **`administer` does not imply + `approve`**: deciding an effect and configuring the system are kept apart (separation of duties, + NIST SP 800-53 AC-5), so a deployment that wants one person to hold both grants both. + + A principal never carries a token. Its receipt form (`receipt/2`) is what reaches a receipt: + `sub`, `iss`, the mode that authenticated it, and the role the act needed. + """ + + @type role :: :view | :approve | :administer + + @type t :: %__MODULE__{ + sub: String.t(), + iss: String.t(), + mode: Trinity.WebAuth.mode(), + roles: [role()], + sid: String.t() | nil + } + + @enforce_keys [:sub, :iss, :mode] + defstruct sub: nil, iss: nil, mode: nil, roles: [], sid: nil + + @roles [:view, :approve, :administer] + + @doc "The three roles." + @spec roles() :: [role()] + def roles, do: @roles + + @doc """ + The principal of a loopback node with no login (`:none`): the person holding this machine, + with every role. The Host allow-list and the origin list are what hold the browser to this + machine; nothing here identifies anyone. + """ + @spec owner() :: t() + def owner, do: %__MODULE__{sub: "owner", iss: "loopback", mode: :none, roles: @roles} + + @doc """ + The principal of a `:local_token` session: the shared token, not a person, and the receipt says + so (`sub` and `iss` are both `local_token`). + """ + @spec local_token() :: t() + def local_token, + do: %__MODULE__{sub: "local_token", iss: "local_token", mode: :local_token, roles: @roles} + + @doc "True when the principal holds `role`, directly or by implication." + @spec has_role?(t() | nil, role()) :: boolean() + def has_role?(%__MODULE__{roles: roles}, :view), + do: Enum.any?(roles, &(&1 in @roles)) + + def has_role?(%__MODULE__{roles: roles}, role) when role in @roles, do: role in roles + def has_role?(_, _), do: false + + @doc "What a receipt records about the principal and the role the act needed." + @spec receipt(t(), role()) :: map() + def receipt(%__MODULE__{} = p, role) do + %{ + "sub" => p.sub, + "iss" => p.iss, + "mode" => Atom.to_string(p.mode), + "role" => Atom.to_string(role) + } + end + + @doc "The decider's name on an approval row: `sub` at `iss`." + @spec label(t()) :: String.t() + def label(%__MODULE__{sub: sub, iss: iss}), do: "#{sub} (#{iss})" +end diff --git a/lib/trinity_web/auth/require_role.ex b/lib/trinity_web/auth/require_role.ex new file mode 100644 index 0000000..738876b --- /dev/null +++ b/lib/trinity_web/auth/require_role.ex @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.Auth.RequireRole do + @moduledoc """ + The role a route needs (slice 136), in the router's pipelines: `plug TrinityWeb.Auth.RequireRole, + :view` on every page, `:administer` on the privileged routes (the export, the dashboards, the + authorization surfaces' owner pages). + + No principal is `401` (the gate in the endpoint already refused it, so reaching here without one + means the plug is mounted somewhere the gate exempts, and failing closed is the answer). A + principal without the role is `403`. A route that needs more than `view` is a privileged route: + its use and its refusal are both receipted with the principal and the role + (`TrinityWeb.Auth.privileged_receipt/4`), and a use that cannot be receipted is refused. + """ + @behaviour Plug + + import Plug.Conn + + alias TrinityWeb.Auth + alias TrinityWeb.Auth.Principal + + @impl Plug + def init(role) when role in [:view, :approve, :administer], do: role + + @impl Plug + def call(conn, role) do + case conn.assigns[:web_principal] do + %Principal{} = principal -> check(conn, principal, role) + _ -> conn |> send_resp(401, "authentication required\n") |> halt() + end + end + + defp check(conn, principal, :view) do + if Principal.has_role?(principal, :view), + do: conn, + else: forbid(conn, :view) + end + + defp check(conn, principal, role) do + conn = fetch_query_params(conn) + + detail = %{ + "phase" => "privileged_route", + "method" => conn.method, + "path" => conn.request_path, + # The export's one parameter that changes what leaves the machine; nothing else of the + # query is recorded. + "keys" => conn.query_params["keys"] in ["1", "true"] + } + + if Principal.has_role?(principal, role) do + case Auth.privileged_receipt(principal, role, "allow", detail) do + :ok -> conn + {:error, _} -> conn |> send_resp(503, "not receipted, so not done\n") |> halt() + end + else + _ = Auth.privileged_receipt(principal, role, "deny", detail) + forbid(conn, role) + end + end + + defp forbid(conn, role) do + conn + |> put_resp_content_type("text/plain") + |> send_resp(403, "forbidden: this needs the #{role} role\n") + |> halt() + end +end diff --git a/lib/trinity_web/auth/sessions.ex b/lib/trinity_web/auth/sessions.ex new file mode 100644 index 0000000..3928cf3 --- /dev/null +++ b/lib/trinity_web/auth/sessions.ex @@ -0,0 +1,121 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.Auth.Sessions do + @moduledoc """ + The web sessions this node has issued, held on the server so they can be ended (slice 136). + + The cookie carries only a random session id. The principal, the time it logged in and the time + it was last seen live here, in an ETS table this process owns, so: + + * **revocation is immediate**: `revoke/1` marks the row and broadcasts `"disconnect"` to the + session's LiveView sockets; the next request, mount, patch or event finds the mark (AC3); + * **idle and absolute timeouts are the server's**, not the cookie's: a session unused for + `idle_timeout_ms` (30 minutes by default) or older than `absolute_timeout_ms` (12 hours) + is refused, whatever the browser still holds; + * **a restart ends every session**, which is the right failure for a node whose cookie key is + regenerated on every boot when `SECRET_KEY_BASE` is unset. + + It also holds the `:local_token` rate limit (`throttle/2`): one guess per address per window. + """ + use GenServer + + alias TrinityWeb.Auth.Principal + + @table __MODULE__ + + @spec start_link(keyword()) :: GenServer.on_start() + def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__) + + @doc "The topic a session's LiveView sockets are registered under (`:live_socket_id`)." + @spec socket_id(String.t()) :: String.t() + def socket_id(sid), do: "web_session:" <> sid + + @doc "Records a logged-in principal and returns its new session id." + @spec create(Principal.t()) :: String.t() + def create(%Principal{} = principal) do + sid = 32 |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false) + now = now() + true = :ets.insert(@table, {sid, %{principal | sid: sid}, now, now, false}) + sid + end + + @doc """ + The principal of a session id, and the last-seen time moved to now; or why not: + `:unknown`, `:revoked`, `:idle_timeout` or `:absolute_timeout`. An expired row is deleted. + """ + @spec fetch(String.t() | nil) :: {:ok, Principal.t()} | {:error, atom()} + def fetch(sid) when is_binary(sid) do + now = now() + + case :ets.lookup(@table, sid) do + [] -> + {:error, :unknown} + + [{^sid, _p, _created, _seen, true}] -> + {:error, :revoked} + + [{^sid, principal, created, seen, false}] -> + cond do + now - created > config(:absolute_timeout_ms, 43_200_000) -> + :ets.delete(@table, sid) + {:error, :absolute_timeout} + + now - seen > config(:idle_timeout_ms, 1_800_000) -> + :ets.delete(@table, sid) + {:error, :idle_timeout} + + true -> + :ets.update_element(@table, sid, {4, now}) + {:ok, principal} + end + end + end + + def fetch(_), do: {:error, :unknown} + + @doc """ + Ends a session: the row is marked revoked (so the next check says why) and every LiveView + socket of it is told to disconnect. `broadcast: false` leaves the sockets connected, so a test + can show the next patch is refused on its own. + """ + @spec revoke(String.t(), keyword()) :: :ok + def revoke(sid, opts \\ []) when is_binary(sid) do + _ = :ets.update_element(@table, sid, {5, true}) + + if Keyword.get(opts, :broadcast, true), + do: TrinityWeb.Endpoint.broadcast(socket_id(sid), "disconnect", %{}) + + :ok + end + + @doc """ + One attempt per `key` per `window_ms`: `:ok` for the first, `{:error, :rate_limited}` for any + other inside the window. The window restarts on every attempt, refused ones included, so + guessing faster than the window never gets a guess through. + """ + @spec throttle(term(), non_neg_integer()) :: :ok | {:error, :rate_limited} + def throttle(key, window_ms) do + now = now() + row = {{:throttle, key}, now} + + case :ets.lookup(@table, {:throttle, key}) do + [{_, last}] when now - last < window_ms -> + :ets.insert(@table, row) + {:error, :rate_limited} + + _ -> + :ets.insert(@table, row) + :ok + end + end + + defp config(key, default), do: Keyword.get(Trinity.WebAuth.config(), key, default) + + defp now, do: System.monotonic_time(:millisecond) + + @impl true + def init(_opts) do + :ets.new(@table, [:named_table, :public, :set, read_concurrency: true]) + {:ok, %{}} + end +end diff --git a/lib/trinity_web/components/layouts.ex b/lib/trinity_web/components/layouts.ex index f97b9f8..6a8ed00 100644 --- a/lib/trinity_web/components/layouts.ex +++ b/lib/trinity_web/components/layouts.ex @@ -35,8 +35,21 @@ defmodule TrinityWeb.Layouts do slot :bar, doc: "what the page puts in the top bar, beside the brand" def app(assigns) do + # Slice 136: under a shared token every page says so, persistently; under any login the bar + # carries a sign-out. Read per render from the mode in force, not from the page's assigns, so + # no page can leave it out. + assigns = assign(assigns, :web_auth_mode, Trinity.WebAuth.mode()) + ~H"""
+
+ {gettext("Shared token, no per-user identity: whoever holds the token acts as the owner.")} +
<.link navigate={~p"/"} class="flex items-center gap-2 font-semibold tracking-tight"> @@ -47,6 +60,15 @@ defmodule TrinityWeb.Layouts do
{render_slot(@bar)}
+ <.form + :if={@web_auth_mode != :none} + for={%{}} + action={~p"/auth/logout"} + method="post" + id="sign-out" + > + + <.theme_toggle />
diff --git a/lib/trinity_web/controllers/auth_controller.ex b/lib/trinity_web/controllers/auth_controller.ex new file mode 100644 index 0000000..eb41842 --- /dev/null +++ b/lib/trinity_web/controllers/auth_controller.ex @@ -0,0 +1,147 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.AuthController do + @moduledoc """ + The login, its callback, the shared token's exchange, and the logout (slice 136). + + * `GET /auth/login`: under `:oidc`, the redirect to the issuer (authorization code, PKCE S256, + `state`, `nonce`); under `:local_token`, the token form; under `:none`, home. + * `GET /auth/callback`: the issuer's answer, checked by `oidcc_plug` (stored session, state, + `iss`, the code exchanged with the verifier, the ID token's signature, issuer, audience and + nonce), then roles from the ID token's claims (`TrinityWeb.Auth.Claims`). A login with no + Trinity role, or whose roles cannot be read, is refused with a page that says why. + * `POST /auth/token`: the shared token, compared in constant time, one guess per address per + window (`429` inside it), exchanged for a session; the token is never logged (the parameter + is `secret`, which `:filter_parameters` names) and never stored. + * `POST /auth/logout`: the session ended on the server and the cookie dropped. + """ + use TrinityWeb, :controller + + require Logger + + alias TrinityWeb.Auth + alias TrinityWeb.Auth.{Claims, OIDC, Principal, Sessions} + + def login(conn, _params) do + case Trinity.WebAuth.mode() do + :oidc -> oidc_login(conn) + :local_token -> render(conn, :token) + :none -> redirect(conn, to: ~p"/") + end + end + + # PKCE with S256 or no login: oidcc falls back to `plain` when an issuer offers only that, and + # `plain` sends the verifier itself as the challenge, which protects nothing against a party + # that can read the authorization request. + defp oidc_login(conn) do + case OIDC.s256_offered?() do + true -> + Oidcc.Plug.Authorize.call(conn, OIDC.authorize_opts(conn)) + + false -> + Logger.error("web login: the issuer does not offer PKCE S256; refusing to start a login") + denied(conn, 503, gettext("The identity provider does not offer PKCE with S256.")) + + {:error, reason} -> + Logger.error("web login: the issuer's configuration is unavailable: #{inspect(reason)}") + denied(conn, 503, gettext("The identity provider cannot be reached.")) + end + end + + def callback(conn, _params) do + if Trinity.WebAuth.mode() == :oidc do + conn = Oidcc.Plug.AuthorizationCallback.call(conn, OIDC.callback_opts(conn)) + finish(conn, conn.private[Oidcc.Plug.AuthorizationCallback]) + else + send_resp(conn, 404, "") + end + end + + defp finish(conn, {:ok, {%Oidcc.Token{id: %Oidcc.Token.Id{claims: claims}}, _userinfo}}) do + config = Trinity.WebAuth.config() + + case Claims.roles(claims, config[:client_id], config[:role_claim]) do + {:ok, roles} -> + principal = %Principal{ + sub: claims["sub"], + iss: claims["iss"], + mode: :oidc, + roles: roles + } + + conn |> Auth.log_in(principal) |> redirect(to: ~p"/") + + {:error, reason} -> + Logger.warning( + "web login refused for #{claims["sub"]} at #{claims["iss"]}: #{inspect(reason)}" + ) + + denied(conn, 403, Claims.describe(reason)) + end + end + + # Only the kind of refusal is logged: some of oidcc's reasons carry the token's claims, which name + # a person and are not the log's to keep. + defp finish(conn, {:error, reason}) do + Logger.warning("web login callback refused: #{describe(reason)}") + denied(conn, 401, "The sign-in could not be completed (#{describe(reason)}). Start again.") + end + + defp finish(conn, _other), do: denied(conn, 401, "The sign-in could not be completed.") + + defp describe({kind, _detail}) when is_atom(kind), do: Atom.to_string(kind) + defp describe(kind) when is_atom(kind), do: Atom.to_string(kind) + defp describe(_), do: "refused" + + def token(conn, params) do + with :local_token <- Trinity.WebAuth.mode(), + :ok <- throttle(conn), + true <- matches?(params["secret"]) do + conn |> Auth.log_in(Principal.local_token()) |> redirect(to: ~p"/") + else + {:error, :rate_limited} -> + conn |> put_resp_content_type("text/plain") |> send_resp(429, "too many attempts\n") + + false -> + conn + |> put_status(401) + |> put_flash(:error, gettext("That token is not this Trinity's.")) + |> render(:token) + + _mode -> + send_resp(conn, 404, "") + end + end + + def logout(conn, _params) do + conn |> Auth.log_out() |> redirect(to: ~p"/auth/login") + end + + def forbidden(conn, _params), + do: denied(conn, 403, gettext("Your role does not include this page.")) + + defp denied(conn, status, reason) do + conn + |> put_status(status) + |> render(:denied, title: gettext("Not signed in"), reason: reason) + end + + defp throttle(conn) do + window = Keyword.get(Trinity.WebAuth.config(), :token_rate_window_ms, 2_000) + Sessions.throttle({:local_token, conn.remote_ip}, window) + end + + # Both sides hashed first, so the comparison is over equal lengths and its time says nothing + # about the length of either; `Plug.Crypto.secure_compare/2` is constant time over those. + defp matches?(guess) when is_binary(guess) do + case Keyword.get(Trinity.WebAuth.config(), :token) do + token when is_binary(token) -> + Plug.Crypto.secure_compare(:crypto.hash(:sha256, guess), :crypto.hash(:sha256, token)) + + _ -> + false + end + end + + defp matches?(_), do: false +end diff --git a/lib/trinity_web/controllers/auth_html.ex b/lib/trinity_web/controllers/auth_html.ex new file mode 100644 index 0000000..fcbe15d --- /dev/null +++ b/lib/trinity_web/controllers/auth_html.ex @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.AuthHTML do + @moduledoc "The login pages (slice 136): the shared token form, a refused login, a refused role." + use TrinityWeb, :html + + embed_templates "auth_html/*" +end diff --git a/lib/trinity_web/controllers/auth_html/denied.html.heex b/lib/trinity_web/controllers/auth_html/denied.html.heex new file mode 100644 index 0000000..b1401d7 --- /dev/null +++ b/lib/trinity_web/controllers/auth_html/denied.html.heex @@ -0,0 +1,11 @@ +<%!-- SPDX-FileCopyrightText: Sudo Apt Holdings LLC --%> +<%!-- SPDX-License-Identifier: Apache-2.0 --%> + + <:bar> + {@title} + +
+

{@title}

+

{@reason}

+
+
diff --git a/lib/trinity_web/controllers/auth_html/token.html.heex b/lib/trinity_web/controllers/auth_html/token.html.heex new file mode 100644 index 0000000..983214a --- /dev/null +++ b/lib/trinity_web/controllers/auth_html/token.html.heex @@ -0,0 +1,30 @@ +<%!-- SPDX-FileCopyrightText: Sudo Apt Holdings LLC --%> +<%!-- SPDX-License-Identifier: Apache-2.0 --%> + + <:bar> + {gettext("Sign in")} + +
+

+ {gettext( + "This Trinity is shared with a token, not with accounts. Whoever holds the token is the owner here: there is no per-user identity, and the receipts say so." + )} +

+ <.form + for={%{}} + as={:secret} + action={~p"/auth/token"} + method="post" + class="flex flex-col gap-2" + > + + + +
+
diff --git a/lib/trinity_web/endpoint.ex b/lib/trinity_web/endpoint.ex index 8037999..606073c 100644 --- a/lib/trinity_web/endpoint.ex +++ b/lib/trinity_web/endpoint.ex @@ -6,10 +6,14 @@ defmodule TrinityWeb.Endpoint do # The session will be stored in the cookie and signed, # this means its contents can be read but not tampered with. # Set :encryption_salt if you would also like to encrypt it. + # Slice 136: encrypted as well as signed (the OpenID Connect login keeps its PKCE verifier and + # nonce here between the redirect and the callback), and `SameSite`/`Secure` chosen per request + # by `TrinityWeb.Plugs.Session`. @session_options [ store: :cookie, key: "_trinity_key", signing_salt: "NacAdZLW", + encryption_salt: "q8N3vXwT", same_site: "Lax" ] @@ -17,6 +21,11 @@ defmodule TrinityWeb.Endpoint do websocket: [connect_info: [session: @session_options]], longpoll: [connect_info: [session: @session_options]] + # Slice 136: a request whose Host is not this machine's own is refused before anything else + # sees it (DNS rebinding; Phoenix and Bandit have no such check). The websocket transports + # above are dispatched before any plug and are held by `check_origin` instead. + plug TrinityWeb.Plugs.HostAllowList + # Serve at "/" the static files from "priv/static" directory. # # When code reloading is disabled (e.g., in production), @@ -57,6 +66,8 @@ defmodule TrinityWeb.Endpoint do plug Plug.MethodOverride plug Plug.Head - plug Plug.Session, @session_options + plug TrinityWeb.Plugs.Session, @session_options + # Slice 136: no principal, no route (TrinityWeb.Auth). + plug TrinityWeb.Auth.Gate plug TrinityWeb.Router end diff --git a/lib/trinity_web/live/permissions_live.ex b/lib/trinity_web/live/permissions_live.ex index 7e10ba6..2795dc8 100644 --- a/lib/trinity_web/live/permissions_live.ex +++ b/lib/trinity_web/live/permissions_live.ex @@ -66,6 +66,9 @@ defmodule TrinityWeb.PermissionsLive do opts = if decision == "always", do: [pattern: Map.get(socket.assigns.patterns, id, "*")], else: [] + # Slice 136: who decided, on the row and on the approval receipt. + opts = opts ++ TrinityWeb.Auth.decider_opts(socket) + case Permissions.decide_request(id, String.to_existing_atom(decision), opts) do {:ok, _} -> {:noreply, load(socket)} @@ -137,7 +140,12 @@ defmodule TrinityWeb.PermissionsLive do def handle_event("approval_answer", %{"approval_id" => id} = params, socket) do with %Approval{} = approval <- Permissions.get_approval(id), answer = answer_from_params(approval, params), - {:ok, _} <- Permissions.decide_request(id, :once, answer: answer) do + {:ok, _} <- + Permissions.decide_request( + id, + :once, + [answer: answer] ++ TrinityWeb.Auth.decider_opts(socket) + ) do {:noreply, load(socket)} else nil -> diff --git a/lib/trinity_web/live/session_live/show.ex b/lib/trinity_web/live/session_live/show.ex index 5bad194..e59ce9d 100644 --- a/lib/trinity_web/live/session_live/show.ex +++ b/lib/trinity_web/live/session_live/show.ex @@ -272,6 +272,9 @@ defmodule TrinityWeb.SessionLive.Show do do: [pattern: Map.get(socket.assigns.patterns, id) || suggested(socket, id)], else: [] + # Slice 136: who decided, on the row and on the approval receipt. + opts = opts ++ TrinityWeb.Auth.decider_opts(socket) + case Permissions.decide_request(id, String.to_existing_atom(decision), opts) do {:ok, _} -> {:noreply, socket} @@ -290,7 +293,12 @@ defmodule TrinityWeb.SessionLive.Show do def handle_event("approval_answer", %{"approval_id" => id} = params, socket) do with %Approval{} = approval <- Permissions.get_approval(id), answer = answer_from_params(approval, params), - {:ok, _} <- Permissions.decide_request(id, :once, answer: answer) do + {:ok, _} <- + Permissions.decide_request( + id, + :once, + [answer: answer] ++ TrinityWeb.Auth.decider_opts(socket) + ) do {:noreply, socket} else nil -> diff --git a/lib/trinity_web/live/skills_live.ex b/lib/trinity_web/live/skills_live.ex index 0a18067..bedeb0f 100644 --- a/lib/trinity_web/live/skills_live.ex +++ b/lib/trinity_web/live/skills_live.ex @@ -54,7 +54,12 @@ defmodule TrinityWeb.SkillsLive do def handle_event("approve_change", %{"change_id" => id} = params, socket) do with %{} = change <- Staging.get(id), - {:ok, _} <- Manager.approve(change, by: "ui", comment: blank_to_nil(params["comment"])) do + {:ok, _} <- + Manager.approve( + change, + Keyword.merge([by: "ui"], TrinityWeb.Auth.decider_opts(socket)) ++ + [comment: blank_to_nil(params["comment"])] + ) do {:noreply, socket |> assign(showing: nil) @@ -72,7 +77,12 @@ defmodule TrinityWeb.SkillsLive do def handle_event("reject_change", %{"id" => id} = params, socket) do with %{} = change <- Staging.get(id), - {:ok, _} <- Manager.reject(change, by: "ui", comment: blank_to_nil(params["comment"])) do + {:ok, _} <- + Manager.reject( + change, + Keyword.merge([by: "ui"], TrinityWeb.Auth.decider_opts(socket)) ++ + [comment: blank_to_nil(params["comment"])] + ) do {:noreply, socket |> assign(showing: nil) |> load() |> put_flash(:info, gettext("Rejected."))} else diff --git a/lib/trinity_web/plugs/host_allow_list.ex b/lib/trinity_web/plugs/host_allow_list.ex new file mode 100644 index 0000000..ed85401 --- /dev/null +++ b/lib/trinity_web/plugs/host_allow_list.ex @@ -0,0 +1,71 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.Plugs.HostAllowList do + @moduledoc """ + Answers `403` to a request whose `Host` is not this machine's own (slice 136, AC5). + + Neither Phoenix nor Bandit checks the Host header. Without a check, a page on any site can point + a name it controls at `127.0.0.1` (DNS rebinding) and then read Trinity's pages from the + browser of the person running it, because to the browser that is the attacker's own origin. A + loopback bind does not help: the browser making the request is on the loopback. + + The allowed names are the loopback names (`localhost`, `127.0.0.1`, `::1` written with or + without brackets), the endpoint's configured host (`PHX_HOST` in a release), and + `config :trinity, :web_auth, extra_hosts:`. Compared without the port, case-insensitively, and + with a trailing dot removed. + + First in the endpoint, ahead of the static files, the MCP server and the router, so a refused + request has been routed nowhere. The websocket transports are dispatched by Phoenix ahead of + every plug in the endpoint, so they never reach this check; the origin list + (`check_origin`, AC6) is what refuses a foreign page there. + + The list is read on every request rather than in `init/1`, which the release runs at compile + time (slice 062 NOTES, F5). + """ + @behaviour Plug + + import Plug.Conn + + @loopback ["localhost", "127.0.0.1", "::1"] + + @impl Plug + def init(opts), do: opts + + @impl Plug + def call(conn, _opts) do + if allowed?(conn.host) do + conn + else + conn + |> put_resp_content_type("text/plain") + |> send_resp(403, "host not allowed\n") + |> halt() + end + end + + @doc "True when `host` is one this node answers to." + @spec allowed?(String.t() | nil) :: boolean() + def allowed?(host) when is_binary(host), do: normalize(host) in allowed_hosts() + def allowed?(_), do: false + + @doc "The names this node answers to, normalised." + @spec allowed_hosts() :: [String.t()] + def allowed_hosts do + configured = TrinityWeb.Endpoint.config(:url)[:host] + extra = Keyword.get(Trinity.WebAuth.config(), :extra_hosts, []) + + [configured | extra] + |> Enum.filter(&is_binary/1) + |> Enum.map(&normalize/1) + |> Enum.concat(@loopback) + |> Enum.uniq() + end + + defp normalize(host) do + host + |> String.downcase() + |> String.trim_trailing(".") + |> String.trim_leading("[") + |> String.trim_trailing("]") + end +end diff --git a/lib/trinity_web/plugs/session.ex b/lib/trinity_web/plugs/session.ex new file mode 100644 index 0000000..b668329 --- /dev/null +++ b/lib/trinity_web/plugs/session.ex @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.Plugs.Session do + @moduledoc """ + `Plug.Session` with the cookie's `SameSite` and `Secure` chosen per request (slice 136). + + One cookie, four ways of setting it, all initialised in `init/1` from plain options (no + closures, so the release's compile-time `init/1` is safe): + + * `SameSite=Strict` under `:local_token`, so no other site can make the browser send the + shared token's session; `Lax` otherwise, because an OpenID Connect callback is a top-level + navigation from the issuer's site and a `Strict` cookie would not come back with it, losing + the stored state, nonce and verifier the callback is checked against; + * `Secure` whenever the request arrived over TLS. + + The cookie is signed and encrypted. Reading it back (the LiveView socket's `connect_info`) needs + only the key and the salts, which all four share. + """ + @behaviour Plug + + @impl Plug + def init(opts) do + %{ + lax: Plug.Session.init(Keyword.merge(opts, same_site: "Lax")), + lax_secure: Plug.Session.init(Keyword.merge(opts, same_site: "Lax", secure: true)), + strict: Plug.Session.init(Keyword.merge(opts, same_site: "Strict")), + strict_secure: Plug.Session.init(Keyword.merge(opts, same_site: "Strict", secure: true)) + } + end + + @impl Plug + def call(conn, configs) do + key = + case {Trinity.WebAuth.mode() == :local_token, conn.scheme == :https} do + {false, false} -> :lax + {false, true} -> :lax_secure + {true, false} -> :strict + {true, true} -> :strict_secure + end + + Plug.Session.call(conn, Map.fetch!(configs, key)) + end +end diff --git a/lib/trinity_web/router.ex b/lib/trinity_web/router.ex index 0be5ee6..abab094 100644 --- a/lib/trinity_web/router.ex +++ b/lib/trinity_web/router.ex @@ -14,6 +14,19 @@ defmodule TrinityWeb.Router do plug :put_secure_browser_headers end + # Slice 136: every page needs a principal with `view` (TrinityWeb.Auth.Gate in the endpoint has + # already refused a request with none). Every route that pipes through :browser pipes through + # this too, except the login pages, which are the only HTML served without a principal; the + # route census (test/trinity_web/auth/route_census_test.exs) holds that. + pipeline :viewer do + plug TrinityWeb.Auth.RequireRole, :view + end + + # Slice 136: the privileged routes. Each use and each refusal is receipted with the principal. + pipeline :administer do + plug TrinityWeb.Auth.RequireRole, :administer + end + pipeline :api do plug :accepts, ["json"] end @@ -35,11 +48,25 @@ defmodule TrinityWeb.Router do post "/oauth/register", OAuthController, :register end - # Slice 013: the chat. One local user, so no scope is fetched; the session id is the URL. - scope "/", TrinityWeb do + # Slice 136: the login (OIDC or the shared token), its callback, the logout. + scope "/auth", TrinityWeb do pipe_through :browser - live_session :chat do + get "/login", AuthController, :login + get "/callback", AuthController, :callback + post "/token", AuthController, :token + post "/logout", AuthController, :logout + get "/forbidden", AuthController, :forbidden + end + + # Slice 013: the chat. The session id is the URL. + # Slice 136: every live route mounts through `TrinityWeb.Auth.on_mount/4`, which checks the + # principal on mount, on every patch and on every event (TrinityWeb.Auth.Policy names the role + # each event needs). + scope "/", TrinityWeb do + pipe_through [:browser, :viewer] + + live_session :chat, on_mount: [{TrinityWeb.Auth, :view}] do live "/", SessionLive.Index, :index live "/s/:id", SessionLive.Show, :show # Slice 021: the approvals audit and the rules. @@ -52,7 +79,6 @@ defmodule TrinityWeb.Router do live "/settings", SettingsLive, :index # Slice 100: the first-run path, reusing the settings components. live "/setup", SetupLive, :index - get "/settings/export.tar.gz", ExportController, :download # Slice 030: personas and the always-on memory. live "/personas", PersonasLive, :index live "/personas/:id", PersonasLive, :edit @@ -69,6 +95,14 @@ defmodule TrinityWeb.Router do live "/s/:id/receipts", ReceiptsLive, :session live "/receipts/boot", ReceiptsLive, :boot end + end + + # Slice 136: the privileged routes, `administer` and receipted. + scope "/", TrinityWeb do + pipe_through [:browser, :viewer, :administer] + + # Slice 034: the export as a download; `keys=1` carries the private keys. + get "/settings/export.tar.gz", ExportController, :download # Slice 062: the owner's pages of the authorization flows (a session and CSRF: the consent # is a form the owner submits; the callback lands in the browser). @@ -83,32 +117,32 @@ defmodule TrinityWeb.Router do # end # Slice 050: Oban's dashboard, in development and wherever `config :trinity, :oban_web` is - # set (the pages carry no authentication yet; the same rule as every other page). + # set. Slice 136: `administer`, by the pipeline and again on mount. if Application.compile_env(:trinity, :dev_routes) || Application.compile_env(:trinity, :oban_web, false) do import Oban.Web.Router scope "/" do - pipe_through :browser - oban_dashboard("/oban", csp_nonce_assign_key: :csp_nonce) + pipe_through [:browser, :viewer, :administer] + + oban_dashboard("/oban", + csp_nonce_assign_key: :csp_nonce, + on_mount: [{TrinityWeb.Auth, :administer}] + ) end end - # Enable LiveDashboard in development + # Enable LiveDashboard in development. Slice 136: `administer`, by the pipeline and on mount. if Application.compile_env(:trinity, :dev_routes) do - # If you want to use the LiveDashboard in production, you should put - # it behind authentication and allow only admins to access it. - # If your application does not have an admins-only section yet, - # you can use Plug.BasicAuth to set up some basic authentication - # as long as you are also using SSL (which you should anyway). import Phoenix.LiveDashboard.Router scope "/dev" do - pipe_through :browser + pipe_through [:browser, :viewer, :administer] live_dashboard "/dashboard", metrics: TrinityWeb.Telemetry, csp_nonce_assign_key: :csp_nonce, + on_mount: [{TrinityWeb.Auth, :administer}], # Slice 090: which session processes are alive and what state each machine is in. additional_pages: [trinity_sessions: TrinityWeb.Dashboard.SessionsPage] end diff --git a/mix.exs b/mix.exs index f68e239..be60edc 100644 --- a/mix.exs +++ b/mix.exs @@ -192,6 +192,11 @@ defmodule Trinity.MixProject do {:oban_web, "~> 2.13"}, # Slice 062: JWT validation and issuance (JWK, JWS, JWKS) over OTP's crypto. {:jose, "~> 1.11"}, + # Slice 136: the web pages' OpenID Connect login (relying party, authorization code with + # PKCE). Floors are the 2026 security fixes: oidcc 3.9.0 (CVE-2026-75759) and oidcc_plug + # 0.5.1 (0.5.0 fixed CVE-2026-66883 and CVE-2026-66884; 0.5.0 itself is retired). + {:oidcc, "~> 3.9"}, + {:oidcc_plug, "~> 0.5.1"}, # Slice 013 (owner decision, 2026-09-20): the linux package builds mdex's NIF from # source for musl (MDEX_NATIVE_BUILD=1 and TRINITY_NIF_TARGET in config/config.exs), # because neither precompiled artifact loads in Burrito's musl ERTS (NOTES finding 13). diff --git a/mix.lock b/mix.lock index e661a48..ea22e89 100644 --- a/mix.lock +++ b/mix.lock @@ -66,6 +66,8 @@ "oban": {:hex, :oban, "2.24.1", "2a609c54697ad2c44ba339df30491df2a40eda0758c95b5b879426e4e478bd1f", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ef8482472cf198554400b7f8e36a0ffee75c3a64de425d5c7ee625d271925ac7"}, "oban_met": {:hex, :oban_met, "1.3.1", "1d8f5ab69e94802c8fbc154dfb3de346528f55f08724f591d3c327bf4fca2ef8", [:mix], [{:oban, "~> 2.21", [hex: :oban, repo: "hexpm", optional: false]}], "hexpm", "abf49524a305bad413fef7cad798fcd3a2d42ec739a0736475582c5dc8cbca1c"}, "oban_web": {:hex, :oban_web, "2.13.0", "9c37ab5c2b976681a647dead0de8a67139620e9ff636d041b5324f90c34467b3", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, "~> 2.24", [hex: :oban, repo: "hexpm", optional: false]}, {:oban_met, "~> 1.1", [hex: :oban_met, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "78af08760ca455ab7d19a311c77de07a0e7fb512d7d68043185cee04d51fbee2"}, + "oidcc": {:hex, :oidcc, "3.9.0", "de42140f108a8e891570728d8d595a119cc2b7e803021bceff6f9811a34f58b7", [:mix, :rebar3], [{:igniter, ">= 0.6.3 and < 0.9.0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jose, "~> 1.11", [hex: :jose, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_registry, "~> 0.3.1", [hex: :telemetry_registry, repo: "hexpm", optional: false]}], "hexpm", "5a825092fe9b3214017a5777ba7ed871c6e3ff9532848f600f6c1fe31c5e41ce"}, + "oidcc_plug": {:hex, :oidcc_plug, "0.5.1", "82f583efbfc607f5a32337e2415d0347a3ba8fdb6daff3ef3b147b3a5b8cd0bf", [:mix], [{:igniter, ">= 0.5.50 and < 0.9.0", [hex: :igniter, repo: "hexpm", optional: true]}, {:oidcc, "~> 3.7", [hex: :oidcc, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "693299f99ed67600396a770c03c293a3449ba942d14397be0c3495b2ab8c1fc2"}, "optimus": {:hex, :optimus, "0.6.1", "b493bdc7ee71035fdf8cbfbf158b1863e2d622b65c3906153e4042815c901fca", [:mix], [], "hexpm", "c0db4107a51f5af94de8b05e4208333ebb8016a3bfdbcd74df6e5c99829db17f"}, "owl": {:hex, :owl, "0.13.1", "1ec4a5dea170465f0e90c502c203079224516bc0cbd599281c8667b3c6ef8848", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "351e768af8f2edc575cdaab1a5a2f6d6381be591758a026c701c703145508a0c"}, "pgvector": {:hex, :pgvector, "0.4.1", "f45b3be1ad37efa944b418757da5414eb59085997c6de85ea83ac5b5b82701af", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:nx, "~> 0.5 or ~> 1.0", [hex: :nx, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "2bb5c468ff6c435941ca326b408a0a01a1526dcd8f2c3c478be44be091ad88e4"}, @@ -100,6 +102,7 @@ "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.2.0", "7632c19c01d88d8aaca5da1a0e8912f5af39b79e7c08a2c253aeb3c14c2c957e", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "71dde12fc29b58b9c77ec17ec319109e5ca848d010fc1965ed4463bba1837c07"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, + "telemetry_registry": {:hex, :telemetry_registry, "0.3.2", "701576890320be6428189bff963e865e8f23e0ff3615eade8f78662be0fc003c", [:mix, :rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7ed191eb1d115a3034af8e1e35e4e63d5348851d556646d46ca3d1b4e16bab9"}, "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "texture": {:hex, :texture, "1.2.1", "ae8d875fb099bcc1ccb0269296bf6c4590116eff9a44895049379359a14d5fca", [:mix], [{:abnf_parsec, "~> 2.0", [hex: :abnf_parsec, repo: "hexpm", optional: false]}], "hexpm", "925b1938891ce5c1d589df408faa7ed57dd872e4aac9521b32495a29b265cb17"}, "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, diff --git a/test/support/fake_oidc_issuer.ex b/test/support/fake_oidc_issuer.ex new file mode 100644 index 0000000..2df8e19 --- /dev/null +++ b/test/support/fake_oidc_issuer.ex @@ -0,0 +1,169 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.FakeOIDCIssuer do + @moduledoc """ + An OpenID Connect issuer on a loopback port, in the test's own VM (slice 136, AC7 and AC8). + + It serves what a relying party reads: the discovery document, the JWKS, and the token endpoint. + Its key is an ES256 key generated when it starts; nothing is fixed in the tree. There is no + authorization endpoint page: a test reads the authorization request from the redirect Trinity + sends and calls `issue_code/2` as the issuer would after the user signed in, choosing the claims. + + The token endpoint checks what an issuer checks and nothing more: the code exists and is used + once, the redirect URI matches the one the code was issued for, and the PKCE verifier hashes to + the stored challenge (S256). The ID token carries the nonce of the authorization request unless + the test asks otherwise, so a test can show the relying party refusing a wrong one. + + Options at `start/1`: `code_challenge_methods: ["S256"]` (what discovery advertises), + `iss_parameter: true` (RFC 9207 advertised). + """ + use Plug.Router + + plug :match + plug Plug.Parsers, parsers: [:urlencoded], pass: ["*/*"] + plug :dispatch + + @doc "Starts the issuer; returns `%{url: issuer_url, state: agent}`. Stopped with the test." + @spec start(keyword()) :: %{url: String.t(), state: pid()} + def start(opts \\ []) do + jwk = + JOSE.JWK.generate_key({:ec, "P-256"}) |> JOSE.JWK.merge(%{"kid" => "k1", "use" => "sig"}) + + {:ok, agent} = + Agent.start_link(fn -> %{jwk: jwk, codes: %{}, opts: opts, url: nil, token_calls: 0} end) + + {:ok, server} = + Bandit.start_link( + plug: {__MODULE__, agent}, + ip: {127, 0, 0, 1}, + port: 0, + startup_log: false + ) + + {:ok, {_ip, port}} = ThousandIsland.listener_info(server) + url = "http://127.0.0.1:#{port}" + Agent.update(agent, &Map.put(&1, :url, url)) + + ExUnit.Callbacks.on_exit(fn -> + for pid <- [server, agent], Process.alive?(pid), do: Process.exit(pid, :normal) + end) + + %{url: url, state: agent, server: server} + end + + @doc """ + What the issuer does once the user has signed in: a code bound to the authorization request's + `nonce`, `code_challenge` and `redirect_uri`, which `/token` will exchange for an ID token with + `claims` (merged over `sub`, `iss`, `aud`, `exp`, `iat` and `nonce`). + """ + @spec issue_code(%{state: pid()}, map(), map()) :: String.t() + def issue_code(%{state: agent}, request, claims) do + code = Base.url_encode64(:crypto.strong_rand_bytes(16), padding: false) + + Agent.update(agent, fn s -> + put_in(s, [:codes, code], %{request: request, claims: claims}) + end) + + code + end + + @doc "How many requests the token endpoint has had, refused ones included." + @spec token_calls(%{state: pid()}) :: non_neg_integer() + def token_calls(%{state: agent}), do: Agent.get(agent, & &1.token_calls) + + @impl Plug + def init(agent), do: agent + + @impl Plug + def call(conn, agent), do: conn |> put_private(:issuer, agent) |> super([]) + + get "/.well-known/openid-configuration" do + s = Agent.get(conn.private.issuer, & &1) + + doc = + %{ + "issuer" => s.url, + "authorization_endpoint" => s.url <> "/authorize", + "token_endpoint" => s.url <> "/token", + "jwks_uri" => s.url <> "/jwks", + "response_types_supported" => ["code"], + "scopes_supported" => ["openid", "profile"], + "subject_types_supported" => ["public"], + "id_token_signing_alg_values_supported" => ["ES256"], + "token_endpoint_auth_methods_supported" => ["client_secret_basic", "client_secret_post"], + "code_challenge_methods_supported" => + Keyword.get(s.opts, :code_challenge_methods, ["S256"]), + "authorization_response_iss_parameter_supported" => + Keyword.get(s.opts, :iss_parameter, true) + } + + json(conn, 200, doc) + end + + get "/jwks" do + jwk = Agent.get(conn.private.issuer, & &1.jwk) + {_, public} = jwk |> JOSE.JWK.to_public() |> JOSE.JWK.to_map() + json(conn, 200, %{"keys" => [public]}) + end + + post "/token" do + agent = conn.private.issuer + Agent.update(agent, &Map.update!(&1, :token_calls, fn n -> n + 1 end)) + params = conn.body_params + code = params["code"] + + entry = + Agent.get_and_update(agent, fn s -> + {get_in(s, [:codes, code]), update_in(s, [:codes], &Map.delete(&1, code))} + end) + + with %{request: request, claims: claims} <- entry, + true <- params["redirect_uri"] == request["redirect_uri"], + true <- s256(params["code_verifier"]) == request["code_challenge"] do + s = Agent.get(agent, & &1) + now = System.system_time(:second) + + base = %{ + "iss" => s.url, + "sub" => "user-1", + "aud" => request["client_id"], + "iat" => now, + "exp" => now + 300, + "nonce" => request["nonce"] + } + + id_token = sign(s.jwk, Map.merge(base, claims)) + + json(conn, 200, %{ + "access_token" => "at-" <> code, + "token_type" => "Bearer", + "expires_in" => 300, + "id_token" => id_token + }) + else + _ -> json(conn, 400, %{"error" => "invalid_grant"}) + end + end + + match _ do + send_resp(conn, 404, "") + end + + defp s256(nil), do: nil + defp s256(verifier), do: :crypto.hash(:sha256, verifier) |> Base.url_encode64(padding: false) + + defp sign(jwk, claims) do + {_, token} = + jwk + |> JOSE.JWT.sign(%{"alg" => "ES256", "kid" => "k1"}, claims) + |> JOSE.JWS.compact() + + token + end + + defp json(conn, status, body) do + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp(status, Jason.encode!(body)) + end +end diff --git a/test/support/raw_http.ex b/test/support/raw_http.ex new file mode 100644 index 0000000..e9b245f --- /dev/null +++ b/test/support/raw_http.ex @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.RawHTTP do + @moduledoc """ + One HTTP/1.1 request over a plain TCP socket to the suite's running endpoint (slice 136). + + Raw because the point is to send what a browser under attack would send: a `Host` that is not + this machine's, an `Origin` from another site, a websocket upgrade. An HTTP client library + rewrites or refuses those. The endpoint serves on an ephemeral loopback port in the suite + (config/test.exs), so this is a loopback connection and reaches no network. + """ + + @doc "The port the suite's endpoint bound." + @spec port() :: :inet.port_number() + def port do + {:ok, {_ip, port}} = TrinityWeb.Endpoint.server_info(:http) + port + end + + @doc """ + Sends `method path` with `headers` (a list of `{name, value}`; `Host` is not added for you) + and returns `{status, headers, body}` from what arrives within a second. + """ + @spec request(String.t(), String.t(), [{String.t(), String.t()}]) :: + {integer(), [{String.t(), String.t()}], String.t()} + def request(method, path, headers) do + {:ok, socket} = + :gen_tcp.connect(~c"127.0.0.1", port(), [:binary, active: false, packet: :raw], 2_000) + + head = + ["#{method} #{path} HTTP/1.1\r\n"] ++ + Enum.map(headers, fn {k, v} -> "#{k}: #{v}\r\n" end) ++ + ["connection: close\r\n", "\r\n"] + + :ok = :gen_tcp.send(socket, head) + raw = recv_all(socket, "") + :gen_tcp.close(socket) + parse(raw) + end + + defp recv_all(socket, acc) do + case :gen_tcp.recv(socket, 0, 1_000) do + {:ok, data} -> + acc = acc <> data + # A 101 keeps the connection open; its head is all there is to read. + if String.starts_with?(acc, "HTTP/1.1 101") and String.contains?(acc, "\r\n\r\n"), + do: acc, + else: recv_all(socket, acc) + + {:error, _} -> + acc + end + end + + defp parse(raw) do + [head | rest] = String.split(raw, "\r\n\r\n", parts: 2) + [status_line | header_lines] = String.split(head, "\r\n") + [_, code | _] = String.split(status_line, " ", parts: 3) + + headers = + for line <- header_lines, + [k, v] <- [String.split(line, ": ", parts: 2)], + do: {String.downcase(k), v} + + {String.to_integer(code), headers, Enum.join(rest)} + end +end diff --git a/test/support/web_auth_helpers.ex b/test/support/web_auth_helpers.ex new file mode 100644 index 0000000..61f9a35 --- /dev/null +++ b/test/support/web_auth_helpers.ex @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.WebAuthHelpers do + @moduledoc """ + Slice 136's test helpers: put the web pages in a login mode for one test, and log a principal + in with chosen roles. + + The suite binds the loopback, so the pages run in `:none` unless a test calls `web_auth!/2`. + That changes application environment, so **every test that calls it must be `async: false`**; + ExUnit runs every async module before the synchronous ones, so no async LiveView test can see + the mode a synchronous test set. The previous environment is restored on exit. + + `log_in_web/3` is the helper the slice brief asks for: it creates a real server-side session + through `TrinityWeb.Auth.Sessions` and puts its id in the test conn's session, so the request + goes through the same gate, role plug and `on_mount` checks a browser's would. Nothing in + `lib/` reads anything this helper sets that a browser could not. + """ + import ExUnit.Callbacks, only: [on_exit: 1] + + alias TrinityWeb.Auth.{Principal, Sessions} + + @doc "Runs the rest of the test with `config :trinity, :web_auth` merged with `mode` and `extra`." + @spec web_auth!(Trinity.WebAuth.mode(), keyword()) :: :ok + def web_auth!(mode, extra \\ []) do + previous = Application.get_env(:trinity, :web_auth, []) + on_exit(fn -> Application.put_env(:trinity, :web_auth, previous) end) + + Application.put_env( + :trinity, + :web_auth, + previous |> Keyword.merge(extra) |> Keyword.merge(mode: mode, mode_in_force: mode) + ) + end + + @doc "A principal with `roles`, as an issuer would have produced it." + @spec principal([Principal.role()], keyword()) :: Principal.t() + def principal(roles, opts \\ []) do + %Principal{ + sub: Keyword.get(opts, :sub, "user-#{System.unique_integer([:positive])}"), + iss: Keyword.get(opts, :iss, "https://issuer.test"), + mode: Keyword.get(opts, :mode, :oidc), + roles: roles + } + end + + @doc "Logs a principal with `roles` into `conn`; returns `{conn, sid}`." + @spec log_in_web(Plug.Conn.t(), [Principal.role()], keyword()) :: {Plug.Conn.t(), String.t()} + def log_in_web(conn, roles, opts \\ []) do + sid = Sessions.create(principal(roles, opts)) + + conn = + Plug.Test.init_test_session(conn, %{ + "web_sid" => sid, + "live_socket_id" => Sessions.socket_id(sid) + }) + + {conn, sid} + end +end diff --git a/test/trinity/web_auth_test.exs b/test/trinity/web_auth_test.exs new file mode 100644 index 0000000..a50d7e3 --- /dev/null +++ b/test/trinity/web_auth_test.exs @@ -0,0 +1,133 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.WebAuthTest do + @moduledoc """ + Slice 136: the decisions behind AC4, AC6 and AC13, as pure functions. The boot itself is + `test/trinity_web/auth/boot_refusal_node_test.exs`, in child OS processes; this file is the + whole matrix, which is too many boots to spawn. + """ + use ExUnit.Case, async: true + + alias Trinity.{Profile, WebAuth} + + @loopbacks [ + {127, 0, 0, 1}, + {127, 1, 2, 3}, + {0, 0, 0, 0, 0, 0, 0, 1}, + {0, 0, 0, 0, 0, 0xFFFF, 0x7F00, 1} + ] + @wider [ + {0, 0, 0, 0}, + {192, 168, 1, 10}, + {10, 0, 0, 5}, + {0, 0, 0, 0, 0, 0, 0, 0}, + {0, 0, 0, 0, 0, 0xFFFF, 0x0A00, 1} + ] + + describe "loopback" do + test "the loopback addresses, and nothing else" do + for ip <- @loopbacks, do: assert(WebAuth.loopback?(ip), inspect(ip)) + for ip <- @wider, do: refute(WebAuth.loopback?(ip), inspect(ip)) + end + end + + describe "resolution: unset is none on the loopback and a refusal elsewhere, in every profile" do + test "unset on the loopback is none" do + for ip <- @loopbacks, raw <- [nil, ""], do: assert({:ok, :none} = WebAuth.resolve(raw, ip)) + end + + test "unset on a wider bind is refused, naming the address and the variable" do + assert {:error, {:web_auth_unset_on_non_loopback_bind, "0.0.0.0", "TRINITY_WEB_AUTH"}} = + WebAuth.resolve(nil, {0, 0, 0, 0}) + end + + test "a set mode is that mode, as an atom or a string" do + for mode <- [:none, :oidc, :local_token] do + assert {:ok, ^mode} = WebAuth.resolve(mode, {0, 0, 0, 0}) + assert {:ok, ^mode} = WebAuth.resolve(Atom.to_string(mode), {0, 0, 0, 0}) + end + end + + test "an unknown mode is refused rather than read as none" do + assert {:error, {:web_auth_unknown_mode, "oidcc", "TRINITY_WEB_AUTH"}} = + WebAuth.resolve("oidcc", {127, 0, 0, 1}) + end + end + + describe "AC4: the regulated matrix" do + test "a wider bind with none or local_token is refused, by name" do + for ip <- @wider, mode <- [:none, :local_token] do + assert {:error, + {:regulated_requires_user_authentication, ^mode, _addr, "TRINITY_WEB_AUTH"}} = + Profile.check_web_auth(:regulated, ip, mode) + end + end + + test "oidc on any bind, and none or local_token on the loopback, pass" do + for ip <- @wider ++ @loopbacks, + do: assert(:ok = Profile.check_web_auth(:regulated, ip, :oidc)) + + for ip <- @loopbacks, + mode <- [:none, :local_token], + do: assert(:ok = Profile.check_web_auth(:regulated, ip, mode)) + end + + test "the default profile is not refused by this check" do + for ip <- @wider ++ @loopbacks, + mode <- WebAuth.modes(), + do: assert(:ok = Profile.check_web_auth(:default, ip, mode)) + end + end + + describe "AC13: x-forwarded-proto needs a trusted proxy under regulated" do + test "rewrite_on with the variable unset or not true is refused" do + for raw <- [nil, "", "0", "false", "yes"] do + assert {:error, {:regulated_rewrite_on_without_trusted_proxy, "TRINITY_TRUSTED_PROXY"}} = + Profile.check_trusted_proxy(:regulated, [:x_forwarded_proto], raw) + end + end + + test "the variable set, or no rewrite, passes; default is untouched" do + assert :ok = Profile.check_trusted_proxy(:regulated, [:x_forwarded_proto], "true") + assert :ok = Profile.check_trusted_proxy(:regulated, [:x_forwarded_proto], "1") + assert :ok = Profile.check_trusted_proxy(:regulated, nil, nil) + assert :ok = Profile.check_trusted_proxy(:regulated, [], nil) + assert :ok = Profile.check_trusted_proxy(:default, [:x_forwarded_proto], nil) + end + end + + describe "AC6: regulated refuses an origin check that is not a list" do + test "false, true and :conn are refused; a list passes" do + for v <- [false, true, :conn, []], + do: + assert( + {:error, {:regulated_requires_origin_list, ^v}} = + Profile.check_origin(:regulated, v) + ) + + assert :ok = Profile.check_origin(:regulated, ["//localhost"]) + assert :ok = Profile.check_origin(:default, false) + end + end + + describe "what each mode needs" do + test "oidc needs an issuer and a client id, and names what is missing" do + assert {:error, + {:web_auth_oidc_unconfigured, + ["TRINITY_WEB_AUTH_ISSUER", "TRINITY_WEB_AUTH_CLIENT_ID"]}} = + WebAuth.check_oidc([]) + + assert :ok = WebAuth.check_oidc(issuer: "https://idp.example", client_id: "trinity") + end + + test "local_token needs at least 128 bits" do + assert {:error, {:web_auth_local_token_unset, _}} = WebAuth.check_token(nil) + assert {:error, {:web_auth_local_token_too_short, _}} = WebAuth.check_token("short") + + assert :ok = + WebAuth.check_token( + Base.url_encode64(:crypto.strong_rand_bytes(16), padding: false) + ) + end + end +end diff --git a/test/trinity_web/auth/access_receipts_test.exs b/test/trinity_web/auth/access_receipts_test.exs new file mode 100644 index 0000000..1a5e90d --- /dev/null +++ b/test/trinity_web/auth/access_receipts_test.exs @@ -0,0 +1,139 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.Auth.AccessReceiptsTest do + @moduledoc """ + Slice 136 AC9: every approval receipt and every privileged-route receipt carries `sub`, `iss` + and the role used, inside the signed body, and the chain verifies under the signing key. + + The acts are made through the pages by logged-in principals: an approval decided on + `/permissions`, the export with keys used by an administrator and refused to a viewer, a gateway + identity paired. Then the whole access chain is read back: each receipt's principal is checked + in the signed payload (not only in the stored `subject` column), and the export of the chain is + run through `Trinity.Receipts.Verifier`. The approval row itself names the decider. + """ + use TrinityWeb.ConnCase + + @moduletag :capture_log + + import Phoenix.LiveViewTest + import TrinityWeb.WebAuthHelpers + + alias Trinity.{Factory, Permissions, Receipts} + alias Trinity.Gateways.Identities + + @iss "https://issuer.test" + + setup do + web_auth!(:oidc) + scope = Receipts.access_scope() + # The access chain's writer outlives a test, and the sandbox does not keep its rows; a writer + # left from another test would continue a chain whose tail was rolled back. + Receipts.stop_writer(scope) + on_exit(fn -> Receipts.stop_writer(scope) end) + {:ok, scope: scope} + end + + defp as(roles, sub), do: build_conn() |> log_in_web(roles, sub: sub, iss: @iss) |> elem(0) + + defp bodies(scope) do + for r <- Receipts.list(scope), do: {r, JSON.decode!(r.signed_payload)} + end + + test "an approval decided on the page names its decider, on the row and on the signed receipt", + %{scope: scope} do + row = Factory.session!() + {:ok, approval} = Permissions.request_approval(row.id, "write_note", %{"path" => "/a"}) + + {:ok, view, _} = live(as([:approve], "alice"), "/permissions") + view |> element("#approval-#{approval.id} button", "Allow once") |> render_click() + + assert %{status: "allowed", decided_by: "alice (#{@iss})"} = + Permissions.get_approval(approval.id) + + assert [{receipt, body}] = + for({r, b} <- bodies(scope), b["subject"]["phase"] == "approval", do: {r, b}) + + assert receipt.kind == "decision" + assert body["subject"]["approval_id"] == approval.id + + assert body["subject"]["principal"] == %{ + "sub" => "alice", + "iss" => @iss, + "mode" => "oidc", + "role" => "approve" + } + + assert body["decision"]["outcome"] == "once" + assert body["fingerprint"] == approval.fingerprint + end + + test "privileged routes and events are receipted, used and refused, with sub, iss and role", + %{scope: scope} do + # Used: the export with keys, by an administrator. + assert get(as([:administer], "root"), "/settings/export.tar.gz?keys=1").status == 200 + # Refused: the same, by a viewer. + assert get(as([:view], "visitor"), "/settings/export.tar.gz?keys=1").status == 403 + # An event: pairing a gateway identity. + {:ok, identity, :pending} = Identities.admit("console", "u-receipt") + {:ok, view, _} = live(as([:administer], "root"), "/gateways") + view |> element("#identity-#{identity.id} button", "allow") |> render_click() + + found = for {_r, b} <- bodies(scope), do: {b["subject"], b["decision"]} + + assert {%{ + "phase" => "privileged_route", + "path" => "/settings/export.tar.gz", + "keys" => true, + "principal" => %{"sub" => "root", "iss" => @iss, "role" => "administer"} + }, %{"outcome" => "allow"}} = + Enum.find( + found, + &match?( + {%{"principal" => %{"sub" => "root"}, "phase" => "privileged_route"}, _}, + &1 + ) + ) + + assert {%{ + "phase" => "privileged_route", + "keys" => true, + "principal" => %{"sub" => "visitor", "iss" => @iss, "role" => "administer"} + }, %{"outcome" => "deny"}} = + Enum.find(found, &match?({%{"principal" => %{"sub" => "visitor"}}, _}, &1)) + + assert {%{ + "phase" => "privileged", + "event" => "allow", + "view" => "TrinityWeb.GatewaysLive", + "principal" => %{"sub" => "root", "role" => "administer"} + }, %{"outcome" => "allow"}} = + Enum.find(found, &match?({%{"event" => "allow"}, _}, &1)) + end + + test "every receipt on the access chain carries sub, iss and role, and the chain verifies", + %{scope: scope} do + row = Factory.session!() + {:ok, approval} = Permissions.request_approval(row.id, "write_note", %{"path" => "/b"}) + {:ok, view, _} = live(as([:approve], "alice"), "/permissions") + view |> element("#approval-#{approval.id} button", "Deny") |> render_click() + _ = get(as([:administer], "root"), "/settings/export.tar.gz") + _ = get(as([:approve], "alice"), "/settings/export.tar.gz") + + all = bodies(scope) + assert length(all) >= 3 + + # The population is the chain itself, every receipt on it. + for {_r, body} <- all do + principal = body["subject"]["principal"] + + for key <- ["sub", "iss", "role"] do + assert is_binary(principal[key]) and principal[key] != "", + "an access receipt without #{key}: #{inspect(body["subject"])}" + end + end + + {:ok, export} = Receipts.export(scope) + assert {:ok, %{receipts: n}} = Receipts.Verifier.verify(export) + assert n == length(all) + end +end diff --git a/test/trinity_web/auth/boot_refusal_node_test.exs b/test/trinity_web/auth/boot_refusal_node_test.exs new file mode 100644 index 0000000..f6b7c97 --- /dev/null +++ b/test/trinity_web/auth/boot_refusal_node_test.exs @@ -0,0 +1,202 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.Auth.BootRefusalNodeTest do + @moduledoc """ + Slice 136 AC4 and AC13 at the only place they are real: a boot, in a separate OS process. + + The same arrangement as `test/trinity/regulated_boot_node_test.exs` (whose moduledoc says why a + child process, why `MIX_ENV=test`, and what `Trinity.BootIsolation` does): each case is its own + `mix run --no-start` with its own data directory and databases, and asserts on the line the + child printed. + + Every regulated case starts from the configuration that test's case 4 proves boots (production + MCP authorization, a non-Local authority, an allowed model endpoint) and changes only the web + pages' bind, mode, or proxy setting, so a refusal is attributable to the one thing changed. The + refused cases bind `0.0.0.0` in configuration only: they never start the endpoint, which is the + point. The cases that boot bind the loopback. + + The whole matrix of tuples is `test/trinity/web_auth_test.exs`, as pure calls; these are the + tuples AC4 and AC13 name, booted. + """ + use ExUnit.Case, async: false + + @moduletag timeout: 300_000 + + @isolate """ + Trinity.BootIsolation.isolate!(System.fetch_env!("TRINITY_BOOT_TAG")) + """ + + @fixture """ + defmodule WebBootFixture do + @behaviour Trinity.Authority + def stage(staged, _ctx), do: {:ok, staged} + def decide(_staged, decision, _ctx), do: {:ok, decision, "fixture"} + def execute(_staged, _decision, _ctx), do: {:error, :not_used} + def receipt(_kind, _attrs), do: {:error, :not_used} + end + Application.put_env(:trinity, :mcp_auth, profile: :production) + llm = Application.get_env(:trinity, :llm, []) + Application.put_env(:trinity, :llm, Keyword.put(llm, :models, [ + %{id: "allowed", provider: :req_llm, model: "openai:x", + base_url: "https://models.internal/v1", api_key_env: "NONE", + caps: [:stream], price: %{input: 0.0, output: 0.0}} + ])) + """ + + # The tail: start, then print the boot receipt's `web` subject so the case can assert the mode + # it booted in was recorded. The receipt is written by a task after the receipts supervisor, so + # it is polled for rather than assumed present. + @report """ + case Application.ensure_all_started(:trinity) do + {:ok, _} -> + receipt = + Enum.find_value(1..50, fn _ -> + case Trinity.Receipts.boot_receipt() do + nil -> Process.sleep(100); nil + r -> r + end + end) + + IO.puts("BOOT_OK " <> Jason.encode!((receipt && receipt.subject["web"]) || %{})) + System.halt(0) + + {:error, reason} -> + IO.puts("BOOT_REFUSED " <> inspect(reason, limit: :infinity, printable_limit: :infinity)) + System.halt(3) + end + """ + + defp case_setup(name) do + tag = Trinity.BootIsolation.tag(name) + on_exit(fn -> Trinity.BootIsolation.drop!(tag) end) + dir = Path.join(System.tmp_dir!(), "webboot-#{name}-#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + on_exit(fn -> File.rm_rf(dir) end) + {dir, tag} + end + + # `bind` and `mode` are put into the child's configuration ahead of the start; `env` is the + # child's environment beyond the isolation and the regulated baseline. + defp boot(name, bind, mode, extra_script \\ "", env \\ []) do + {root, tag} = case_setup(name) + + script = """ + #{@isolate} + #{@fixture} + ep = Application.get_env(:trinity, TrinityWeb.Endpoint, []) + Application.put_env(:trinity, TrinityWeb.Endpoint, + Keyword.put(ep, :http, ip: #{inspect(bind)}, port: 0)) + wa = Application.get_env(:trinity, :web_auth, []) + Application.put_env(:trinity, :web_auth, + Keyword.merge(wa, mode: #{inspect(mode)}, token: String.duplicate("t", 32))) + #{extra_script} + #{@report} + """ + + base = [ + {"MIX_ENV", "test"}, + {"XDG_DATA_HOME", root}, + {"TRINITY_BOOT_TAG", tag}, + {"TRINITY_PROFILE", "regulated"}, + {"TRINITY_AUTHORITY", "WebBootFixture"}, + {"TRINITY_REGULATED_LLM_ENDPOINTS", "https://models.internal"}, + {"TRINITY_MCP_AUTH_PROFILE", nil}, + {"TRINITY_WEB_AUTH", nil}, + {"TRINITY_TRUSTED_PROXY", nil} + ] + + System.cmd("mix", ["run", "--no-start", "-e", script], + env: Enum.reduce(env, base, fn {k, v}, acc -> List.keystore(acc, k, 0, {k, v}) end), + stderr_to_stdout: true + ) + end + + defp line(out, prefix), + do: out |> String.split("\n") |> Enum.find("", &String.starts_with?(&1, prefix)) + + describe "AC4: regulated, a bind other machines can reach, no user authentication" do + test "none on 0.0.0.0 refuses to boot, naming the mode, the address and the variable" do + {out, status} = boot("reg_none_wide", {0, 0, 0, 0}, :none) + assert status == 3, "a regulated node served pages with no login on 0.0.0.0:\n#{out}" + reason = line(out, "BOOT_REFUSED") + assert reason =~ "regulated_requires_user_authentication", reason + assert reason =~ ":none" + assert reason =~ "0.0.0.0" + assert reason =~ "TRINITY_WEB_AUTH" + end + + test "local_token on 0.0.0.0 refuses to boot, with a valid token configured" do + {out, status} = boot("reg_token_wide", {0, 0, 0, 0}, :local_token) + + assert status == 3, + "a regulated node served pages behind a shared token on 0.0.0.0:\n#{out}" + + reason = line(out, "BOOT_REFUSED") + assert reason =~ "regulated_requires_user_authentication", reason + assert reason =~ ":local_token" + end + + test "unset on 0.0.0.0 refuses to boot (the rule of every profile)" do + {out, status} = boot("reg_unset_wide", {0, 0, 0, 0}, nil) + assert status == 3, out + assert line(out, "BOOT_REFUSED") =~ "web_auth_unset_on_non_loopback_bind" + end + + test "none on the loopback boots, and the boot receipt records the mode and the bind" do + {out, status} = boot("reg_none_loopback", {127, 0, 0, 1}, :none) + + assert status == 0, + """ + the loopback case refused. If this fails while the refusals pass, the profile refuses + everything and proves nothing. + + #{String.slice(out, -4000, 4000)} + """ + + "BOOT_OK " <> json = line(out, "BOOT_OK") + assert %{"mode" => "none", "bind" => "127.0.0.1", "loopback" => true} = Jason.decode!(json) + end + end + + describe "AC13: regulated, x-forwarded-proto believed, no trusted proxy declared" do + @rewrite """ + ep = Application.get_env(:trinity, TrinityWeb.Endpoint, []) + Application.put_env(:trinity, TrinityWeb.Endpoint, + Keyword.put(ep, :force_ssl, rewrite_on: [:x_forwarded_proto])) + """ + + test "TRINITY_TRUSTED_PROXY unset refuses to boot; set, the same node boots" do + {out, status} = boot("reg_rewrite_unset", {127, 0, 0, 1}, :none, @rewrite) + + assert status == 3, + "a regulated node believed x-forwarded-proto with no proxy declared:\n#{out}" + + reason = line(out, "BOOT_REFUSED") + assert reason =~ "regulated_rewrite_on_without_trusted_proxy", reason + assert reason =~ "TRINITY_TRUSTED_PROXY" + + {out2, status2} = + boot("reg_rewrite_set", {127, 0, 0, 1}, :none, @rewrite, [ + {"TRINITY_TRUSTED_PROXY", "true"} + ]) + + assert status2 == 0, + """ + the operator declared the proxy and the node still refused; the check refuses every + rewrite rather than an undeclared one. + + #{String.slice(out2, -4000, 4000)} + """ + end + end + + describe "every profile: a wider bind with the mode unset" do + test "the default profile refuses too, naming the variable" do + {out, status} = + boot("default_unset_wide", {0, 0, 0, 0}, nil, "", [{"TRINITY_PROFILE", "default"}]) + + assert status == 3, "a default node served pages with no login on 0.0.0.0:\n#{out}" + assert line(out, "BOOT_REFUSED") =~ "web_auth_unset_on_non_loopback_bind" + end + end +end diff --git a/test/trinity_web/auth/host_and_origin_test.exs b/test/trinity_web/auth/host_and_origin_test.exs new file mode 100644 index 0000000..1bb0a24 --- /dev/null +++ b/test/trinity_web/auth/host_and_origin_test.exs @@ -0,0 +1,127 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.Auth.HostAndOriginTest do + @moduledoc """ + Slice 136 AC5 and AC6, over a real socket to the suite's running endpoint. + + AC5: a request whose `Host` is not this machine's own is `403` before routing, on a page, on a + path no route matches and on a static file alike; the loopback names and the configured host + pass. AC6: the websocket origin list is explicit in every environment, and a websocket upgrade + from a foreign page is refused. + + The suite binds `127.0.0.1` in `:none`, which is the desktop's shape: no login, the Host + allow-list and the origin list are all there is. + """ + use TrinityWeb.ConnCase + + @moduletag :capture_log + + alias TrinityWeb.RawHTTP + + describe "AC5: the Host allow-list" do + test "a foreign Host is 403 before routing, wherever it points" do + for path <- ["/", "/permissions", "/no-such-route", "/assets/css/app.css", "/oauth/token"] do + {status, _headers, body} = RawHTTP.request("GET", path, [{"host", "evil.example"}]) + + assert status == 403, + "GET #{path} with Host: evil.example answered #{status}; a rebinding page reads it" + + assert body =~ "host not allowed" + end + + # The same with a port, and a POST: the port is not part of the decision and the method is + # not either. + assert {403, _, _} = + RawHTTP.request("POST", "/auth/token", [ + {"host", "evil.example:#{RawHTTP.port()}"}, + {"content-length", "0"} + ]) + end + + test "the loopback names and the configured host pass" do + port = RawHTTP.port() + + for host <- [ + "localhost", + "127.0.0.1", + "[::1]", + "LOCALHOST", + "localhost.", + "www.example.com" + ] do + {status, _headers, _body} = + RawHTTP.request("GET", "/permissions", [{"host", "#{host}:#{port}"}]) + + assert status == 200, "Host: #{host} was refused (#{status})" + end + end + + test "the configured host is the endpoint's own url host" do + assert TrinityWeb.Endpoint.config(:url)[:host] in TrinityWeb.Plugs.HostAllowList.allowed_hosts() + refute TrinityWeb.Plugs.HostAllowList.allowed?("evil.example") + refute TrinityWeb.Plugs.HostAllowList.allowed?("localhost.evil.example") + refute TrinityWeb.Plugs.HostAllowList.allowed?(nil) + end + end + + describe "AC6: the websocket origin" do + defp upgrade(origin) do + headers = + [ + {"host", "127.0.0.1:#{RawHTTP.port()}"}, + {"upgrade", "websocket"}, + {"connection", "Upgrade"}, + {"sec-websocket-key", Base.encode64(:crypto.strong_rand_bytes(16))}, + {"sec-websocket-version", "13"} + ] ++ if(origin, do: [{"origin", origin}], else: []) + + {status, _headers, _body} = + RawHTTP.request("GET", "/live/websocket?vsn=2.0.0", headers) + + status + end + + test "a foreign Origin is refused, the machine's own is upgraded" do + assert upgrade("http://evil.example") == 403 + assert upgrade("http://evil.example:#{RawHTTP.port()}") == 403 + assert upgrade("http://localhost.evil.example") == 403 + + assert upgrade("http://127.0.0.1:#{RawHTTP.port()}") == 101 + assert upgrade("http://localhost:#{RawHTTP.port()}") == 101 + end + + test "check_origin is an explicit list in the running endpoint" do + origins = TrinityWeb.Endpoint.config(:check_origin) + assert is_list(origins) and origins != [] + end + + # The value each environment's configuration resolves to, read from the files themselves: + # config/config.exs with the environment's file, and for :prod also config/runtime.exs, which + # is where the release's list is built. `:conn` and `false` are refused everywhere. + test "check_origin is an explicit list in dev, test and prod" do + for env <- [:dev, :test, :prod] do + base = Config.Reader.read!("config/config.exs", env: env, target: :host) + + runtime = + if env == :prod, + do: Config.Reader.read!("config/runtime.exs", env: :prod, target: :host), + else: [] + + value = + [base, runtime] + |> Enum.flat_map( + &(&1 + |> Keyword.get(:trinity, []) + |> Keyword.get_values(TrinityWeb.Endpoint)) + ) + |> Enum.reduce([], &Keyword.merge(&2, &1)) + |> Keyword.get(:check_origin, :unset) + + assert is_list(value) and value != [], + "#{env}: check_origin is #{inspect(value)}, not an explicit list" + + refute :conn in value + end + end + end +end diff --git a/test/trinity_web/auth/local_token_test.exs b/test/trinity_web/auth/local_token_test.exs new file mode 100644 index 0000000..a93cde7 --- /dev/null +++ b/test/trinity_web/auth/local_token_test.exs @@ -0,0 +1,128 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.Auth.LocalTokenTest do + @moduledoc """ + Slice 136 AC12: the shared token. + + Presented once and exchanged for a session cookie that is `SameSite=Strict` (and `Secure` over + TLS); a second guess inside the rate window is `429`, the right token included; the token never + reaches the log, even at debug, where the request's parameters are logged; it is compared in + constant time; every page carries the banner; receipts name the principal `local_token`. + + Each test sends from its own address, because the window is per address and is shared by the + whole node. + """ + use TrinityWeb.ConnCase + + import ExUnit.CaptureLog + import Phoenix.LiveViewTest + import TrinityWeb.WebAuthHelpers + + @token "tok-" <> Base.url_encode64(:crypto.strong_rand_bytes(24), padding: false) + + setup do + web_auth!(:local_token, token: @token, token_rate_window_ms: 2_000) + :ok + end + + defp from(n), do: %{build_conn() | remote_ip: {10, 136, 0, n}} + + test "the login page asks for the token" do + conn = get(from(1), "/auth/login") + assert html_response(conn, 200) =~ ~s(name="secret") + end + + test "the right token is exchanged for a Strict, HttpOnly session cookie, and opens the pages" do + conn = post(from(2), "/auth/token", %{"secret" => @token}) + assert redirected_to(conn) == "/" + + # The header the browser receives, not the conn's map of options. + [set_cookie] = for h <- get_resp_header(conn, "set-cookie"), h =~ "_trinity_key=", do: h + assert set_cookie =~ "SameSite=Strict" + assert set_cookie =~ "HttpOnly" + refute set_cookie =~ "secure" + + page = conn |> recycle() |> get("/permissions") + assert page.status == 200 + assert page.resp_body =~ "Shared token, no per-user identity" + end + + test "over TLS the cookie is Secure as well" do + conn = post(from(3), "https://www.example.com/auth/token", %{"secret" => @token}) + [set_cookie] = for h <- get_resp_header(conn, "set-cookie"), h =~ "_trinity_key=", do: h + assert set_cookie =~ "secure" + assert set_cookie =~ "SameSite=Strict" + end + + test "a wrong token is 401 and a second guess inside the window is 429, the right one included" do + assert post(from(4), "/auth/token", %{"secret" => "wrong"}).status == 401 + second = post(from(4), "/auth/token", %{"secret" => @token}) + assert second.status == 429 + assert second.resp_cookies["_trinity_key"] == nil + + # Another address is not held by this one's window. + assert redirected_to(post(from(5), "/auth/token", %{"secret" => @token})) == "/" + end + + test "the window passes" do + web_auth!(:local_token, token: @token, token_rate_window_ms: 100) + assert post(from(6), "/auth/token", %{"secret" => "wrong"}).status == 401 + Process.sleep(150) + assert redirected_to(post(from(6), "/auth/token", %{"secret" => @token})) == "/" + end + + test "the token is never logged, even with the request's parameters logged at debug" do + level = Logger.level() + Logger.configure(level: :debug) + on_exit(fn -> Logger.configure(level: level) end) + + log = + capture_log([level: :debug], fn -> + post(from(7), "/auth/token", %{"secret" => @token}) + post(from(8), "/auth/token", %{"secret" => @token <> "x"}) + end) + + # The parameters were logged, filtered; so the absence below is not an absence of logging. + assert log =~ "[FILTERED]" + refute log =~ @token + end + + # A structural check, stated as one: timing a comparison in a test is noise. The configured token + # is read in exactly one place under lib/, and that place compares digests with + # `Plug.Crypto.secure_compare/2`. + test "the token is compared in constant time, in the one place it is read" do + readers = + for path <- Path.wildcard("lib/**/*.ex"), + source = File.read!(path), + source =~ ~r/WebAuth\.config\(\)[^\n]*:token\)/, + do: {path, source} + + assert [{"lib/trinity_web/controllers/auth_controller.ex", source}] = readers + + assert source =~ + "Plug.Crypto.secure_compare(:crypto.hash(:sha256, guess), :crypto.hash(:sha256, token))" + end + + test "a privileged act under the shared token is receipted as local_token, not as a person" do + scope = Trinity.Receipts.access_scope() + Trinity.Receipts.stop_writer(scope) + on_exit(fn -> Trinity.Receipts.stop_writer(scope) end) + + conn = post(from(9), "/auth/token", %{"secret" => @token}) + assert conn |> recycle() |> get("/settings/export.tar.gz") |> Map.fetch!(:status) == 200 + + [receipt] = Trinity.Receipts.list(scope) + principal = JSON.decode!(receipt.signed_payload)["subject"]["principal"] + assert %{"sub" => "local_token", "iss" => "local_token", "mode" => "local_token"} = principal + end + + test "a LiveView under the shared token shows the banner" do + conn = post(from(10), "/auth/token", %{"secret" => @token}) + {:ok, _view, html} = conn |> recycle() |> live("/permissions") + assert html =~ "shared-token-banner" + end + + test "no session, no page, and the login form is the way in" do + assert redirected_to(get(from(11), "/permissions")) == "/auth/login" + end +end diff --git a/test/trinity_web/auth/oidc_login_test.exs b/test/trinity_web/auth/oidc_login_test.exs new file mode 100644 index 0000000..1c6b626 --- /dev/null +++ b/test/trinity_web/auth/oidc_login_test.exs @@ -0,0 +1,230 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.Auth.OIDCLoginTest do + @moduledoc """ + Slice 136 AC7, and the login half of AC8: the OpenID Connect login against an issuer in this VM + (`TrinityWeb.FakeOIDCIssuer`, a loopback port, a key generated here), through the whole endpoint. + + What is checked: the authorization request always carries PKCE S256, `state` and `nonce`; a + login refuses an issuer that offers no S256; a callback with no stored authorization session is + refused (CVE-2026-66884's case); a wrong `state`, a wrong `iss` parameter, a wrong nonce in the ID + token and a wrong verifier are each refused; a good login yields a session whose principal has + the issuer, the subject and the roles of the ID token; a token whose roles cannot be read is a + refused login with a diagnostic, never a session with no roles. And the dependency floors. + """ + use TrinityWeb.ConnCase + + @moduletag :capture_log + + import TrinityWeb.WebAuthHelpers + + alias TrinityWeb.FakeOIDCIssuer + + @client_id "trinity-test" + + defp setup_issuer(opts \\ []) do + issuer = FakeOIDCIssuer.start(opts) + + web_auth!(:oidc, + issuer: issuer.url, + client_id: @client_id, + client_secret: "test-secret", + redirect_uri: "http://www.example.com/auth/callback", + role_claim: nil + ) + + start_supervised!(TrinityWeb.Auth.OIDC.worker_spec(Trinity.WebAuth.config())) + issuer + end + + # The redirect to the issuer, parsed. + defp begin_login do + conn = get(build_conn(), "/auth/login") + assert conn.status == 302, "login did not redirect: #{conn.status} #{conn.resp_body}" + [location] = get_resp_header(conn, "location") + %URI{query: query} = URI.parse(location) + {conn, URI.decode_query(query), location} + end + + defp callback(conn, params), do: conn |> recycle() |> get("/auth/callback", params) + + describe "AC7: the authorization request" do + test "always carries PKCE S256, state and nonce, to the configured issuer" do + issuer = setup_issuer() + {_conn, q, location} = begin_login() + + assert String.starts_with?(location, issuer.url <> "/authorize?") + assert q["response_type"] == "code" + assert q["client_id"] == @client_id + assert q["code_challenge_method"] == "S256" + assert byte_size(q["code_challenge"]) == 43 + assert is_binary(q["state"]) and byte_size(q["state"]) > 16 + assert is_binary(q["nonce"]) and byte_size(q["nonce"]) > 16 + assert q["redirect_uri"] == "http://www.example.com/auth/callback" + assert "openid" in String.split(q["scope"], " ") + end + + test "an issuer that offers only plain PKCE gets no login started" do + setup_issuer(code_challenge_methods: ["plain"]) + conn = get(build_conn(), "/auth/login") + assert conn.status == 503 + assert get_resp_header(conn, "location") == [] + assert conn.resp_body =~ "PKCE" + end + end + + describe "an issuer that cannot be reached" do + test "the worker keeps retrying and the login says so, rather than the node going down" do + web_auth!(:oidc, issuer: "http://127.0.0.1:1", client_id: @client_id) + pid = start_supervised!(TrinityWeb.Auth.OIDC.worker_spec(Trinity.WebAuth.config())) + Process.sleep(300) + assert Process.alive?(pid) + + conn = get(build_conn(), "/auth/login") + assert conn.status == 503 + assert conn.resp_body =~ "cannot be reached" + end + end + + describe "AC7: the callback" do + test "a good login yields a session with the token's issuer, subject and roles" do + issuer = setup_issuer() + {conn, q, _} = begin_login() + + code = + FakeOIDCIssuer.issue_code(issuer, q, %{"sub" => "alice", "roles" => ["trinity:approve"]}) + + conn = callback(conn, %{"code" => code, "state" => q["state"], "iss" => issuer.url}) + + assert redirected_to(conn) == "/" + sid = get_session(conn, "web_sid") + assert {:ok, principal} = TrinityWeb.Auth.Sessions.fetch(sid) + assert principal.sub == "alice" + assert principal.iss == issuer.url + assert principal.roles == [:approve] + assert principal.mode == :oidc + # Every socket of the session registers under this id, so a revocation reaches them all. + assert get_session(conn, "live_socket_id") == TrinityWeb.Auth.Sessions.socket_id(sid) + + # And the session is a key to the pages. + assert conn |> recycle() |> get("/permissions") |> Map.fetch!(:status) == 200 + end + + test "a callback replayed with no stored authorization session is refused" do + issuer = setup_issuer() + {_conn, q, _} = begin_login() + code = FakeOIDCIssuer.issue_code(issuer, q, %{"roles" => ["view"]}) + + # A fresh browser: no cookie, so no state, nonce or verifier stored for this request. + conn = + get(build_conn(), "/auth/callback", %{ + "code" => code, + "state" => q["state"], + "iss" => issuer.url + }) + + # Refused before the code is redeemed: the issuer's token endpoint never hears of it. + assert FakeOIDCIssuer.token_calls(issuer) == 0 + assert conn.status == 401 + assert conn.resp_body =~ "missing_authorize_session" + assert get_session(conn, "web_sid") == nil + end + + test "a wrong state is refused" do + issuer = setup_issuer() + {conn, q, _} = begin_login() + code = FakeOIDCIssuer.issue_code(issuer, q, %{"roles" => ["view"]}) + conn = callback(conn, %{"code" => code, "state" => "not-the-state", "iss" => issuer.url}) + assert conn.status == 401 + assert conn.resp_body =~ "state_not_verified" + assert get_session(conn, "web_sid") == nil + end + + test "an iss parameter naming another issuer is refused (RFC 9207)" do + issuer = setup_issuer() + {conn, q, _} = begin_login() + code = FakeOIDCIssuer.issue_code(issuer, q, %{"roles" => ["view"]}) + + conn = + callback(conn, %{"code" => code, "state" => q["state"], "iss" => "https://evil.example"}) + + assert conn.status == 401 + assert conn.resp_body =~ "invalid_issuer" + end + + test "an ID token with another nonce is refused" do + issuer = setup_issuer() + {conn, q, _} = begin_login() + code = FakeOIDCIssuer.issue_code(issuer, q, %{"roles" => ["view"], "nonce" => "replayed"}) + conn = callback(conn, %{"code" => code, "state" => q["state"], "iss" => issuer.url}) + assert conn.status == 401 + assert get_session(conn, "web_sid") == nil + end + + test "a code issued for another verifier is refused at the token endpoint" do + issuer = setup_issuer() + {conn, q, _} = begin_login() + + wrong = + Map.put( + q, + "code_challenge", + Base.url_encode64(:crypto.hash(:sha256, "other"), padding: false) + ) + + code = FakeOIDCIssuer.issue_code(issuer, wrong, %{"roles" => ["view"]}) + conn = callback(conn, %{"code" => code, "state" => q["state"], "iss" => issuer.url}) + assert conn.status == 401 + assert get_session(conn, "web_sid") == nil + end + end + + describe "AC8 and AC10's automatic half: roles from the token" do + test "a token with no Trinity role is a refused login that says so, not an empty session" do + issuer = setup_issuer() + {conn, q, _} = begin_login() + code = FakeOIDCIssuer.issue_code(issuer, q, %{"roles" => ["Sales"], "groups" => ["staff"]}) + conn = callback(conn, %{"code" => code, "state" => q["state"], "iss" => issuer.url}) + + assert conn.status == 403 + assert conn.resp_body =~ "No Trinity role" + assert get_session(conn, "web_sid") == nil + end + + test "an Entra group-overage token is a refused login that names the overage" do + issuer = setup_issuer() + {conn, q, _} = begin_login() + + overage = %{ + "_claim_names" => %{"groups" => "src1"}, + "_claim_sources" => %{ + "src1" => %{"endpoint" => "https://graph.microsoft.com/v1.0/users/x/getMemberObjects"} + } + } + + code = FakeOIDCIssuer.issue_code(issuer, q, overage) + conn = callback(conn, %{"code" => code, "state" => q["state"], "iss" => issuer.url}) + + assert conn.status == 403 + assert conn.resp_body =~ "groups claim out of the token" + assert conn.resp_body =~ "200 groups" + assert get_session(conn, "web_sid") == nil + end + end + + describe "AC7: the dependency floors" do + test "oidcc >= 3.9.0 and oidcc_plug >= 0.5.1, locked and loaded" do + lock = Mix.Dep.Lock.read() + {:hex, :oidcc, locked_oidcc, _, _, _, _, _} = lock[:oidcc] + {:hex, :oidcc_plug, locked_plug, _, _, _, _, _} = lock[:oidcc_plug] + + assert Version.compare(locked_oidcc, "3.9.0") in [:gt, :eq], "oidcc #{locked_oidcc}" + assert Version.compare(locked_plug, "0.5.1") in [:gt, :eq], "oidcc_plug #{locked_plug}" + + for {app, floor} <- [oidcc: "3.9.0", oidcc_plug: "0.5.1"] do + loaded = app |> Application.spec(:vsn) |> List.to_string() + assert Version.compare(loaded, floor) in [:gt, :eq], "#{app} #{loaded} is loaded" + end + end + end +end diff --git a/test/trinity_web/auth/policy_census_test.exs b/test/trinity_web/auth/policy_census_test.exs new file mode 100644 index 0000000..a7a28fd --- /dev/null +++ b/test/trinity_web/auth/policy_census_test.exs @@ -0,0 +1,102 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.Auth.PolicyCensusTest do + @moduledoc """ + Slice 136: every LiveView event in the tree is classified by `TrinityWeb.Auth.Policy`, and the + role claims of the four identity provider shapes map as documented (`TrinityWeb.Auth.Claims`). + + The population of events is read from the source: every `handle_event("name"` clause under + `lib/trinity_web/live/`, single-line or multi-line heads alike, attributed to the module the file + defines. An event the table does not name would still need `administer` (the default), so a + miss here is not a hole; it is an event nobody decided about, which this test makes somebody do. + """ + use ExUnit.Case, async: true + + alias TrinityWeb.Auth.{Claims, Policy} + + defp events_in_tree do + for path <- Path.wildcard("lib/trinity_web/live/**/*.ex"), + source = File.read!(path), + [_, module] <- [Regex.run(~r/defmodule\s+([\w.]+)\s+do/, source)], + [_, event] <- Regex.scan(~r/def handle_event\(\s*"([a-z_]+)"/, source), + uniq: true, + do: {Module.concat([module]), event} + end + + test "every handle_event in the tree is named by the policy, once" do + events = events_in_tree() + assert length(events) > 80, "the census read #{length(events)} events; is the regex right?" + + named = Policy.named() + named_keys = Enum.map(named, fn {view, event, _role} -> {view, event} end) + + missing = Enum.reject(events, &(&1 in named_keys)) + assert missing == [], "events no role was decided for: #{inspect(missing)}" + + stale = Enum.reject(named_keys, &(&1 in events)) + assert stale == [], "the policy names events that do not exist: #{inspect(stale)}" + + dupes = named_keys -- Enum.uniq(named_keys) + assert dupes == [], "named under two roles: #{inspect(dupes)}" + end + + test "the events AC8 names need administer, approvals need approve, an unnamed one administer" do + assert Policy.event_role(TrinityWeb.MCPLive, "create") == :administer + assert Policy.event_role(TrinityWeb.GatewaysLive, "allow") == :administer + assert Policy.event_role(TrinityWeb.SettingsLive, "save_key") == :administer + assert Policy.event_role(TrinityWeb.PermissionsLive, "approval_decide") == :approve + assert Policy.event_role(TrinityWeb.SessionLive.Show, "approval_decide") == :approve + assert Policy.event_role(TrinityWeb.SessionLive.Show, "send") == :view + assert Policy.event_role(TrinityWeb.MCPLive, "something_new") == :administer + assert Policy.event_role(Phoenix.LiveDashboard.PageLive, "anything") == :administer + end + + describe "role claims" do + test "Keycloak: realm roles and this client's roles" do + claims = %{ + "realm_access" => %{"roles" => ["offline_access", "trinity:view"]}, + "resource_access" => %{ + "trinity" => %{"roles" => ["administer"]}, + "other-client" => %{"roles" => ["approve"]} + } + } + + assert {:ok, [:view, :administer]} = Claims.roles(claims, "trinity") + end + + test "Entra: app roles in roles" do + assert {:ok, [:approve]} = Claims.roles(%{"roles" => ["Trinity.Approve"]}, "app-id") + end + + test "Okta: groups" do + assert {:ok, [:view, :approve]} = + Claims.roles(%{"groups" => ["Everyone", "trinity-approve", "trinity-view"]}, "c") + end + + test "generic roles, as a list or a space-separated string" do + assert {:ok, [:administer]} = Claims.roles(%{"roles" => "administer"}, nil) + assert {:ok, [:view, :approve]} = Claims.roles(%{"roles" => "approve view"}, nil) + end + + test "a named claim is the only one read" do + claims = %{"roles" => ["administer"], "trinity_roles" => ["view"]} + assert {:ok, [:view]} = Claims.roles(claims, nil, "trinity_roles") + assert {:ok, [:approve]} = Claims.roles(%{"a" => %{"b" => ["approve"]}}, nil, "a.b") + end + + test "no Trinity role is a refusal naming what was read, never an empty list" do + assert {:error, {:no_trinity_role, read}} = Claims.roles(%{"roles" => ["Sales"]}, "c") + assert "roles" in read and "groups" in read + assert {:error, {:no_trinity_role, _}} = Claims.roles(%{}, nil) + end + + test "an Entra overage of a claim that would be read is a refusal naming it" do + overage = %{"_claim_names" => %{"groups" => "src1"}, "roles" => ["view"]} + assert {:error, {:claim_overage, "groups"}} = Claims.roles(overage, "c") + assert Claims.describe({:claim_overage, "groups"}) =~ "200 groups" + + # Named elsewhere, the overaged claim is not read and so does not matter. + assert {:ok, [:view]} = Claims.roles(overage, "c", "roles") + end + end +end diff --git a/test/trinity_web/auth/roles_test.exs b/test/trinity_web/auth/roles_test.exs new file mode 100644 index 0000000..2b33afe --- /dev/null +++ b/test/trinity_web/auth/roles_test.exs @@ -0,0 +1,136 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.Auth.RolesTest do + @moduledoc """ + Slice 136 AC8: what each role may do, through the whole endpoint and the LiveView hooks. + + Without `administer`: `403` on `/oban`, `/dev/dashboard` and the export with and without + `keys=1`; the `/mcp` "create" event and the `/gateways` "allow" event are halted before their + handlers run (nothing is created, nobody is paired). `approve` without `administer` cannot + export. `administer` without `approve` cannot decide an approval (separation of duties). And the + positive halves, so a role check that refuses everyone fails here too. + + An event refused by the hook is told apart from one its handler refused by the flash: the hook + says "Not permitted", a handler that ran and failed says something else. + """ + use TrinityWeb.ConnCase + + @moduletag :capture_log + + import Phoenix.LiveViewTest + import TrinityWeb.WebAuthHelpers + + alias Trinity.Gateways.Identities + alias Trinity.MCP.Servers + + setup do + web_auth!(:oidc) + :ok + end + + defp as(roles), do: build_conn() |> log_in_web(roles) |> elem(0) + + defp status(conn, path), do: conn |> get(path) |> Map.fetch!(:status) + + describe "privileged routes" do + test "view and approve get 403 on the export, keys or not, /oban and /dev/dashboard" do + for roles <- [[:view], [:approve], [:view, :approve]] do + conn = as(roles) + + for path <- [ + "/settings/export.tar.gz", + "/settings/export.tar.gz?keys=1", + "/oban", + "/dev/dashboard" + ] do + assert status(conn, path) == 403, "#{inspect(roles)} reached #{path}" + end + end + end + + # `/oban`'s page cannot mount in the suite (Oban.Met is not running; see tasks_live_test), so + # the administer half reads its asset route, which runs the same two pipelines. + test "administer reaches the dashboards and the export" do + conn = as([:administer]) + assert status(conn, "/dev/dashboard/home") == 200 + assert status(conn, "/oban/css-0") == 200 + assert status(as([:view]), "/oban/css-0") == 403 + + conn = get(conn, "/settings/export.tar.gz") + assert conn.status == 200 + assert [disposition] = get_resp_header(conn, "content-disposition") + assert disposition =~ "trinity-" + end + + test "every role reaches the ordinary pages" do + for roles <- [[:view], [:approve], [:administer]] do + assert status(as(roles), "/mcp") == 200 + assert status(as(roles), "/gateways") == 200 + end + end + end + + describe "privileged events" do + test "view cannot add an MCP server; administer can" do + {:ok, view, _} = live(as([:view]), "/mcp") + view |> form("#server-form", %{"server" => %{"transport" => "http"}}) |> render_change() + + html = + view + |> form("#server-form", %{ + "server" => %{ + "name" => "viewer", + "transport" => "http", + "url" => "http://127.0.0.1:1/mcp" + } + }) + |> render_submit() + + assert html =~ "Not permitted" + assert Servers.list() == [] + + on_exit(fn -> Trinity.MCP.Supervisor.stop_client("admin") end) + {:ok, view, _} = live(as([:administer]), "/mcp") + view |> form("#server-form", %{"server" => %{"transport" => "http"}}) |> render_change() + + view + |> form("#server-form", %{ + "server" => %{"name" => "admin", "transport" => "http", "url" => "http://127.0.0.1:1/mcp"} + }) + |> render_submit() + + assert [%{name: "admin"}] = Servers.list() + end + + test "view and approve cannot pair a gateway identity; administer can" do + {:ok, identity, :pending} = Identities.admit("console", "u-role") + + for roles <- [[:view], [:approve]] do + {:ok, view, _} = live(as(roles), "/gateways") + html = view |> element("#identity-#{identity.id} button", "allow") |> render_click() + assert html =~ "Not permitted" + assert Identities.get("console", "u-role").state == "pending" + end + + {:ok, view, _} = live(as([:administer]), "/gateways") + view |> element("#identity-#{identity.id} button", "allow") |> render_click() + assert Identities.get("console", "u-role").state == "paired" + end + + test "deciding an approval needs approve: administer alone is refused, approve reaches the handler" do + args = %{"id" => "no-such-approval", "decision" => "once"} + + {:ok, view, _} = live(as([:administer]), "/permissions") + assert render_hook(view, "approval_decide", args) =~ "Not permitted" + + {:ok, view, _} = live(as([:view]), "/permissions") + assert render_hook(view, "approval_decide", args) =~ "Not permitted" + + # The handler ran and answered for itself: the request does not exist. + {:ok, view, _} = live(as([:approve]), "/permissions") + html = render_hook(view, "approval_decide", args) + refute html =~ "Not permitted" + assert html =~ "Not decided" + end + end +end diff --git a/test/trinity_web/auth/route_census_test.exs b/test/trinity_web/auth/route_census_test.exs new file mode 100644 index 0000000..0848791 --- /dev/null +++ b/test/trinity_web/auth/route_census_test.exs @@ -0,0 +1,273 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.Auth.RouteCensusTest do + @moduledoc """ + Slice 136 AC1 and AC2. + + **AC1, the census.** The population is the router's own: `TrinityWeb.Router.__routes__/0`, each + route resolved through `Phoenix.Router.route_info/4` for its pipelines and its `live_session`. + Every route must either run through a pipeline that carries `TrinityWeb.Auth.RequireRole` + (`:viewer`, `:administer`; read from the router's source rather than assumed) and, if it is a LiveView, mount through + `{TrinityWeb.Auth, role}`; or be one of a named list of exemptions that is itself asserted to be + exactly the login and the MCP authorization protocol endpoints. The privileged routes the AC + names must also run through `:administer`. The checker is a function over route data, so a + planted unguarded route is a permanent test of it. + + **AC2, the sweep.** Every censused route that is not exempt, with no session, under `:oidc` and + under `:local_token`: `GET` and `POST` are each a redirect to the login or a `401`, never a + `200`, through the whole endpoint (`Phoenix.ConnTest` dispatches through it). + """ + use TrinityWeb.ConnCase + + import Phoenix.LiveViewTest + import TrinityWeb.WebAuthHelpers + + @router TrinityWeb.Router + + # The only routes a request without a principal may reach, each with its reason. + @exempt %{ + # The login itself. + {"GET", "/auth/login"} => :login, + {"GET", "/auth/callback"} => :login, + {"POST", "/auth/token"} => :login, + {"POST", "/auth/logout"} => :login, + {"GET", "/auth/forbidden"} => :login, + # Slice 062's protocol endpoints, read by OAuth clients and servers without a browser and + # authenticated by their own protocol. + {"GET", "/.well-known/oauth-protected-resource"} => :oauth_protocol, + {"GET", "/.well-known/oauth-protected-resource/*path"} => :oauth_protocol, + {"GET", "/.well-known/oauth-authorization-server"} => :oauth_protocol, + {"GET", "/.well-known/jwks.json"} => :oauth_protocol, + {"POST", "/oauth/token"} => :oauth_protocol, + {"POST", "/oauth/register"} => :oauth_protocol + } + + # The routes AC1 names, each with the role it must need. + @named [ + {"GET", "/mcp", :view}, + {"GET", "/gateways", :view}, + {"GET", "/settings/export.tar.gz", :administer}, + {"GET", "/oban", :administer}, + {"POST", "/oauth/consent", :administer}, + {"GET", "/oauth/authorize", :administer}, + {"GET", "/oauth/callback", :administer}, + {"GET", "/dev/dashboard", :administer} + ] + + ## The census, as data + + defp census do + for r <- @router.__routes__() do + verb = r.verb |> Atom.to_string() |> String.upcase() + info = Phoenix.Router.route_info(@router, verb, sample_path(r.path), "localhost") + + on_mount = + case info[:phoenix_live_view] do + {_view, _action, _opts, live_session} -> + Enum.map(live_session.extra[:on_mount] || [], & &1.id) + + _ -> + nil + end + + %{verb: verb, path: r.path, pipe_through: info[:pipe_through], on_mount: on_mount} + end + end + + # A UUID for every parameter: the pages that read an id cast it, and a value that cannot be cast + # raises before any answer, which would hide what the page would have answered. + defp sample_path(path), + do: String.replace(path, ~r/[:*][a-z_0-9]+/, "00000000-0000-4000-8000-000000000000") + + # The pipelines of the router's source and the plugs each one names, read as code. + defp pipelines do + {:ok, ast} = "lib/trinity_web/router.ex" |> File.read!() |> Code.string_to_quoted() + + {_, acc} = + Macro.prewalk(ast, %{}, fn + {:pipeline, _, [name, [do: body]]} = node, acc -> + plugs = + body + |> block() + |> Enum.flat_map(fn + {:plug, _, [{:__aliases__, _, parts} | args]} -> [{Module.concat(parts), args}] + {:plug, _, [atom | args]} when is_atom(atom) -> [{atom, args}] + _ -> [] + end) + + {node, Map.put(acc, name, plugs)} + + node, acc -> + {node, acc} + end) + + acc + end + + defp block({:__block__, _, exprs}), do: exprs + defp block(expr), do: [expr] + + @doc false + def violations(routes, pipelines) do + guarded_pipes = + for {name, plugs} <- pipelines, + {TrinityWeb.Auth.RequireRole, [_role]} <- plugs, + into: MapSet.new(), + do: name + + Enum.flat_map(routes, fn route -> + key = {route.verb, route.path} + + cond do + Map.has_key?(@exempt, key) -> + [] + + not Enum.any?(route.pipe_through || [], &(&1 in guarded_pipes)) -> + [{:no_auth_pipeline, key, route.pipe_through}] + + is_list(route.on_mount) and + not Enum.any?(route.on_mount, &match?({TrinityWeb.Auth, _}, &1)) -> + [{:no_on_mount_guard, key, route.on_mount}] + + true -> + [] + end + end) + end + + describe "AC1: the route census" do + test "the pipelines the routes rely on carry the role plug" do + pipes = pipelines() + assert {TrinityWeb.Auth.RequireRole, [:view]} in pipes[:viewer] + assert {TrinityWeb.Auth.RequireRole, [:administer]} in pipes[:administer] + end + + test "every route and every live_session is guarded, or named as exempt" do + routes = census() + assert length(routes) > 30, "the census read #{length(routes)} routes; is the router empty?" + assert violations(routes, pipelines()) == [] + end + + test "the exemptions are exactly the login and the OAuth protocol endpoints, and all exist" do + present = MapSet.new(census(), &{&1.verb, &1.path}) + missing = @exempt |> Map.keys() |> Enum.reject(&MapSet.member?(present, &1)) + assert missing == [], "an exemption names no route: #{inspect(missing)}" + + # Nothing outside /auth, /.well-known and the two OAuth POSTs is exempt. + for {{_verb, path}, _why} <- @exempt do + assert String.starts_with?(path, [ + "/auth/", + "/.well-known/", + "/oauth/token", + "/oauth/register" + ]) + end + end + + test "the routes the criterion names are present, guarded, and need their role" do + routes = Map.new(census(), &{{&1.verb, &1.path}, &1}) + + for {verb, path, role} <- @named do + route = Map.fetch!(routes, {verb, path}) + assert :viewer in route.pipe_through, "#{verb} #{path} is not in :viewer" + if role == :administer, do: assert(:administer in route.pipe_through, "#{path}") + + if is_list(route.on_mount), + do: + assert( + {TrinityWeb.Auth, role} in route.on_mount, + "#{path}: #{inspect(route.on_mount)}" + ) + end + + # Every /oban and /dev route, not only their roots. + for {{_v, path}, route} <- routes, String.starts_with?(path, ["/oban", "/dev/"]) do + assert :administer in route.pipe_through, path + end + end + + test "a planted route without the guard is found (the checker's own red)" do + pipes = pipelines() + + planted = [ + %{verb: "GET", path: "/unguarded", pipe_through: [:api], on_mount: nil}, + %{ + verb: "GET", + path: "/live-unguarded", + pipe_through: [:browser, :viewer], + on_mount: [{Other, :x}] + }, + # :browser alone is the login pages' pipeline and carries no role plug. + %{verb: "GET", path: "/browser-only", pipe_through: [:browser], on_mount: nil}, + %{verb: "POST", path: "/oauth/token-ish", pipe_through: [:oauth], on_mount: nil} + ] + + assert [ + {:no_auth_pipeline, {"GET", "/unguarded"}, [:api]}, + {:no_on_mount_guard, {"GET", "/live-unguarded"}, [{Other, :x}]}, + {:no_auth_pipeline, {"GET", "/browser-only"}, [:browser]}, + {:no_auth_pipeline, {"POST", "/oauth/token-ish"}, [:oauth]} + ] = violations(planted, pipes) + end + end + + describe "AC2: no session, no page" do + defp sweep_paths do + census() + |> Enum.reject(&Map.has_key?(@exempt, {&1.verb, &1.path})) + |> Enum.map(&sample_path(&1.path)) + |> Enum.uniq() + end + + for mode <- [:oidc, :local_token] do + test "under #{mode}, GET and POST to every guarded route are a login redirect or 401" do + web_auth!(unquote(mode), token: String.duplicate("t", 32)) + paths = sweep_paths() + assert length(paths) > 25 + + # Every answer is collected before asserting, so a red names every route that leaked + # rather than the first. + wrong = + for path <- paths, verb <- [:get, :post], reduce: [] do + acc -> + conn = dispatch(build_conn(), @endpoint, verb, path, %{}) + + case {conn.status, Plug.Conn.get_resp_header(conn, "location")} do + {302, ["/auth/login"]} -> acc + {401, _} -> acc + {status, location} -> [{verb, path, status, location} | acc] + end + end + + assert Enum.reverse(wrong) == [], + "answered without a session: #{inspect(Enum.reverse(wrong))}" + end + end + + test "a GET that does not ask for HTML is 401, not a redirect" do + web_auth!(:oidc) + conn = build_conn() |> put_req_header("accept", "application/json") |> get("/permissions") + assert conn.status == 401 + end + + test "a LiveView mounted with no session is sent to the login" do + web_auth!(:oidc) + assert {:error, {:redirect, %{to: "/auth/login"}}} = live(build_conn(), "/permissions") + + # And on mount itself, which a navigation reaches without the plug pipeline. + socket = %Phoenix.LiveView.Socket{ + endpoint: @endpoint, + router: @router, + view: TrinityWeb.PermissionsLive + } + + assert {:halt, %{redirected: {:redirect, %{to: "/auth/login"}}}} = + TrinityWeb.Auth.on_mount(:view, %{}, %{}, socket) + end + + test "under none (the loopback) the same routes answer, which is the desktop" do + conn = get(build_conn(), "/permissions") + assert conn.status == 200 + end + end +end diff --git a/test/trinity_web/auth/session_lifecycle_test.exs b/test/trinity_web/auth/session_lifecycle_test.exs new file mode 100644 index 0000000..50b140e --- /dev/null +++ b/test/trinity_web/auth/session_lifecycle_test.exs @@ -0,0 +1,101 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.Auth.SessionLifecycleTest do + @moduledoc """ + Slice 136 AC3, with the timeouts and the logout the scope names. + + A session revoked on the server is halted on the open page's next patch, its next event and its + next mount, and its sockets are told to disconnect. An idle or over-age session is refused + whatever the cookie says. Logging out ends the session on the server, not only in the browser. + """ + use TrinityWeb.ConnCase + + @moduletag :capture_log + + import Phoenix.LiveViewTest + import TrinityWeb.WebAuthHelpers + + alias TrinityWeb.Auth.Sessions + + setup do + web_auth!(:oidc) + :ok + end + + describe "AC3: revocation" do + test "a revoked session is halted on the next live_patch" do + {conn, sid} = log_in_web(build_conn(), [:view]) + {:ok, view, _} = live(conn, "/search") + + # The patch works while the session stands. + assert render_patch(view, "/search?q=one") =~ "Search" + + :ok = Sessions.revoke(sid, broadcast: false) + assert {:error, {:redirect, %{to: "/auth/login"}}} = render_patch(view, "/search?q=two") + end + + test "a revoked session is halted on its next event" do + {conn, sid} = log_in_web(build_conn(), [:view]) + {:ok, view, _} = live(conn, "/search") + :ok = Sessions.revoke(sid, broadcast: false) + + assert {:error, {:redirect, %{to: "/auth/login"}}} = + render_hook(view, "search", %{"q" => "x"}) + end + + test "a revoked session cannot mount or load a page again" do + {conn, sid} = log_in_web(build_conn(), [:view]) + assert {:ok, _view, _} = live(conn, "/permissions") + :ok = Sessions.revoke(sid) + + assert {:error, {:redirect, %{to: "/auth/login"}}} = live(conn, "/permissions") + assert redirected_to(get(conn, "/permissions")) == "/auth/login" + assert {:error, :revoked} = Sessions.fetch(sid) + end + + # The socket's id is the session's (`live_socket_id`, set at login and asserted in + # oidc_login_test.exs); Phoenix closes every transport registered under it on "disconnect". + # `Phoenix.LiveViewTest` has no transport to close, so what is asserted is Trinity's half: the + # broadcast goes out on that topic. + test "revoking broadcasts disconnect to the session's sockets" do + {_conn, sid} = log_in_web(build_conn(), [:view]) + :ok = TrinityWeb.Endpoint.subscribe(Sessions.socket_id(sid)) + :ok = Sessions.revoke(sid) + assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: "web_session:" <> _} + end + end + + describe "timeouts" do + test "an idle session is refused" do + web_auth!(:oidc, idle_timeout_ms: 500) + {conn, sid} = log_in_web(build_conn(), [:view]) + assert get(conn, "/permissions").status == 200 + Process.sleep(700) + assert redirected_to(get(conn, "/permissions")) == "/auth/login" + assert {:error, :unknown} = Sessions.fetch(sid) + end + + test "a session past its absolute lifetime is refused even while in use" do + web_auth!(:oidc, absolute_timeout_ms: 600, idle_timeout_ms: 5_000) + {conn, sid} = log_in_web(build_conn(), [:view]) + + # In use the whole time: never idle, and still refused once the lifetime has passed. + for _ <- 1..3 do + assert get(conn, "/permissions").status == 200 + Process.sleep(150) + end + + Process.sleep(400) + assert {:error, :absolute_timeout} = Sessions.fetch(sid) + end + end + + describe "logout" do + test "ends the session on the server and drops the cookie" do + {conn, sid} = log_in_web(build_conn(), [:view]) + conn = post(conn, "/auth/logout") + assert redirected_to(conn) == "/auth/login" + assert {:error, :revoked} = Sessions.fetch(sid) + end + end +end diff --git a/test/trinity_web/live/approval_live_test.exs b/test/trinity_web/live/approval_live_test.exs index 87002a2..887b85f 100644 --- a/test/trinity_web/live/approval_live_test.exs +++ b/test/trinity_web/live/approval_live_test.exs @@ -61,7 +61,9 @@ defmodule TrinityWeb.ApprovalLiveTest do assert html =~ "wrote 2 bytes" assert html =~ "written written" - assert %Approval{status: "allowed", decision: "once", decided_by: "liveview"} = + # Slice 136: the row names who decided. On the loopback with no login that is the machine's + # owner; under a login it is the principal's subject and issuer. + assert %Approval{status: "allowed", decision: "once", decided_by: "owner (loopback)"} = Permissions.get_approval(aid) refute has_element?(view, "#pending-approvals") diff --git a/test/trinity_web/live/skill_changes_test.exs b/test/trinity_web/live/skill_changes_test.exs index 70a2121..eed3757 100644 --- a/test/trinity_web/live/skill_changes_test.exs +++ b/test/trinity_web/live/skill_changes_test.exs @@ -78,10 +78,16 @@ defmodule TrinityWeb.SkillChangesTest do applied = Staging.get(clean.id) assert applied.status == "applied" and applied.comment == "looks fine" and - applied.decided_by == "ui" + applied.decided_by == "owner (loopback)" assert has_element?(view, "#skill-proposed-skill span[title=account]", "user") - assert has_element?(view, "#recent-changes li", "applied create proposed-skill v1 by ui") + + assert has_element?( + view, + "#recent-changes li", + "applied create proposed-skill v1 by owner (loopback)" + ) + assert Skills.get("proposed-skill").version == 1 end From f0591b49b7a324dfc37412884e41120c392d469e Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Thu, 8 Oct 2026 15:00:49 -0400 Subject: [PATCH 2/4] fix(s136): the control mapping cites what slice 136 adds, and IA-2 leaves Partial Slice 132's mapping, merged to main, requires every census, every regulated refusal and every responsibility-matrix row to be cited. Slice 136 adds two censuses (route, policy), three refusals (web auth, origin, trusted proxy) and five matrix rows, so the merge failed test/control_mapping_test.exs. New rows: AC-3 twice, AC-6(9), SC-8, SC-23. The IA-2 row and the matrix's web-pages row no longer describe the gap 136 closes. Signed-off-by: Ayla Croft --- docs/regulated/control-mapping.md | 7 ++++++- docs/regulated/customer-responsibility-matrix.md | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/regulated/control-mapping.md b/docs/regulated/control-mapping.md index 25186ea..db50e8b 100644 --- a/docs/regulated/control-mapping.md +++ b/docs/regulated/control-mapping.md @@ -65,11 +65,14 @@ register does not claim is never Trinity's. | `AC-3` | Context only tightens a decision: no state, alone or combined, produces a result weaker than the decision without it | `lib/trinity/permissions/policy/` | `test/trinity/permissions/policy/state_test.exs` "no decision and no set of states produces a result weaker than the decision alone" | Trinity's | none | none | | `AC-3` | Nothing the rule learner produces reaches a pending approval | `lib/trinity/permissions/learner.ex` | `test/trinity/permissions/learner_census_test.exs` "only the planted file lets the learner near a pending approval" | Trinity's | none | none | | `AC-3` | An approval arriving on a chat channel may authorize at most a read, network or write effect; exec and destructive requests are refused by the channel's cap, whatever the profile | `lib/trinity/gateways/cap.ex` | `test/trinity/gateways/approvals_test.exs` "AC8: a destructive request is refused by the cap, receipted, and left for the desktop" | Trinity's | none | none | +| `AC-3` | Every route and every LiveView event is named by the access policy with the role it needs (`view`, `approve` or `administer`), an event the policy does not name needs `administer`, and a census over every `handle_event` in the tree holds it | `lib/trinity_web/auth/policy.ex`, `lib/trinity_web/router.ex` | `test/trinity_web/auth/policy_census_test.exs` "every handle_event in the tree is named by the policy, once"; `test/trinity_web/auth/policy_census_test.exs` "the events AC8 names need administer, approvals need approve, an unnamed one administer" | shared | Granting each person their roles at the identity provider | CRM "Enforcing a role on each page and action (AC-3, AC-6)" | +| `AC-3` | Every route and every `live_session` is guarded by the authentication plug and an `on_mount` check, or named as exempt, and the only exemptions are the login and the OAuth protocol endpoints; a census over the router holds it, and an unauthenticated GET or POST to any guarded route is a login redirect or a 401 | `lib/trinity_web/router.ex`, `lib/trinity_web/auth/require_role.ex` | `test/trinity_web/auth/route_census_test.exs` "every route and every live_session is guarded, or named as exempt"; `test/trinity_web/auth/route_census_test.exs` "the exemptions are exactly the login and the OAuth protocol endpoints, and all exist" | shared | The identity provider the login is made at | CRM "Authenticating access to the web pages" | | `AC-4` | Under `TRINITY_PROFILE=regulated` the node refuses to boot when a configured model's endpoint is not on `TRINITY_REGULATED_LLM_ENDPOINTS`, or states none. The check runs once, at boot, over the configured models | `lib/trinity/profile.ex`, `lib/trinity/application.ex` | `Trinity.Profile.check_llm_endpoints/3`; `test/trinity/regulated_boot_node_test.exs` "production mcp_auth and a non-Local authority, but no allow-list, does not start"; `test/trinity/profile_test.exs` "a model off the list is refused, naming the model and the endpoint" | shared | The allow-list itself, and the egress filtering that stops anything else leaving | CRM "Enforcing that only an approved model may be called" | | `AC-4` | Under `TRINITY_PROFILE=regulated` every configured gateway adapter must be named in `TRINITY_REGULATED_GATEWAYS`, or the node refuses to boot | `lib/trinity/profile.ex` | `Trinity.Profile.check_gateways/3`; `test/trinity/regulated_boot_node_test.exs` "a configured adapter refuses without the allow-list and boots with it" | shared | Naming the channels, and authorizing each one | CRM "Which chat gateways may carry conversation text" | | `AC-4` | Nothing in the tree decides what may leave the boundary; `data-flow.md` states what enters a prompt | `docs/regulated/data-flow.md` | none | the deployment's | The information flow policy, and the egress controls that enforce it | CRM "Deciding what may leave the boundary at all" | | `AC-5` | Under `TRINITY_PROFILE=regulated` the node refuses to boot on the local authority, where one process would decide, act and record; an external adapter that changes the arguments it decided on is denied | `lib/trinity/profile.ex`, `lib/trinity/authority.ex`, `lib/trinity/effects.ex` | `Trinity.Profile.check_authority/2`; `test/trinity/regulated_boot_node_test.exs` "everything else correct, but TRINITY_AUTHORITY unset, does not start"; `test/trinity/effects/membrane_test.exs` "an adapter that rewrites the arguments in stage/2 is denied, with the fingerprint re-derived after stage" | shared | The external authority adapter. None ships in this tree, and it is assessed with the deployment | CRM "Who decides whether an effect may happen" | | `AC-6` | The image runs as UID 10001, not root, which is checked on the built image; it writes only `/data` and `/tmp` | `ci/ironbank/Dockerfile`, `lib/mix/tasks/trinity.image.inspect.ex` | `mix trinity.image.inspect`; `test/mix/tasks/trinity_image_inspect_test.exs` "no user, root, UID 0 and a UID below 1000 are refused" | shared | A read-only root filesystem, dropped capabilities and no privilege escalation, set by the runtime that starts the container | CRM "Running the image read-only, with no capabilities and no privilege escalation" | +| `AC-6(9)` | Every approval decided on a page, and every privileged route or event used or refused, is a signed receipt on the `access` chain carrying the principal's `sub`, `iss` and the role used | `lib/trinity_web/auth.ex` | `test/trinity_web/auth/access_receipts_test.exs` "privileged routes and events are receipted, used and refused, with sub, iss and role"; `test/trinity_web/auth/access_receipts_test.exs` "every receipt on the access chain carries sub, iss and role, and the chain verifies" | shared | Retaining and reviewing the chain | CRM "Logging the use of privileged functions (AC-6(9))" | | `AC-16` | Content from outside the machine is marked untrusted, with its digest, and reaches the model only inside an untrusted block | `lib/trinity/tools/untrusted.ex` | `test/trinity/tools/provenance_test.exs` "the assistant's summary of a fetched page carries taint untrusted; the user's message stays trusted" | Trinity's | none | none | | `AU-2` | The events recorded are a closed vocabulary of receipt kinds: decision, effect, query, boot and cap | `lib/trinity/receipts/receipt.ex`, `docs/receipt-scheme-mapping.md` | `test/trinity/receipts/scheme_mapping_test.exs` "AC3: the kinds the document names are exactly Receipt.kinds/0" | Trinity's | none | none | | `AU-3` | Every receipt signs one field set: its kind, its subject, the decision, the fingerprint, the time, the key, and its place in the chain | `lib/trinity/receipts/chain_writer.ex`, `docs/receipt-scheme-mapping.md` | `test/trinity/receipts/scheme_mapping_test.exs` "AC3: every signed field has a row in the mapping table, and every row names a signed field" | Trinity's | none | none | @@ -93,7 +96,7 @@ register does not claim is never Trinity's. | `IA-2` | Trinity is never the identity provider for a deployment's users; it validates the tokens the deployment's provider issues | `lib/trinity/mcp/auth.ex` | none | the deployment's | Operating the identity provider, and authenticating users there | CRM "Operating the IdP" | | `IA-2` | An inbound MCP request is refused unless it carries a token the configured issuer signed for this resource: a wrong audience, an expired token and the static bearer are refused, each with a receipt | `lib/trinity/mcp/auth.ex`, `lib/trinity/mcp/auth/token.ex` | `test/trinity/mcp/auth/resource_server_test.exs` "AC1: 401 with resource_metadata; the PRM names the AS; wrong audience, expired and the static bearer are 401; each refusal receipted"; `test/trinity/mcp/auth/token_test.exs` "each claim check is its own refusal" | shared | The issuer, audience and resource Trinity is configured to accept | CRM "Validating a bearer token on an inbound MCP request" | | `IA-2` | Under the production profile Trinity mints no token and holds no issuer key; the personal issuer refuses to start under an external authority, and under `TRINITY_PROFILE=regulated` | `lib/trinity/mcp/auth/embedded.ex`, `lib/trinity/profile.ex` | `Trinity.Profile.check_embedded_as/2`; `test/trinity/mcp/auth/embedded_test.exs` "the production profile holds no key material of its own: no keys, no JWKS, no AS metadata"; `test/trinity/mcp/auth/embedded_test.exs` "the personal profile refuses to start under an external authority adapter" | shared | The identity provider that does issue the tokens | CRM "Choosing the profile"; Register "Trinity issues no production authority": tree property | -| `IA-2` | Partial. The web pages, among them the permissions page where approvals are answered, carry no authentication of their own (a product gap, tracked as slice 136); the bearer is required on `/mcp` only, and the image binds every interface | `lib/trinity_web/router.ex`, `docs/mcp-server.md`, `ci/ironbank/Dockerfile` | none | shared | The OIDC issuer; until slice 136, an authenticating reverse proxy or an owner-only network as an interim compensating control with a POA&M entry | CRM "Authenticating access to the web pages" | +| `IA-2` | The web pages answer only an authenticated principal. On a bind other machines can reach, that is an OpenID Connect login (code flow with PKCE) or, outside `TRINITY_PROFILE=regulated`, one shared token; with neither the node refuses to boot in every profile, and under the regulated profile it refuses the shared token and no login on any non-loopback bind. `/mcp` still requires the issuer's bearer | `lib/trinity_web/auth/oidc.ex`, `lib/trinity/web_auth.ex`, `lib/trinity/profile.ex` | `Trinity.Profile.check_web_auth/3`; `test/trinity_web/auth/boot_refusal_node_test.exs` "none on 0.0.0.0 refuses to boot, naming the mode, the address and the variable"; `test/trinity_web/auth/boot_refusal_node_test.exs` "local_token on 0.0.0.0 refuses to boot, with a valid token configured" | shared | The OIDC issuer, the client registration, and the roles in a claim the ID token carries | CRM "Authenticating a person on the web pages (IA-2)" | | `IA-5(7)` | The gate fails on a credential-shaped string anywhere in the tracked tree | `lib/mix/tasks/trinity.secrets.scan.ex` | `mix trinity.secrets.scan`; `test/secrets_scan_test.exs` "RED: a planted fake key of each shape is found" | Trinity's | none | none | | `IA-11` | An expired token is refused, and with introspection enabled the issuer is asked about each token; a client then goes back to the identity provider | `lib/trinity/mcp/auth/token.ex`, `config/runtime.exs` | `test/trinity/mcp/auth/token_test.exs` "introspection: an opaque token is asked about at the issuer (RFC 7662), the answer's claims checked as a JWT's are" | shared | Token lifetime, revocation and re-authentication policy, at the identity provider | CRM "Token revocation and session lifetime" | | `IR-6` | Trinity reports nothing to anyone; `incident.md` sets out the clocks that may apply and what the record gives an investigator | `docs/regulated/incident.md` | none | the deployment's | Reporting, within whatever clock applies | CRM "Reporting to a government customer within a clock" | @@ -106,11 +109,13 @@ register does not claim is never Trinity's. | `SC-7` | The model endpoint is outside the boundary unless the deployment puts it inside | `docs/regulated/authorization-boundary.md` | none | the deployment's | Self-hosting the model, where that is required | CRM "Keeping the model inside your boundary" | | `SC-8` | Connections Trinity makes offer TLS 1.2 and 1.3 and nothing older; the check covers the versions offered, not peer verification or the trust store | `README.md` | `test/supply_chain_test.exs` "the runtime's default TLS versions are 1.2 and 1.3, and nothing older" | shared | The trust store peers are verified against | CRM "TLS on connections Trinity makes"; Register "TLS floor 1.2 in FIPS mode": :unknown | | `SC-8` | The headless release serves plain HTTP on `TRINITY_BIND`: the loopback by default, every interface in the image; `force_ssl` trusts `x-forwarded-proto`, a header any client can send unless a proxy strips it | `config/runtime.exs`, `ci/ironbank/Dockerfile`, `config/prod.exs` | none | the deployment's | TLS in front of the process, and a proxy that strips inbound `x-forwarded-*` headers | CRM "TLS on connections made to Trinity" | +| `SC-8` | `x-forwarded-proto` is believed only when `TRINITY_TRUSTED_PROXY` says a proxy strips and sets it; under `TRINITY_PROFILE=regulated` the node refuses to boot when `force_ssl` would believe the header without that statement | `lib/trinity/profile.ex`, `config/prod.exs` | `Trinity.Profile.check_trusted_proxy/3`; `test/trinity_web/auth/boot_refusal_node_test.exs` "TRINITY_TRUSTED_PROXY unset refuses to boot; set, the same node boots" | shared | A proxy that strips inbound `x-forwarded-*` headers and sets its own | CRM "Believing `x-forwarded-proto`" | | `SC-12` | The receipt signing key is generated once, mode 0600, registered, and read at every signature by the custody module and by nothing else | `lib/trinity/receipts/key_custody.ex`, `lib/trinity/keys.ex` | `test/trinity/receipts/signer_test.exs` "boot generates the key once (0600), appends its registry row, and a second boot reuses it"; `test/trinity/keys/custody_census_test.exs` "no module outside the custody seam reads key material from disk" | Trinity's | none | CRM "Receipt signing key generation and use" | | `SC-12` | Key material lives in the data directory's keys directory by default, sealed when a key source is configured | `lib/trinity/paths.ex`, `lib/trinity/keys/local.ex` | `test/trinity/keys/signer_seam_test.exs` "the signing key is ciphertext on disk and the registry says so" | shared | Where the keys live, and who can read them | CRM "Where key material lives"; Register "Exclusive deployment control of encryption keys": :unknown | | `SC-12` | Keys pass through one behaviour, so a KMS or HSM adapter is a module and a configuration line; none ships in this tree | `lib/trinity/keys.ex` | `test/trinity/keys/signer_seam_test.exs` "a sealed key still signs, which is the point of sealing it" | shared | A KMS or HSM adapter, where one is required | CRM "A KMS or HSM adapter" | | `SC-12` | Custody of the signing key is not addressed: a key in a file shows the records were not altered by anything without read access to it, and nothing about who held it | `docs/10-assurance-case.md` | none | the deployment's | Custody of the signing key | CRM "Custody of the signing key" | | `SC-13` | Signing picks its algorithm by the runtime's mode: Ed25519 by default, ECDSA P-384 with SHA-384 in FIPS mode, where Ed25519 reports itself unavailable. The gate and the suite run in FIPS mode on every push | `lib/trinity/receipts/signer.ex`, `docs/fips-leg.md`, `docs/regulated/crypto-inventory.md` | `test/trinity/receipts/signer_test.exs` "outside FIPS mode the default is Ed25519; in FIPS mode (the fips leg) it is P-384"; `test/fips/receipts_test.exs` "the mode is on, Ed25519 reports unavailable, P-384 is selected and the boot receipt names it" | shared | A validated cryptographic module on the host, established from its certificate and the running binary | Register "FIPS 140-3 validated cryptography in FIPS mode": :unknown; Register "FIPS 140-2 certificates on the Historical list from 2026-09-22": real-world dependency | +| `SC-23` | A request whose `Host` is not the loopback or the configured host is refused before routing, and the websocket origin check is an explicit list in every environment; under `TRINITY_PROFILE=regulated` an origin check of `false` or `:conn` refuses to boot, because a DNS rebinding page controls both | `lib/trinity_web/plugs/host_allow_list.ex`, `lib/trinity/profile.ex` | `Trinity.Profile.check_origin/2`; `test/trinity_web/auth/host_and_origin_test.exs` "a foreign Host is 403 before routing, wherever it points"; `test/trinity_web/auth/host_and_origin_test.exs` "check_origin is an explicit list in dev, test and prod" | shared | Setting `PHX_HOST` to the host users reach | CRM "Refusing a request whose Host is not this deployment's" | | `SC-23` | The state an MCP client carries between rounds is sealed with AES-256-GCM: a replay, one tampered byte, an expired envelope and a state bound to other arguments are refused | `lib/trinity/mcp/server/envelope.ex` | `test/trinity/mcp/server_mrtr_test.exs` "a replay inside the partition, one tampered byte, an expired envelope and a state bound to other arguments are refused; a missing state is a first call" | Trinity's | none | Register "Envelope MAC in FIPS mode is an approved algorithm": :unknown | | `SC-24` | Failure denies: an unavailable signer denies every effect, a tool absent from the catalogue or a decision other than allow is denied before anything runs, and an authority adapter that cannot be loaded refuses the boot | `lib/trinity/effects.ex`, `lib/trinity/authority/selection.ex` | `test/trinity/effects/membrane_test.exs` "a :catalog tool absent from the catalog, or a decision other than allow, is denied before anything runs"; `test/trinity/authority/selection_test.exs` "an absent module is refused as not loaded, by name" | Trinity's | none | Register "Nothing fails open": :unknown | | `SC-28` | Anything SQLite indexes is left unencrypted by design, for the volume beneath it | `docs/encryption-at-rest.md` | none | the deployment's | Volume or page-level encryption below SQLite | CRM "Encryption of everything SQLite indexes" | diff --git a/docs/regulated/customer-responsibility-matrix.md b/docs/regulated/customer-responsibility-matrix.md index a884785..f8dc5b5 100644 --- a/docs/regulated/customer-responsibility-matrix.md +++ b/docs/regulated/customer-responsibility-matrix.md @@ -71,7 +71,7 @@ deployment, and it does not supply what the deployment has to. |---|---|---|---| | TLS on connections Trinity makes | **Yes.** The runtime offers TLS 1.2 and 1.3 and nothing older, `test/supply_chain_test.exs`. That test checks the versions offered and nothing else: not peer verification, not the trust store | **Yes.** The trust store peers are verified against (in a DoD deployment, the DoD roots) | No | | TLS on connections made to Trinity | **No.** The headless release serves plain HTTP on `TRINITY_BIND`: the loopback by default (`config/runtime.exs`), every interface in the image (`ci/ironbank/Dockerfile`). `config/prod.exs` sets `force_ssl` with `rewrite_on: [:x_forwarded_proto]`, so a request carrying `x-forwarded-proto: https` is treated as HTTPS, and any client can send that header unless a proxy strips it | **Yes.** Terminate TLS in front of it, and strip inbound `x-forwarded-*` headers at that proxy | **Yes** | -| Authenticating access to the web pages | **Partial.** MCP endpoints require a token from the external issuer (implemented). The web pages, among them the permissions page where approvals are answered, carry no authentication of their own (`lib/trinity_web/router.ex`, `docs/mcp-server.md`): a product gap, tracked as slice 136, which authenticates them against the same issuer. The image binds every interface | **Yes.** The OIDC issuer. Until slice 136 lands, an authenticating reverse proxy in front of the port, or a network only the owner reaches, as an interim compensating control with a POA&M entry, never the sole mitigation | **Yes** | +| Authenticating access to the web pages | **Yes**, since slice 136. Every page and every LiveView mount and event requires a principal holding the role the access policy names (`lib/trinity_web/router.ex`, `lib/trinity_web/auth/policy.ex`), a foreign `Host` is refused before routing, and the websocket origin check is an explicit list. MCP endpoints still require the external issuer's token | **Yes.** The OIDC issuer, and the roles `view`, `approve` and `administer` granted to each person | No | ## Backups From 596349bb40dc5c862d25ab67313d429a7748eafe Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Thu, 8 Oct 2026 15:18:18 -0400 Subject: [PATCH 3/4] fix(s136): the session cookie is signed, not encrypted, so the pages work on a FIPS node Plug encrypts a session cookie with XChaCha20-Poly1305 only, and the FIPS provider refuses it: 128 tests failed on both fips runs of f0591b4 with "Forbidden in FIPS" from Plug.Crypto.MessageEncryptor, every one a page request. The cookie holds a server-side session id and, during a login, the PKCE verifier and nonce; it is HttpOnly, SameSite, Secure behind TLS and HMAC-SHA256 signed. A test holds the shape of the cookie the application sends (SFMyNTY., not XCP.) on every leg; it fails with encryption_salt put back. Signed-off-by: Ayla Croft --- lib/trinity_web/endpoint.ex | 12 +++++++----- test/trinity_web/session_cookie_test.exs | 25 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) create mode 100644 test/trinity_web/session_cookie_test.exs diff --git a/lib/trinity_web/endpoint.ex b/lib/trinity_web/endpoint.ex index 606073c..8b9b3da 100644 --- a/lib/trinity_web/endpoint.ex +++ b/lib/trinity_web/endpoint.ex @@ -5,15 +5,17 @@ defmodule TrinityWeb.Endpoint do # The session will be stored in the cookie and signed, # this means its contents can be read but not tampered with. - # Set :encryption_salt if you would also like to encrypt it. - # Slice 136: encrypted as well as signed (the OpenID Connect login keeps its PKCE verifier and - # nonce here between the redirect and the callback), and `SameSite`/`Secure` chosen per request - # by `TrinityWeb.Plugs.Session`. + # Slice 136: signed and never encrypted. Plug encrypts a cookie with XChaCha20-Poly1305 and + # offers nothing else, and the FIPS provider refuses it ("Forbidden in FIPS"), so an encrypted + # session would fail every page on a FIPS node. What the cookie holds does not need hiding from + # the browser that holds it: a server-side session id, and during a login the PKCE verifier and + # nonce, under HttpOnly, SameSite and (behind TLS) Secure, chosen per request by + # `TrinityWeb.Plugs.Session`. HMAC-SHA256 signing is FIPS-approved. + # `test/trinity_web/session_cookie_test.exs` holds it. @session_options [ store: :cookie, key: "_trinity_key", signing_salt: "NacAdZLW", - encryption_salt: "q8N3vXwT", same_site: "Lax" ] diff --git a/test/trinity_web/session_cookie_test.exs b/test/trinity_web/session_cookie_test.exs new file mode 100644 index 0000000..d81b0d0 --- /dev/null +++ b/test/trinity_web/session_cookie_test.exs @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.SessionCookieTest do + @moduledoc """ + The session cookie is signed and not encrypted (slice 136, `TrinityWeb.Endpoint`). + + Plug encrypts a session cookie with XChaCha20-Poly1305 and has no other cipher; the FIPS provider + refuses it, so an `encryption_salt` turns every page on a FIPS node into a 500. That happened once: + 128 tests failed on the FIPS leg and none elsewhere. This holds the shape of the cookie the + application actually sends, so the regression fails on every leg, not only on the FIPS one. + + `Plug.Crypto.MessageVerifier` writes `SFMyNTY.` (base64 of `HS256`) before a signed message; an + encrypted one starts `XCP.` (or, from older Plug, a base64 `A128GCM` header). + """ + use TrinityWeb.ConnCase, async: true + + test "the session cookie the pages set is signed HS256, not encrypted", %{conn: conn} do + conn = get(conn, ~p"/") + cookie = conn.resp_cookies["_trinity_key"] + + assert cookie, "GET / set no session cookie; the shape cannot be checked" + assert String.starts_with?(cookie.value, "SFMyNTY."), cookie.value + refute String.starts_with?(cookie.value, "XCP.") + end +end From 1442bfd0b7844cfbf173c712cac2bf1b1f271c7d Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Thu, 8 Oct 2026 16:01:05 -0400 Subject: [PATCH 4/4] fix(s136): the boot refusal cases carry an egress allow-list, now that slice 135 asks for one Merged with slice 135, every regulated case in boot_refusal_node_test stopped at 135's egress refusal (network: :allow with no TRINITY_REGULATED_EGRESS) before the web-auth check it names. The base environment carries the list, as 135 did for regulated_boot_node_test. Signed-off-by: Ayla Croft --- test/trinity_web/auth/boot_refusal_node_test.exs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/trinity_web/auth/boot_refusal_node_test.exs b/test/trinity_web/auth/boot_refusal_node_test.exs index f6b7c97..01c837a 100644 --- a/test/trinity_web/auth/boot_refusal_node_test.exs +++ b/test/trinity_web/auth/boot_refusal_node_test.exs @@ -100,6 +100,9 @@ defmodule TrinityWeb.Auth.BootRefusalNodeTest do {"TRINITY_PROFILE", "regulated"}, {"TRINITY_AUTHORITY", "WebBootFixture"}, {"TRINITY_REGULATED_LLM_ENDPOINTS", "https://models.internal"}, + # Slice 135: a regulated node with the default `network: :allow` needs an egress allow-list, + # so every case here has one and refuses (or boots) for the web reason it names. + {"TRINITY_REGULATED_EGRESS", "docs.internal"}, {"TRINITY_MCP_AUTH_PROFILE", nil}, {"TRINITY_WEB_AUTH", nil}, {"TRINITY_TRUSTED_PROXY", nil}