- 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
274 lines
9.5 KiB
Elixir
274 lines
9.5 KiB
Elixir
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
|