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 缺少 ", 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 缺少 ", 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 缺少 ", 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 缺少 ", 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 缺少 ", 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 缺少 ", 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