lib/openagents_web/route_authority.ex

main at 58e6347eeb72 · 25 KB

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