lib/openagents/deployments/authority.ex

58e6347eeb72 · 11 KB

defmodule OpenAgents.Deployments.Authority do
  @moduledoc """
  Fail-closed authorization for every deployment operation.

  Authority is decided from the principal and the durable records it names,
  never from request parameters. Four rules hold throughout:

    * Repository boundary. A grant or membership for one repository authorizes
      nothing in another, so cross-repository reads, approvals, cancellations,
      and check publications are refused before any work happens.
    * Environment boundary. A grant issued for `preview` cannot address
      `production`, even inside its own repository.
    * A workflow grant cannot widen. Its repository, environment, source ref,
      source workflow, and workflow run are compared against the bound values.
    * Platform-operator authority is not tenant authority. An operator can
      recover a stuck control-plane run, but cannot request, approve, or publish
      checks on a tenant repository, and never receives tenant secrets.

  Sensitive transitions recheck authority rather than trusting the decision made
  when the request was created: membership is revoked and policy tightens
  between `requested` and `deploying`.
  """

  alias OpenAgents.Accounts
  alias OpenAgents.Accounts.User
  alias OpenAgents.Deployments.Environment
  alias OpenAgents.Deployments.Principal
  alias OpenAgents.Deployments.Protection
  alias OpenAgents.Deployments.Request
  alias OpenAgents.Deployments.Run
  alias OpenAgents.Deployments.WorkflowGrant
  alias OpenAgents.Repositories
  alias OpenAgents.Repositories.Repository

  @type reason ::
          :cross_repository
          | :cross_environment
          | :not_a_member
          | :not_writable
          | :not_an_approver
          | :self_approval
          | :grant_expired
          | :grant_scope
          | :source_ref_mismatch
          | :source_workflow_mismatch
          | :workflow_run_mismatch
          | :not_an_operator
          | :operator_is_not_tenant
  @type result :: :ok | {:error, {:forbidden, reason()}}

  @doc """
  Authorize reading a repository's deployment records.

  A public repository is readable by anyone who can read the repository, which
  is what visibility already means. Everything else requires membership, a grant
  bound to that repository, or platform-operator authority.
  """
  @spec authorize_read(Principal.t(), Repository.t()) :: result()
  def authorize_read(%Principal{kind: :system}, %Repository{}), do: :ok

  def authorize_read(%Principal{kind: :operator} = principal, %Repository{}),
    do: operator_check(principal)

  def authorize_read(%Principal{kind: :workflow} = principal, %Repository{} = repository),
    do: grant_repository_check(principal, repository)

  def authorize_read(%Principal{kind: :user, user: %User{} = user}, %Repository{} = repository) do
    if repository.visibility == "public" or Repositories.member?(repository, user) do
      :ok
    else
      forbidden(:not_a_member)
    end
  end

  @doc """
  Authorize creating a deployment request for one environment.

  A human needs write authority on the repository. A workflow needs a live grant
  bound to this repository and environment, carrying `deployments:request`, whose
  source ref, workflow, and workflow run match the intent it is recording. A
  platform operator is refused: fleet promotion is a separate operator API, and
  tenant deployment is not an operator power.
  """
  @spec authorize_request(Principal.t(), Repository.t(), Environment.t(), map()) :: result()
  def authorize_request(
        %Principal{kind: :user, user: %User{} = user},
        repository,
        _environment,
        _intent
      ) do
    if Repositories.writable?(repository, user), do: :ok, else: forbidden(:not_writable)
  end

  def authorize_request(%Principal{kind: :workflow} = principal, repository, environment, intent) do
    with :ok <- grant_repository_check(principal, repository),
         :ok <- grant_environment_check(principal, environment),
         :ok <- grant_live_check(principal),
         :ok <- grant_scope_check(principal, "deployments:request") do
      grant_intent_check(principal, intent)
    end
  end

  def authorize_request(%Principal{kind: :operator}, _repository, _environment, _intent),
    do: forbidden(:operator_is_not_tenant)

  def authorize_request(%Principal{kind: :system}, _repository, _environment, _intent),
    do: forbidden(:operator_is_not_tenant)

  @doc """
  Authorize publishing a check result from a trusted workflow identity.

  Only a workflow grant carrying `deployments:checks`, or a human with write
  authority, can publish. The bytes a check claims to have examined are the
  caller's to state, but the repository they belong to is not.
  """
  @spec authorize_publish_check(Principal.t(), Repository.t(), map()) :: result()
  def authorize_publish_check(%Principal{kind: :user, user: %User{} = user}, repository, _intent) do
    if Repositories.writable?(repository, user), do: :ok, else: forbidden(:not_writable)
  end

  def authorize_publish_check(%Principal{kind: :workflow} = principal, repository, intent) do
    with :ok <- grant_repository_check(principal, repository),
         :ok <- grant_live_check(principal),
         :ok <- grant_scope_check(principal, "deployments:checks") do
      grant_intent_check(principal, intent)
    end
  end

  def authorize_publish_check(%Principal{}, _repository, _intent),
    do: forbidden(:operator_is_not_tenant)

  @doc """
  Authorize an approval decision on one run.

  The approver must hold a repository role the environment admits, and must not
  be the principal that requested the deployment when the environment enforces
  separation of duties. Workflow grants cannot approve at all: a workflow that
  could approve its own deployment turns a two-party control into a one-party
  one.
  """
  @spec authorize_approval(Principal.t(), Repository.t(), Environment.t(), Request.t()) ::
          result()
  def authorize_approval(
        %Principal{kind: :user, user: %User{} = user},
        %Repository{} = repository,
        %Environment{} = environment,
        %Request{} = request
      ) do
    protection = environment.protection || %Protection{}
    role = Repositories.membership_role(repository, user)

    cond do
      is_nil(role) -> forbidden(:not_a_member)
      role not in protection.approver_roles -> forbidden(:not_an_approver)
      self_approval?(protection, request, user) -> forbidden(:self_approval)
      true -> :ok
    end
  end

  def authorize_approval(%Principal{}, _repository, _environment, _request),
    do: forbidden(:operator_is_not_tenant)

  @doc """
  Authorize cancelling a run.

  A human with write authority may cancel their repository's run. A workflow may
  cancel only a run its own grant created, so one workflow cannot cancel
  another's deployment. Cancellation is a request, not an immediate stop: the
  worker observes it and reaches a terminal state itself.
  """
  @spec authorize_cancel(Principal.t(), Repository.t(), Run.t(), Request.t()) :: result()
  def authorize_cancel(%Principal{kind: :user, user: %User{} = user}, repository, _run, _request) do
    if Repositories.writable?(repository, user), do: :ok, else: forbidden(:not_writable)
  end

  def authorize_cancel(
        %Principal{kind: :workflow} = principal,
        repository,
        _run,
        %Request{} = request
      ) do
    with :ok <- grant_repository_check(principal, repository),
         :ok <- grant_live_check(principal),
         :ok <- grant_scope_check(principal, "deployments:request") do
      if request.requested_by_grant_id == principal.grant.id do
        :ok
      else
        forbidden(:cross_repository)
      end
    end
  end

  def authorize_cancel(%Principal{kind: :operator} = principal, _repository, _run, _request),
    do: operator_check(principal)

  def authorize_cancel(%Principal{kind: :system}, _repository, _run, _request), do: :ok

  @doc """
  Authorize control-plane recovery of a stuck run.

  Recovery reconciles the control plane's own bookkeeping — expired leases,
  runs abandoned by a crashed worker — and never resolves a tenant secret or
  reports a provider outcome the provider did not report.
  """
  @spec authorize_recovery(Principal.t()) :: result()
  def authorize_recovery(%Principal{kind: :system}), do: :ok
  def authorize_recovery(%Principal{kind: :operator} = principal), do: operator_check(principal)
  def authorize_recovery(%Principal{}), do: forbidden(:not_an_operator)

  @doc "Whether this repository record belongs to this durable record's repository."
  @spec same_repository?(Repository.t(), map()) :: boolean()
  def same_repository?(%Repository{id: id}, %{repository_id: repository_id}),
    do: id == repository_id

  @doc """
  Confirm a durable record belongs to the repository the caller addressed.

  Every read path calls this after loading by id, so a valid id from another
  repository is a `cross_repository` denial rather than a successful read.
  """
  @spec check_repository(Repository.t(), map()) :: result()
  def check_repository(%Repository{} = repository, record) do
    if same_repository?(repository, record), do: :ok, else: forbidden(:cross_repository)
  end

  defp self_approval?(%Protection{separation_of_duties: false}, _request, _user), do: false

  defp self_approval?(%Protection{}, %Request{requested_by_user_id: nil}, _user), do: false

  defp self_approval?(%Protection{}, %Request{} = request, %User{id: user_id}),
    do: request.requested_by_user_id == user_id

  defp operator_check(%Principal{user: user}) do
    if Accounts.admin?(user), do: :ok, else: forbidden(:not_an_operator)
  end

  defp grant_repository_check(%Principal{grant: %WorkflowGrant{} = grant}, %Repository{} = repo) do
    if grant.repository_id == repo.id, do: :ok, else: forbidden(:cross_repository)
  end

  defp grant_repository_check(%Principal{}, %Repository{}), do: forbidden(:cross_repository)

  # A grant with no environment can address any environment in its repository;
  # a grant bound to one environment can address only that one.
  defp grant_environment_check(
         %Principal{grant: %WorkflowGrant{environment_id: nil}},
         %Environment{}
       ),
       do: :ok

  defp grant_environment_check(%Principal{grant: %WorkflowGrant{} = grant}, %Environment{} = env) do
    if grant.environment_id == env.id, do: :ok, else: forbidden(:cross_environment)
  end

  defp grant_live_check(%Principal{grant: %WorkflowGrant{} = grant}) do
    if WorkflowGrant.usable?(grant, DateTime.utc_now()), do: :ok, else: forbidden(:grant_expired)
  end

  defp grant_scope_check(%Principal{grant: %WorkflowGrant{} = grant}, scope) do
    if scope in grant.scopes, do: :ok, else: forbidden(:grant_scope)
  end

  # The intent a workflow records must match the grant it holds. Otherwise a
  # grant issued for a pull-request branch could deploy a tag, and a grant
  # issued for one workflow run could publish evidence for another.
  defp grant_intent_check(%Principal{grant: %WorkflowGrant{} = grant}, intent) do
    cond do
      mismatch?(intent, :source_ref, grant.source_ref) ->
        forbidden(:source_ref_mismatch)

      mismatch?(intent, :source_workflow, grant.source_workflow) ->
        forbidden(:source_workflow_mismatch)

      mismatch?(intent, :workflow_run_id, grant.workflow_run_id) ->
        forbidden(:workflow_run_mismatch)

      true ->
        :ok
    end
  end

  defp mismatch?(intent, key, bound) do
    case Map.get(intent, key) do
      nil -> false
      value -> value != bound
    end
  end

  defp forbidden(reason), do: {:error, {:forbidden, reason}}
end