Files
bear-cli/lib/bear_cli/jwks.ex
queena d70aa9e541 feat: 實作管理端指令群 jwks/accounts/audit-logs(issue #12)
- docs/commands.md 新增 §3.8 jwks、§3.9 accounts、§3.10 audit-logs 規格
- Api 新增 jwks/accounts/audit-logs 端點(共用 api_request)
- 新增 Jwks/Accounts/AuditLogs/Admin 模組;CLI 接線三個指令群
- 403 → 退出碼 8 提示需 admin;一次性密碼只在成功當下輸出
- 測試 100 例全綠(fake API 注入,比照 cli_test.exs);基於含 PR #15 的最新 main
2026-09-10 03:15:53 +08:00

199 lines
5.5 KiB
Elixir
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
defmodule BearCli.Jwks do
@moduledoc """
`bear jwks` 指令群(簽章金鑰管理;docs/commands.md §3.8、issue #12)。
對應伺服器端 JWK 管理 JSON API(alterminal/bear#46,`/api/v1/jwks`、
PAT Bearer、admin 限定)。認證沿用既有 PAT 憑證(`BEAR_TOKEN` 或憑證檔)。
金鑰材質(`key_data`)永不經 CLI 顯示(API 端即不序列化);本群組只管理
公開中繼資料(kid/kty/alg/active 等)。
退出碼:0 成功;1 404/422;2 用法錯誤;3 未登入或 401;6 網路/伺服器
錯誤;8 權限不足(403,僅限 admin)。
"""
import BearCli.Admin, only: [context: 1, fail: 3, format_api_error: 2]
alias BearCli.Api
# -- 入口 --
@doc """
執行 `bear jwks <動詞>`,回傳退出碼。`opts[:jwks_api]` 可注入假 API 模組
供測試(需實作 `jwks_list/2`、`jwks_create/3`、`jwks_get/3`、`jwks_toggle/3`)。
"""
def run(verb, opts) do
api = opts[:jwks_api] || Api
with {:ok, ctx} <- context(opts) do
execute(verb, ctx, opts, api)
else
{:error, message, code} -> fail(opts, message, code)
end
end
# -- 各動詞 --
defp execute(:list, ctx, opts, api) do
case api.jwks_list(ctx.issuer, ctx.token) do
{:ok, body} ->
render_list(opts, body["data"] || [])
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
end
defp execute(:show, ctx, opts, api) do
with {:ok, key} <- resolve_key(ctx, opts[:kid_arg], api) do
case api.jwks_get(ctx.issuer, ctx.token, key["id"]) do
{:ok, body} ->
show_key(body["data"] || %{}, opts)
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
else
{:error, message, code} -> fail(opts, message, code)
end
end
defp execute(:create, ctx, opts, api) do
attrs = %{"kid" => opts[:kid], "alg" => opts[:alg] || "RS256"}
case api.jwks_create(ctx.issuer, ctx.token, attrs) do
{:ok, body} ->
key = body["data"] || %{}
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, jwk: key}))
else
IO.puts("JWK 已建立:#{key["kid"]}(#{key["alg"]}/#{status_label(key)})")
end
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
end
defp execute(:toggle, ctx, opts, api) do
with {:ok, key} <- resolve_key(ctx, opts[:kid_arg], api) do
case api.jwks_toggle(ctx.issuer, ctx.token, key["id"]) do
{:ok, body} ->
updated = body["data"] || %{}
if opts[:json] do
IO.puts(
Jason.encode!(%{
ok: true,
jwk: %{id: updated["id"], kid: updated["kid"], active: updated["active"]}
})
)
else
IO.puts("#{key["kid"]}:#{status_label(key)} → #{status_label(updated)}")
end
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
else
{:error, message, code} -> fail(opts, message, code)
end
end
# -- kid → UUID 解析(API 路徑參數為 UUID,CLI 對外介面用 kid)--
defp resolve_key(_ctx, nil, _api), do: {:error, "缺少 <kid> 參數", 2}
defp resolve_key(ctx, kid, api) do
case api.jwks_list(ctx.issuer, ctx.token) do
{:ok, body} ->
case Enum.find(body["data"] || [], &(&1["kid"] == kid)) do
nil -> {:error, "找不到 kid 為 #{kid} 的金鑰", 1}
key -> {:ok, key}
end
{:error, _kind, _detail} = error ->
{message, code} = format_api_error(ctx, error)
{:error, message, code}
end
end
# -- 輸出 --
defp render_list(opts, keys) do
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, jwks: keys}))
else
print_table(keys)
end
end
@table_headers ["ID", "KID", "KTY", "ALG", "ACTIVE", "CREATED AT"]
defp print_table(keys) do
rows =
Enum.map(keys, fn key ->
[
String.slice(key["id"] || "", 0, 8),
key["kid"] || "",
key["kty"] || "",
key["alg"] || "",
status_label(key),
key["created_at"] || ""
]
end)
widths =
Enum.with_index(@table_headers, fn _header, i ->
Enum.max([
String.length(Enum.at(@table_headers, i))
| Enum.map(rows, &String.length(Enum.at(&1, i)))
])
end)
render_row = fn cells ->
cells
|> Enum.with_index()
|> Enum.map(fn {cell, i} -> String.pad_trailing(cell, Enum.at(widths, i)) end)
|> Enum.join(" ")
|> String.trim_trailing()
end
IO.puts(render_row.(@table_headers))
Enum.each(rows, fn cells -> IO.puts(render_row.(cells)) end)
end
@key_fields ~w(id kid kty alg use active created_at)
defp show_key(key, opts) do
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, jwk: key}))
else
Enum.each(@key_fields, fn field ->
IO.puts("#{String.pad_trailing(field, 14)}: #{format_key_value(key[field])}")
end)
end
end
defp format_key_value(nil), do: "(無)"
defp format_key_value(value) when is_binary(value), do: value
defp format_key_value(value), do: Jason.encode!(value)
defp status_label(%{"active" => true}), do: "active"
defp status_label(%{"active" => false}), do: "inactive"
defp status_label(_), do: "(未知)"
# -- 錯誤處理 --
defp fail_api(opts, ctx, error) do
{message, code} = format_api_error(ctx, error)
fail(opts, message, code)
end
end