Files
bear-cli/lib/bear_cli/admin.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

274 lines
9.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.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