defmodule OpenAgentsWeb.RouteAuthority do
@moduledoc """
Executable authority inventory for every Phoenix route and endpoint socket.
The classifier intentionally has no catch-all policy. A route outside one of
these bounded surfaces is `:unclassified`, which fails the inventory test
until its principal and scope are chosen deliberately.
"""
@classes [
:public_read,
:public_action,
:authenticated_browser,
:authenticated_api,
:operator,
:computer,
:internal_service,
:git_transport
]
@public_browser_paths [
"/",
"/status",
"/changelog",
"/coder",
"/leaderboard",
"/components",
"/components/icons",
"/components/:slug",
"/docs",
"/docs/:slug",
"/health",
"/healthz"
]
@authenticated_browser_prefixes [
"/sarah",
"/timeline",
"/computers",
"/voice/",
"/data",
"/artifact-catalog",
"/machines",
# No trailing slash: the memory page is "/memory" itself, and its export
# lives under it.
"/memory",
"/device",
"/repositories",
# The signed-in forum surfaces: claiming a legacy identity and setting a
# tip destination. The board, topic, and home reads are public and are
# classified above; this prefix covers what is left under `/forum`.
"/forum",
# The inbox addresses exactly one account, so it has nothing to show a
# visitor who has not identified themselves.
"/notifications",
# Thread transcripts. The list is the account's own, and a thread the
# reader may not read answers 404 rather than 403, so the prefix is
# authenticated even where a thread's own tier widens who may read it
# (THREAD-002): the wider audience is still signed in.
"/threads",
# The model catalog and its rates. Same principal as `GET /api/v1/models`,
# which sits in the thread scope because the catalog names what a grant can
# be minted for: the reader of the prices is the caller who can spend them.
"/models",
"/settings/api-tokens",
"/github/connection",
"/api/tokens",
"/api/computers",
"/api/capacity",
"/api/computer-agent-jobs/"
]
# Reads that an anonymous caller reaches on a public repository and a bearer
# token widens to the private repositories its account may read. The class
# stays `:public_read` because anonymous access is unchanged; the principal
# is what the token changes, and every one of these routes resolves the
# repository through `Repositories.get_visible_by_path!/3`.
@optional_forge_read_paths [
"/api/v1/repos/:owner/:repo/issues",
"/api/v1/repos/:owner/:repo/issues/:issue_number",
"/api/v1/repos/:owner/:repo/issues/:issue_number/activity",
"/api/v1/repos/:owner/:repo/issues/:issue_number/dependencies",
"/api/v1/repos/:owner/:repo/issues/:issue_number/comments",
"/api/v1/repos/:owner/:repo/issues/comments/:id",
"/api/v1/repos/:owner/:repo/issues/:issue_number/labels",
"/api/v1/repos/:owner/:repo/issues/:issue_number/assignees",
"/api/v1/repos/:owner/:repo/labels",
"/api/v1/repos/:owner/:repo/labels/:name",
"/api/v1/repos/:owner/:repo/milestones",
"/api/v1/repos/:owner/:repo/milestones/:milestone_number",
"/api/v1/repos/:owner/:repo/assignees",
"/api/v1/repos/:owner/:repo/assignees/:assignee",
"/api/v1/repos/:owner/:repo/pulls",
"/api/v1/repos/:owner/:repo/pulls/:pull_number",
"/api/v1/repos/:owner/:repo/projectsV2",
"/api/v1/repos/:owner/:repo/projectsV2/:project_number",
"/api/v1/repos/:owner/:repo/projectsV2/:project_number/items",
"/api/v1/repos/:owner/:repo/projectsV2/:project_number/items/:item_id/events",
"/api/v1/repos/:owner/:repo/projectsV2/:project_number/fields"
]
@spec classes() :: [atom()]
def classes, do: @classes
@spec inventory() :: [map()]
def inventory do
routes = Enum.map(OpenAgentsWeb.Router.__routes__(), &classify/1)
routes ++ socket_inventory()
end
@spec classify(map()) :: map()
def classify(route) do
base = %{
transport: :http,
verb: to_string(route.verb),
path: route.path,
handler: inspect(route.plug),
action: inspect(route.plug_opts)
}
Map.merge(base, policy(route))
end
@spec socket_inventory() :: [map()]
def socket_inventory do
[
%{
transport: :websocket,
verb: "connect",
path: "/live",
handler: "Phoenix.LiveView.Socket",
action: "connect",
class: :authenticated_browser,
principal: "encrypted browser session",
scope: "liveview:session",
mutation: true
},
%{
transport: :websocket,
verb: "connect",
path: "/controller/socket",
handler: "OpenAgentsWeb.ControllerSocket",
action: "connect",
class: :computer,
principal: "active paired-computer bearer",
scope: "computer:channel",
mutation: true
}
]
end
defp policy(%{path: path, verb: verb})
when path in @public_browser_paths and verb in [:get, :head],
do: declaration(:public_read, "anonymous", "published:web", false)
defp policy(%{path: "/issues", verb: verb}) when verb in [:get, :head],
do:
declaration(
:public_read,
"anonymous visitor or signed-in person",
"forge:issues:web",
false
)
defp policy(%{path: "/projects", verb: verb}) when verb in [:get, :head],
do:
declaration(
:public_read,
"anonymous visitor or signed-in person",
"forge:projects:web",
false
)
# The forum reads. Public since the cutover retired the legacy surface: the
# context's readability predicates decide what an anonymous reader sees, and
# posting still requires an account. `/forum/claim` and `/forum/tips` stay
# behind the authenticated prefix.
defp policy(%{path: path, verb: verb})
when path in [
"/forum",
"/forum/f/:slug",
"/forum/t/:id",
"/forum/topic/:id",
"/forum/post/:id"
] and verb in [:get, :head],
do:
declaration(
:public_read,
"anonymous visitor or signed-in person",
"forum:web",
false
)
defp policy(%{path: "/auth/github", verb: :post}),
do: declaration(:public_action, "OAuth applicant", "identity:sign-in", true)
defp policy(%{path: "/auth/github/callback"}),
do: declaration(:authenticated_browser, "one-time OAuth attempt", "identity:connect", true)
defp policy(%{path: "/logout"}),
do: declaration(:authenticated_browser, "encrypted browser session", "session:delete", true)
defp policy(%{path: "/chat"}),
do: declaration(:operator, "configured operator GitHub ID", "chat:preview", false)
defp policy(%{path: "/gym"}),
do: declaration(:operator, "configured operator GitHub ID", "gym:read", false)
defp policy(%{path: "/gym/runs/:id"}),
do: declaration(:operator, "configured operator GitHub ID", "gym:read", false)
defp policy(%{path: "/admin/analytics"}),
do: declaration(:operator, "configured operator GitHub ID", "analytics:read", false)
defp policy(%{path: "/admin/tokens"}),
do: declaration(:operator, "configured operator GitHub ID", "tokens:productivity:read", false)
defp policy(%{path: "/admin/forge"}),
do: declaration(:operator, "configured operator GitHub ID", "forge:promote", true)
defp policy(%{path: "/admin/scv/accounts"}),
do: declaration(:operator, "configured operator GitHub ID", "scv:account:connect", true)
defp policy(%{path: "/admin/forum/claims"}),
do: declaration(:operator, "configured operator GitHub ID", "forum:identity:link", true)
defp policy(%{path: "/admin/recordings"}),
do: declaration(:operator, "configured operator GitHub ID", "voice:recording:list", false)
defp policy(%{path: "/admin/recordings/:id/audio"}),
do: declaration(:operator, "configured operator GitHub ID", "voice:recording:read", false)
defp policy(%{path: "/admin"}),
do: declaration(:operator, "configured operator GitHub ID", "voice:metadata:read", false)
defp policy(%{path: path})
when path in [
"/:owner/:repo/info/refs",
"/:owner/:repo/git-upload-pack",
"/:owner/:repo/git-receive-pack",
"/git"
],
do:
declaration(
:git_transport,
"anonymous read or authorized user, operator, or paired-computer HTTP credential",
"git:repository",
true
)
defp policy(%{path: "/api/status", verb: verb}) when verb in [:get, :head],
do: declaration(:public_read, "anonymous", "published:status", false)
# The published WAL anchor (EXIT-005, ADR 0008). Anonymous read: a
# commitment a stranger cannot fetch without a credential is a commitment
# the operator decides who may check.
defp policy(%{path: "/.well-known/openagents-forge-anchor.json", verb: verb})
when verb in [:get, :head],
do: declaration(:public_read, "anonymous", "published:forge-wal-anchor", false)
# The agent front door. Public by construction: it describes how to ask and
# carries no instance data, so there is nothing in it to withhold.
defp policy(%{path: path, verb: verb})
when path in ["/agents.md", "/agents.json"] and verb in [:get, :head],
do: declaration(:public_read, "anonymous", "published:contribution-contract", false)
defp policy(%{path: "/api/capacity", verb: verb}) when verb in [:get, :head],
do:
declaration(:authenticated_api, "active encrypted browser session", "capacity:self", false)
defp policy(%{path: "/api/changelog", verb: verb}) when verb in [:get, :head],
do: declaration(:public_read, "anonymous", "published:changelog", false)
defp policy(%{path: path, verb: verb})
when path in [
"/api/contracts/repositories-v1.json",
"/api/contracts/do-not-build-v1.json"
] and
verb in [:get, :head],
do: declaration(:public_read, "anonymous", "published:api-contract", false)
defp policy(%{path: "/api/v1", verb: verb}) when verb in [:get, :head],
do: declaration(:public_read, "anonymous", "published:api-extensions", false)
defp policy(%{path: "/controller/pairings", verb: :post}),
do: declaration(:computer, "unpaired computer", "computer:pairing:create", true)
defp policy(%{path: "/controller/pairings/:id"}),
do: declaration(:computer, "expiring one-time poll secret", "computer:pairing:claim", true)
# `computer:status`, not `machine:status`. This label is an in-repository
# inventory string: no token carries it, no pipeline authorizes against it,
# and `/controller/status` appears in no published contract, so it is on the
# movable side of the line CANON-002 draws. Its siblings above already say
# computer.
defp policy(%{path: "/controller/status", verb: :get}),
do: declaration(:computer, "active paired computer bearer", "computer:status", false)
defp policy(%{path: "/api/inference/proxy"}),
do: declaration(:internal_service, "scoped inference grant", "inference:invoke", true)
defp policy(%{path: "/api/artifact-listings" <> _path, verb: verb})
when verb in [:get, :head],
do:
declaration(
:authenticated_api,
"active encrypted browser session",
"artifact-catalog:read",
false
)
defp policy(%{path: "/api/v1/agent/credentials", verb: :post}),
do:
declaration(
:authenticated_api,
"agent bearer token",
"agent:participate",
true
)
defp policy(%{path: path, verb: verb})
when path in [
"/api/v1/computers",
"/api/v1/computers/:id",
"/api/v1/computers/:computer_id/probe",
"/api/v1/computers/:computer_id/agent-jobs",
"/api/v1/computer-agent-jobs/:id"
] do
declaration(
:authenticated_api,
"human or delegated computer-control bearer token",
"computer:control",
verb in [:patch, :post, :delete]
)
end
defp policy(%{path: "/api/v1/conversations/:conversation_id/boxes", verb: verb})
when verb in [:get, :post],
do:
declaration(
:authenticated_api,
"human or delegated box-control bearer token",
"box:control",
verb == :post
)
defp policy(%{path: "/api/v1/conversations/:conversation_id/boxes/:box_id", verb: :get}),
do:
declaration(
:authenticated_api,
"human or delegated box-control bearer token",
"box:control",
false
)
defp policy(%{
path: "/api/v1/conversations/:conversation_id/boxes/:box_id/commands",
verb: :post
}),
do:
declaration(
:authenticated_api,
"human or delegated box-control bearer token",
"box:control",
true
)
defp policy(%{path: "/api/v1/conversations/:conversation_id/boxes/:box_id/stop", verb: :post}),
do:
declaration(
:authenticated_api,
"human or delegated box-control bearer token",
"box:control",
true
)
defp policy(%{
path: "/api/v1/conversations/:conversation_id/boxes/:box_id/assignments",
verb: :post
}),
do:
declaration(
:authenticated_api,
"human or delegated box-control bearer token",
"box:control",
true
)
defp policy(%{
path: "/api/v1/conversations/:conversation_id/boxes/:box_id/assignments/:assignment_id",
verb: :get
}),
do:
declaration(
:authenticated_api,
"human or delegated box-control bearer token",
"box:control",
false
)
defp policy(%{
path:
"/api/v1/conversations/:conversation_id/boxes/:box_id/assignments/:assignment_id/cancel",
verb: :post
}),
do:
declaration(
:authenticated_api,
"human or delegated box-control bearer token",
"box:control",
true
)
defp policy(%{
path: "/api/v1/conversations/:conversation_id/computers/:computer_id/assignments",
verb: :post
}),
do:
declaration(
:authenticated_api,
"human or delegated computer-control bearer token",
"computer:control",
true
)
defp policy(%{
path:
"/api/v1/conversations/:conversation_id/computers/:computer_id/assignments/:assignment_id",
verb: :get
}),
do:
declaration(
:authenticated_api,
"human or delegated computer-control bearer token",
"computer:control",
false
)
defp policy(%{
path:
"/api/v1/conversations/:conversation_id/computers/:computer_id/assignments/:assignment_id/cancel",
verb: :post
}),
do:
declaration(
:authenticated_api,
"human or delegated computer-control bearer token",
"computer:control",
true
)
defp policy(%{
path: "/api/v1/conversations/:conversation_id/boxes/:box_id/runs",
verb: verb
})
when verb in [:get, :post],
do:
declaration(
:authenticated_api,
"human or delegated box-control bearer token",
"box:control",
verb == :post
)
defp policy(%{
path: "/api/v1/conversations/:conversation_id/boxes/:box_id/runs/:run_id",
verb: :get
}),
do:
declaration(
:authenticated_api,
"human or delegated box-control bearer token",
"box:control",
false
)
defp policy(%{
path: "/api/v1/conversations/:conversation_id/boxes/:box_id/runs/:run_id/output",
verb: :get
}),
do:
declaration(
:authenticated_api,
"human or delegated box-control bearer token",
"box:control",
false
)
defp policy(%{
path: "/api/v1/conversations/:conversation_id/boxes/:box_id/runs/:run_id/cancel",
verb: :post
}),
do:
declaration(
:authenticated_api,
"human or delegated box-control bearer token",
"box:control",
true
)
defp policy(%{path: "/api/operator/artifact-listings" <> _path, verb: verb}),
do:
declaration(
:operator,
"configured operator GitHub ID",
"artifact-catalog:operate",
verb not in [:get, :head]
)
defp policy(%{path: "/api/operator/continual-learning" <> _path, verb: verb}),
do:
declaration(
:operator,
"configured operator GitHub ID",
"continual-learning:operate",
verb not in [:get, :head]
)
defp policy(%{path: "/api/operator/agents/" <> _path, verb: verb}),
do:
declaration(
:operator,
"configured operator GitHub ID",
"agents:moderate",
verb not in [:get, :head]
)
defp policy(%{path: "/api/v1/device/authorizations" <> _path, verb: :post}),
do:
declaration(
:authenticated_api,
"expiring one-time device secret",
"device:authorize",
true
)
defp policy(%{path: path, verb: verb})
when path in ["/api/v1/user", "/api/v1/user/repos", "/api/v1/repository-imports/:id"] and
verb in [:get, :head],
do: declaration(:authenticated_api, "first-party bearer token", "forge:read", false)
defp policy(%{path: "/api/v1/repos/:owner/:repo", verb: verb}) when verb in [:get, :head],
do:
declaration(
:public_read,
"anonymous or first-party bearer token",
"forge:repository:read",
false
)
# Fleet promotion is release authority over OpenAgents itself, so it is
# classified before every /api/v1 rule below it: an operator credential
# holding `deployments:promote`, never the generic write catch-all, and never
# the tenant plane's `deployments:write`.
defp policy(%{path: "/api/v1/admin/forge/targets" <> _path, verb: verb}),
do:
declaration(
:operator,
"current operator holding a privileged bearer token",
"deployments:promote",
verb not in [:get, :head]
)
# The deployment control plane has no anonymous surface. Reading a deployment
# discloses what a repository ships and when, so every route here authenticates
# a tenant principal, and none of them carries the operator fleet authority
# behind `/admin/forge`.
defp policy(%{path: "/api/v1/repos/:owner/:repo/deployment" <> _path, verb: verb})
when verb in [:get, :head],
do:
declaration(
:authenticated_api,
"first-party bearer token or workflow grant",
"deployments:write",
false
)
defp policy(%{path: "/api/v1/repos/:owner/:repo/deployment" <> _path}),
do:
declaration(
:authenticated_api,
"first-party bearer token or workflow grant",
"deployments:write",
true
)
defp policy(%{path: "/api/v1/chat/events", verb: verb}) when verb in [:get, :head],
do: declaration(:authenticated_api, "first-party bearer token", "chat:account", false)
defp policy(%{path: "/api/v1/chat/turns", verb: :post}),
do: declaration(:authenticated_api, "first-party bearer token", "chat:account", true)
defp policy(%{path: "/api/v1/capacity", verb: verb}) when verb in [:get, :head],
do: declaration(:authenticated_api, "first-party bearer token", "chat:account", false)
defp policy(%{path: "/api/v1/capacity/matches", verb: :post}),
do: declaration(:authenticated_api, "first-party bearer token", "chat:account", true)
defp policy(%{path: path, verb: verb})
when path in @optional_forge_read_paths and verb in [:get, :head],
do:
declaration(
:public_read,
"anonymous or first-party bearer token",
"forge:repository:read",
false
)
defp policy(%{path: path, verb: verb})
when path in ["/api/v1/plugins", "/api/v1/plugins/:name"] and
verb in [:get, :head],
do: declaration(:public_read, "anonymous", "plugins:discover", false)
defp policy(%{path: path, verb: :post})
when path in [
"/api/v1/forum/topics",
"/api/v1/forum/topics/:topic_id/posts",
"/api/v1/repos/:owner/:repo/issues",
"/api/v1/repos/:owner/:repo/issues/:issue_number/comments"
],
do:
declaration(
:authenticated_api,
"first-party human or agent bearer token",
"forge:write or agent:participate",
true
)
defp policy(%{path: "/api/v1/" <> _path, verb: verb}) when verb in [:get, :head],
do: declaration(:public_read, "anonymous", "published:forge", false)
defp policy(%{path: "/api/v1/" <> _path}),
do: declaration(:authenticated_api, "first-party bearer token", "forge:write", true)
defp policy(%{path: "/dev/" <> _path}),
do:
declaration(:internal_service, "development-only browser", "development:diagnostics", false)
defp policy(%{plug: OpenAgentsWeb.NotFoundController, verb: verb}) when verb in [:get, :head],
do: declaration(:public_read, "anonymous", "published:not-found", false)
# CLI release downloads. Public by construction: the route proxies a bucket
# that already grants every object to `allUsers`, so it withholds nothing a
# direct storage URL would hand over, and it only ever reads. It exists to
# give the artifacts one durable name, not to gate them.
defp policy(%{path: "/releases" <> _rest, verb: verb}) when verb in [:get, :head],
do: declaration(:public_read, "anonymous", "published:cli-release", false)
defp policy(%{path: path, verb: verb}) do
cond do
String.starts_with?(path, "/og/") and verb in [:get, :head] ->
# Card images are public by construction (they describe only what
# anonymous pages already show) and mutate nothing.
declaration(:public_read, "anonymous crawler", "published:og-card", false)
Enum.any?(@authenticated_browser_prefixes, &String.starts_with?(path, &1)) ->
declaration(
:authenticated_browser,
"active encrypted browser session",
browser_scope(path),
browser_mutation?(path, verb)
)
tracker_browser_path?(path) ->
declaration(:authenticated_browser, "active encrypted browser session", "forge:web", true)
tracker_read_browser_path?(path) and verb in [:get, :head] ->
# Reading tracker surfaces is public on a public repository. The GET
# mutates nothing: every write rides the LiveView channel and is
# re-checked against writability in the view's event handlers.
declaration(
:public_read,
"anonymous visitor or signed-in person",
"forge:repository:web",
false
)
issue_browser_path?(path) and verb in [:get, :head] ->
# Reading issues is public on a public repository. The GET mutates
# nothing: every write rides the LiveView channel and is re-checked
# against writability in the view's event handlers.
declaration(
:public_read,
"anonymous visitor or signed-in person",
"forge:issues:web",
false
)
pull_request_browser_path?(path) and verb in [:get, :head] ->
declaration(
:public_read,
"anonymous visitor or signed-in person",
"forge:pull-requests:web",
false
)
repository_browser_path?(path) and verb in [:get, :head] ->
declaration(
:public_read,
"anonymous or active repository member session",
"forge:repository:web",
false
)
true ->
%{class: :unclassified, principal: nil, scope: nil, mutation: mutation_verb?(path)}
end
end
defp declaration(class, principal, scope, mutation) do
%{class: class, principal: principal, scope: scope, mutation: mutation}
end
defp browser_scope("/api/tokens" <> _path), do: "api-token:self"
defp browser_scope("/api/computers" <> _path), do: "computer:self"
defp browser_scope("/api/computer-agent-jobs/" <> _path), do: "computer-job:self"
defp browser_scope("/voice/" <> _path), do: "voice:self"
defp browser_scope("/data" <> _path), do: "data:self"
defp browser_scope("/artifact-catalog"), do: "artifact-catalog:read"
defp browser_scope("/timeline"), do: "timeline:self"
defp browser_scope("/memory/" <> _path), do: "memory:self"
defp browser_scope("/github/connection"), do: "github-tools:self"
defp browser_scope("/notifications"), do: "notification:self"
defp browser_scope("/settings/api-tokens"), do: "api-token:self"
defp browser_scope("/models"), do: "model-catalog:read"
defp browser_scope(_path), do: "product:self"
defp browser_mutation?(path, :get),
do: path in ["/sarah", "/memory", "/computers", "/settings/api-tokens"]
defp browser_mutation?(_path, _verb), do: true
defp tracker_browser_path?(path) do
String.match?(path, ~r{\A/:owner/:repo/(issues/new|assignees|members)\z})
end
defp tracker_read_browser_path?(path) do
path in [
"/:owner/:repo/labels",
"/:owner/:repo/milestones",
"/:owner/:repo/projects",
"/:owner/:repo/projects/:number"
]
end
# The issue index and detail pages live in their own public-read session;
# `issues/new` above stays behind sign-in because filing needs an author.
defp issue_browser_path?(path) do
path == "/:owner/:repo/issues" or
String.match?(path, ~r{\A/:owner/:repo/issues/:number\z})
end
defp pull_request_browser_path?(path) do
path == "/:owner/:repo/pulls" or
String.match?(path, ~r{\A/:owner/:repo/pulls/:number\z})
end
defp repository_browser_path?(path) do
path == "/:owner/:repo" or
String.starts_with?(path, [
"/:owner/:repo/commit/",
"/:owner/:repo/tree/",
"/:owner/:repo/blob/"
])
end
defp mutation_verb?(_path), do: true
end