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
This commit is contained in:
2026-09-10 03:15:53 +08:00
parent e23be309f8
commit d70aa9e541
9 changed files with 1720 additions and 2 deletions
+273
View File
@@ -0,0 +1,273 @@
defmodule BearCli.Admin do
@moduledoc """
`bear jwks`/`bear accounts`/`bear audit-logs` 管理端指令群的共用層
(docs/commands.md §3.8–§3.10、issue #12)。
`parse/3` 由 `BearCli.CLI` 的 fallback 分派呼叫,負責解析三個資源群組的
子指令與選項並做本地用法驗證;`context/1`/`fail/3`/`format_api_error/3`
為各群組模組共用的背景與錯誤處理。
"""
alias BearCli.{Accounts, AuditLogs, Config, Credentials, Jwks}
@jwks_verbs ~w(list show create toggle)
@accounts_verbs ~w(list show create update set-password delete)
@audit_logs_verbs ~w(list)
@jwks_create_switches [kid: :string, alg: :string]
@accounts_list_switches [page: :integer, per_page: :integer]
@accounts_create_switches [email: :string, role: :string, generate_password: :boolean]
@accounts_update_switches [role: :string]
@accounts_set_password_switches [generate_password: :boolean]
@audit_logs_list_switches [page: :integer, per_page: :integer, category: :string]
@algs ~w(RS256 RS384 RS512 ES256 ES384 ES512)
@roles ~w(user admin)
@global_switches [
issuer: :string,
config: :string,
json: :boolean,
verbose: :boolean,
help: :boolean,
version: :boolean
]
# -- 解析(非管理端指令回 :error,交回 CLI 報未知指令)--
@doc """
解析 `jwks`/`accounts`/`audit-logs` 群組的 argv,回傳 `{:ok, {module, verb}, opts}`
或 `{:error, message, code}`;非本層負責的指令回 `:error`。
"""
def parse(["jwks" | rest], global), do: parse_group("jwks", @jwks_verbs, rest, global)
def parse(["accounts" | rest], global),
do: parse_group("accounts", @accounts_verbs, rest, global)
def parse(["audit-logs" | rest], global),
do: parse_group("audit-logs", @audit_logs_verbs, rest, global)
def parse(_, _), do: :error
defp parse_group(group, verbs, rest, global) do
case rest do
[verb | args] ->
if verb in verbs do
switches = group_switches(group, verb)
case OptionParser.parse(args, strict: @global_switches ++ switches) do
{opts, positionals, []} ->
validate(group, verb, positionals, merge_opts(global, opts_to_map(opts)))
{_opts, _extra, [{opt, _} | _]} ->
{:error, "無法解析選項:#{opt}", 2}
end
else
{:error, "未知的 #{group} 子指令:#{verb}(可用:#{Enum.join(verbs, " ")})", 2}
end
[] ->
{:error, "#{group} 缺少子指令(可用:#{Enum.join(verbs, " ")})", 2}
end
end
defp group_switches("jwks", "list"), do: []
defp group_switches("jwks", "show"), do: []
defp group_switches("jwks", "create"), do: @jwks_create_switches
defp group_switches("jwks", "toggle"), do: []
defp group_switches("accounts", "list"), do: @accounts_list_switches
defp group_switches("accounts", "show"), do: []
defp group_switches("accounts", "create"), do: @accounts_create_switches
defp group_switches("accounts", "update"), do: @accounts_update_switches
defp group_switches("accounts", "set-password"), do: @accounts_set_password_switches
defp group_switches("accounts", "delete"), do: []
defp group_switches("audit-logs", "list"), do: @audit_logs_list_switches
# keyword 轉 map(本層無 :keep 選項,直接 Map.new)。
defp opts_to_map(opts), do: Map.new(opts)
defp merge_opts(global, opts), do: Map.merge(Map.new(global), opts)
# -- 本地用法驗證(用法錯誤 → 退出碼 2)--
# jwks
defp validate("jwks", "list", [], opts), do: {:ok, {Jwks, :list}, opts}
defp validate("jwks", "list", extra, _opts), do: positional_error("jwks list", extra, 0)
defp validate("jwks", "show", [kid], opts),
do: {:ok, {Jwks, :show}, Map.put(opts, :kid_arg, kid)}
defp validate("jwks", "show", [], _opts), do: {:error, "jwks show 缺少 <kid>", 2}
defp validate("jwks", "show", extra, _opts), do: positional_error("jwks show", extra, 1)
defp validate("jwks", "create", [], opts) do
cond do
blank?(opts[:kid]) ->
{:error, "jwks create 缺少必選參數:--kid", 2}
opts[:alg] && opts[:alg] not in @algs ->
{:error, "--alg 不接受的值:#{opts[:alg]}(可用:#{Enum.join(@algs, "、")})", 2}
true ->
{:ok, {Jwks, :create}, opts}
end
end
defp validate("jwks", "create", extra, _opts), do: positional_error("jwks create", extra, 0)
defp validate("jwks", "toggle", [kid], opts),
do: {:ok, {Jwks, :toggle}, Map.put(opts, :kid_arg, kid)}
defp validate("jwks", "toggle", [], _opts), do: {:error, "jwks toggle 缺少 <kid>", 2}
defp validate("jwks", "toggle", extra, _opts), do: positional_error("jwks toggle", extra, 1)
# accounts
defp validate("accounts", "list", [], opts), do: {:ok, {Accounts, :list}, opts}
defp validate("accounts", "list", extra, _opts), do: positional_error("accounts list", extra, 0)
defp validate("accounts", "show", [email], opts),
do: {:ok, {Accounts, :show}, Map.put(opts, :email_arg, email)}
defp validate("accounts", "show", [], _opts), do: {:error, "accounts show 缺少 <email>", 2}
defp validate("accounts", "show", extra, _opts), do: positional_error("accounts show", extra, 1)
defp validate("accounts", "create", [], opts) do
cond do
blank?(opts[:email]) ->
{:error, "accounts create 缺少必選參數:--email", 2}
opts[:role] && opts[:role] not in @roles ->
{:error, "--role 不接受的值:#{opts[:role]}(可用:#{Enum.join(@roles, "、")})", 2}
true ->
{:ok, {Accounts, :create}, opts}
end
end
defp validate("accounts", "create", extra, _opts),
do: positional_error("accounts create", extra, 0)
defp validate("accounts", "update", [email], opts) do
if blank?(opts[:role]) or opts[:role] not in @roles do
{:error, "accounts update 必須給 --role user|admin", 2}
else
{:ok, {Accounts, :update}, Map.put(opts, :email_arg, email)}
end
end
defp validate("accounts", "update", [], _opts), do: {:error, "accounts update 缺少 <email>", 2}
defp validate("accounts", "update", extra, _opts),
do: positional_error("accounts update", extra, 1)
defp validate("accounts", "set-password", [email], opts),
do: {:ok, {Accounts, :"set-password"}, Map.put(opts, :email_arg, email)}
defp validate("accounts", "set-password", [], _opts),
do: {:error, "accounts set-password 缺少 <email>", 2}
defp validate("accounts", "set-password", extra, _opts),
do: positional_error("accounts set-password", extra, 1)
defp validate("accounts", "delete", [email], opts),
do: {:ok, {Accounts, :delete}, Map.put(opts, :email_arg, email)}
defp validate("accounts", "delete", [], _opts), do: {:error, "accounts delete 缺少 <email>", 2}
defp validate("accounts", "delete", extra, _opts),
do: positional_error("accounts delete", extra, 1)
# audit-logs
defp validate("audit-logs", "list", [], opts), do: {:ok, {AuditLogs, :list}, opts}
defp validate("audit-logs", "list", extra, _opts),
do: positional_error("audit-logs list", extra, 0)
defp positional_error(command, extra, _max) do
{:error, "#{command} 不接受位置參數:#{Enum.join(extra, " ")}", 2}
end
# -- 共用背景(token/issuer;三個群組模組共用)--
@doc "解析 token/issuer 背景,回傳 {:ok, ctx} 或 {:error, message, code}。"
def context(opts) do
if token = Config.env_token() do
# BEAR_TOKEN 優先於憑證檔(不讀檔、不寫檔)。
{:ok, %{token: token, issuer: resolve_issuer(opts, nil)}}
else
case Credentials.load() do
{:ok, creds} ->
token = creds["access_token"]
if blank?(token) do
{:error, "憑證檔缺少 access_token,請重新 bear login", 7}
else
{:ok, %{token: token, issuer: resolve_issuer(opts, creds["issuer"])}}
end
:error ->
{:error, "未登入(請先執行 bear login)", 3}
end
end
end
defp resolve_issuer(opts, stored_issuer) do
opts[:issuer] || stored_issuer || Config.resolve_issuer(nil, opts[:config])
end
# -- 共用錯誤處理 --
@doc "輸出錯誤(--json 走 stdout JSON、人類模式走 stderr),回傳退出碼。"
def fail(opts, message, code) do
if opts[:json] do
IO.puts(Jason.encode!(%{ok: false, error: message, code: code}))
else
IO.puts(:stderr, "錯誤:#{message}")
end
code
end
@doc "API 錯誤 → 訊息與退出碼(管理端共通對應)。"
def format_api_error(_ctx, {:error, :unauthorized, desc}) do
{"token 無效或已過期(#{desc}),請重新 bear login", 3}
end
def format_api_error(_ctx, {:error, :forbidden, _desc}) do
{"此操作需 admin 權限(HTTP 403)", 8}
end
def format_api_error(_ctx, {:error, :not_found, _desc}) do
{"找不到目標資源(HTTP 404)", 1}
end
def format_api_error(_ctx, {:error, :unprocessable_entity, body}) do
details =
body
|> Map.get("errors", %{})
|> Enum.map(fn {field, messages} ->
"#{field}: #{Enum.join(List.wrap(messages), "、")}"
end)
|> Enum.join(";")
if details == "" do
{"驗證失敗(HTTP 422)", 1}
else
{"驗證失敗(HTTP 422):#{details}", 1}
end
end
def format_api_error(_ctx, {:error, :server_error, status}) do
{"伺服器回應 #{status}", 6}
end
def format_api_error(ctx, {:error, :network, reason}) do
{"無法連線到 #{ctx.issuer}:#{reason}", 6}
end
defp blank?(nil), do: true
defp blank?(""), do: true
defp blank?(_), do: false
end