From a7bbf983347a57b2e8d1f1f08d2a622c82883829 Mon Sep 17 00:00:00 2001 From: Mark Kreyman Date: Sun, 12 Apr 2026 22:17:55 -0600 Subject: [PATCH] Move bootstrap-audit-key to user-accessible endpoint with ownership check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The superadmin-only admin endpoint was too restrictive — tenant operators need to bootstrap their own key without a superadmin key. Moved to POST /api/v1/tenants/:id/bootstrap-audit-key alongside the existing rotate endpoint, with user role + ownership check. Removed the now-redundant admin endpoint. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tenant_audit_key_controller.ex | 63 ++++++++++++++++++- lib/loopctl_web/router.ex | 4 +- 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/lib/loopctl_web/controllers/tenant_audit_key_controller.ex b/lib/loopctl_web/controllers/tenant_audit_key_controller.ex index e6368b0..a244d61 100644 --- a/lib/loopctl_web/controllers/tenant_audit_key_controller.ex +++ b/lib/loopctl_web/controllers/tenant_audit_key_controller.ex @@ -11,8 +11,8 @@ defmodule LoopctlWeb.TenantAuditKeyController do alias Loopctl.Tenants alias Loopctl.WebAuthn - # Rotation requires user role — agents must not rotate keys - plug LoopctlWeb.Plugs.RequireRole, [role: :user] when action in [:rotate] + # Key management requires user role — agents must not rotate/bootstrap keys + plug LoopctlWeb.Plugs.RequireRole, [role: :user] when action in [:rotate, :bootstrap] @doc """ GET /api/v1/tenants/:id/audit_public_key @@ -160,6 +160,65 @@ defmodule LoopctlWeb.TenantAuditKeyController do ) end + @doc """ + POST /api/v1/tenants/:id/bootstrap-audit-key + + Generates the initial ed25519 audit keypair for a legacy tenant that + predates the Chain of Custody v2 signup ceremony. Caller must own + the target tenant. Refuses if a key already exists. + """ + def bootstrap(conn, %{"id" => tenant_id}) do + caller_tenant_id = + case conn.assigns do + %{current_api_key: %{tenant_id: tid}} -> tid + _ -> nil + end + + if caller_tenant_id != tenant_id do + conn + |> put_status(:forbidden) + |> json(%{error: %{message: "Forbidden", status: 403}}) + else + do_bootstrap(conn, tenant_id) + end + end + + defp do_bootstrap(conn, tenant_id) do + case Tenants.bootstrap_audit_key(tenant_id) do + {:ok, tenant} -> + json(conn, %{ + data: %{ + tenant_id: tenant.id, + audit_signing_public_key: Base.encode64(tenant.audit_signing_public_key), + message: "Audit keypair generated. Trust layers L1, L2, L5, L6 are now active." + } + }) + + {:error, :not_found} -> + conn |> put_status(:not_found) |> json(%{error: %{message: "Not found", status: 404}}) + + {:error, :key_already_exists} -> + conn + |> put_status(:conflict) + |> json(%{ + error: %{ + message: "Tenant already has an audit key. Use rotate-audit-key instead.", + status: 409 + } + }) + + {:error, {:audit_key_storage_failed, _reason}} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: %{message: "Failed to store the audit key. Please retry.", status: 500}}) + + {:error, _reason} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: %{message: "Key bootstrap failed", status: 500}}) + end + end + # Encode ed25519 public key as SubjectPublicKeyInfo DER wrapped in PEM. # OID for Ed25519: 1.3.101.112 → {0x06, 0x03, 0x2B, 0x65, 0x70} # SubjectPublicKeyInfo ::= SEQUENCE { algorithm AlgorithmIdentifier, subjectPublicKey BIT STRING } diff --git a/lib/loopctl_web/router.ex b/lib/loopctl_web/router.ex index 4498b0f..37c1cb9 100644 --- a/lib/loopctl_web/router.ex +++ b/lib/loopctl_web/router.ex @@ -111,6 +111,7 @@ defmodule LoopctlWeb.Router do get "/tenants/me", TenantController, :show patch "/tenants/me", TenantController, :update post "/tenants/:id/rotate-audit-key", TenantAuditKeyController, :rotate + post "/tenants/:id/bootstrap-audit-key", TenantAuditKeyController, :bootstrap # US-26.2.1 — Dispatch lineage resources "/dispatches", DispatchController, only: [:create, :show, :index] @@ -362,8 +363,5 @@ defmodule LoopctlWeb.Router do # US-26.5.2 — Custody halt management post "/tenants/:id/clear-halt", AdminTenantController, :clear_halt - - # Legacy tenant audit key bootstrap - post "/tenants/:id/bootstrap-audit-key", AdminTenantController, :bootstrap_audit_key end end