Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions lib/loopctl/tenants.ex
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,64 @@ defmodule Loopctl.Tenants do
{pub, priv}
end

@doc """
Generate an initial audit signing keypair for a legacy tenant that
doesn't have one yet. Refuses to overwrite an existing key — use
`rotate_audit_key/2` for that.

Returns `{:ok, updated_tenant}` or `{:error, reason}`.
"""
@spec bootstrap_audit_key(Ecto.UUID.t()) :: {:ok, Tenant.t()} | {:error, term()}
def bootstrap_audit_key(tenant_id) do
case get_tenant(tenant_id) do
{:error, _} = err ->
err

{:ok, %{audit_signing_public_key: existing}} when not is_nil(existing) ->
{:error, :key_already_exists}

{:ok, tenant} ->
do_bootstrap_audit_key(tenant)
end
end

defp do_bootstrap_audit_key(tenant) do
{pub, priv} = generate_ed25519_keypair()
secret_name = Secrets.audit_key_secret_name(tenant.slug)

multi =
Multi.new()
|> Multi.run(:store_secret, fn _repo, _changes ->
case Secrets.set(secret_name, priv) do
:ok -> {:ok, :stored}
{:error, reason} -> {:error, {:audit_key_storage_failed, reason}}
end
end)
|> Multi.update(:set_public_key, fn _ ->
Ecto.Changeset.change(tenant, audit_signing_public_key: pub)
end)
|> Audit.log_in_multi(:audit_bootstrap, fn %{set_public_key: updated} ->
%{
tenant_id: updated.id,
entity_type: "tenant",
entity_id: updated.id,
action: "audit_key_bootstrapped",
actor_type: "superadmin",
actor_id: nil,
actor_label: "superadmin:bootstrap",
new_state: %{
"audit_signing_public_key" => Base.encode64(pub),
"reason" => "Legacy tenant — no key existed prior to Chain of Custody v2"
}
}
end)

case AdminRepo.transaction(multi) do
{:ok, %{set_public_key: tenant}} -> {:ok, tenant}
{:error, _step, reason, _changes} -> {:error, reason}
end
end

@doc """
Rotate the tenant's audit signing key. Requires a WebAuthn assertion
to authorize. Stores the old public key in `tenant_audit_key_history`,
Expand Down
55 changes: 55 additions & 0 deletions lib/loopctl_web/controllers/admin_tenant_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,61 @@ defmodule LoopctlWeb.AdminTenantController do

defp ceil_div(numerator, denominator), do: ceil(numerator / denominator)

@doc """
POST /api/v1/admin/tenants/:id/bootstrap-audit-key

Generates the initial ed25519 audit keypair for a legacy tenant that
predates the Chain of Custody v2 signup ceremony. Refuses to run if
the tenant already has a key — use rotate-audit-key for that.
"""
def bootstrap_audit_key(conn, %{"id" => id}) do
case Tenants.bootstrap_audit_key(id) do
{:ok, tenant} ->
api_key = conn.assigns.current_api_key

Loopctl.AuditChain.append(tenant.id, %{
action: "audit_key_bootstrapped",
actor_lineage: [],
entity_type: "tenant",
entity_id: tenant.id,
payload: %{"bootstrapped_by" => api_key.id}
})

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

@doc "POST /api/v1/admin/tenants/:id/clear-halt — clears custody halt (break-glass, requires WebAuthn)"
def clear_halt(conn, %{"id" => id} = params) do
# G7: Break-glass requires WebAuthn assertion
Expand Down
3 changes: 3 additions & 0 deletions lib/loopctl_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -362,5 +362,8 @@ 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
Loading