mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 10:08:02 +08:00
Add a config set-app-secret command so users and agents can rotate a profile's app secret after resetting it on the open platform, instead of hacking the OS keychain (which fails because the stored value lives in a closed secure store). The command reads the new value from stdin, previews the target and exits 10 without --yes, verifies the value via FetchTAT before writing, stores it through core.ForStorage (the same primitive as config init and config bind), and leaves other profiles untouched. It also redirects the invalid_client hint to the new command and adds an optional target field to the error envelope so the affected bot is named without exposing the secret.
491 lines
18 KiB
Go
491 lines
18 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package errclass
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
)
|
|
|
|
// ClassifyContext is the contextual data BuildAPIError uses to populate
|
|
// identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL).
|
|
// Identity is a plain string ("user" / "bot" / "") so this package does not
|
|
// depend on internal/core (which would create an import cycle).
|
|
type ClassifyContext struct {
|
|
Brand string // "feishu" | "lark" — drives console_url host
|
|
AppID string // placed in console_url
|
|
Identity string // "user" / "bot" / "" — caller converts core.Identity at the boundary
|
|
LarkCmd string // e.g. "drive +delete" — used as Action fallback on CategoryConfirmation arm
|
|
}
|
|
|
|
// BuildAPIError consumes a parsed Lark API response and returns a typed error.
|
|
// Returns nil when resp is nil or resp["code"] is 0.
|
|
//
|
|
// Routing by Category:
|
|
//
|
|
// Authorization → *errs.PermissionError (with MissingScopes / Identity / ConsoleURL)
|
|
// Authentication → *errs.AuthenticationError
|
|
// Config → *errs.ConfigError
|
|
// Policy → *errs.SecurityPolicyError
|
|
// Validation → *errs.ValidationError
|
|
// Network → *errs.NetworkError
|
|
// Internal → *errs.InternalError
|
|
// Confirmation → *errs.ConfirmationRequiredError
|
|
// default (CategoryAPI) → *errs.APIError (catch-all for classified Lark business errors)
|
|
//
|
|
// Unknown Lark codes (LookupCodeMeta returns false) fall back to
|
|
// CategoryAPI + SubtypeUnknown.
|
|
func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
|
|
if resp == nil {
|
|
return nil
|
|
}
|
|
code := intFromAny(resp["code"])
|
|
if code == 0 {
|
|
return nil
|
|
}
|
|
msg, _ := resp["msg"].(string)
|
|
if msg == "" {
|
|
// Upstream omitted or sent non-string msg. Keep Problem.Message non-empty
|
|
// so the typed wire envelope still carries a human-readable signal.
|
|
msg = fmt.Sprintf("API error: [%d]", code)
|
|
}
|
|
// Lark API responses sometimes carry log_id at the top level
|
|
// ({"code":..., "log_id":"..."}) and sometimes nested under "error"
|
|
// ({"code":..., "error":{"log_id":"..."}}). Prefer top level and fall
|
|
// back to the nested location so log_id always surfaces on the typed
|
|
// envelope.
|
|
logID, _ := resp["log_id"].(string)
|
|
if logID == "" {
|
|
if errBlock, ok := resp["error"].(map[string]any); ok {
|
|
if nested, ok := errBlock["log_id"].(string); ok {
|
|
logID = nested
|
|
}
|
|
}
|
|
}
|
|
|
|
meta, ok := LookupCodeMeta(code)
|
|
if !ok {
|
|
meta = CodeMeta{Category: errs.CategoryAPI, Subtype: errs.SubtypeUnknown}
|
|
}
|
|
|
|
base := errs.Problem{
|
|
Category: meta.Category,
|
|
Subtype: meta.Subtype,
|
|
Code: code,
|
|
Message: msg,
|
|
LogID: logID,
|
|
Retryable: meta.Retryable,
|
|
}
|
|
// Upstream-provided diagnostic URL (resp.error.troubleshooter). Lifted
|
|
// universally before the category switch so every classified typed
|
|
// error surfaces it when present. The remaining contents of resp["error"]
|
|
// (permission_violations.subject, data.challenge_url, data.hint) are
|
|
// either lifted into category-specific typed extension fields below or
|
|
// intentionally dropped as redundant with the typed envelope.
|
|
if errBlock, ok := resp["error"].(map[string]any); ok {
|
|
if ts, _ := errBlock["troubleshooter"].(string); ts != "" {
|
|
base.Troubleshooter = ts
|
|
}
|
|
}
|
|
// Upstream-provided field-level reasons (resp.error.details[].value). Lark
|
|
// returns these as free-text reason strings with no machine-readable field
|
|
// name (verified for code 190014:
|
|
// {"error":{"details":[{"value":"end_time should be later than start_time"}]}}),
|
|
// so they are lifted into Problem.Hint — the sanctioned free-text recovery
|
|
// prompt — rather than fabricated structured params. Lifted before the
|
|
// category switch so any classified arm inherits it; the CategoryAPI arm
|
|
// below prefers this server detail over the context-free APIHint default.
|
|
detailHint := liftErrorDetailValues(resp)
|
|
if detailHint != "" {
|
|
base.Hint = detailHint
|
|
}
|
|
|
|
switch meta.Category {
|
|
case errs.CategoryAuthorization:
|
|
return buildPermissionError(base, resp, cc)
|
|
case errs.CategoryAuthentication:
|
|
return &errs.AuthenticationError{Problem: base}
|
|
case errs.CategoryConfig:
|
|
return buildConfigError(base)
|
|
case errs.CategoryPolicy:
|
|
return buildSecurityPolicyError(base, resp)
|
|
case errs.CategoryValidation:
|
|
return &errs.ValidationError{Problem: base}
|
|
case errs.CategoryNetwork:
|
|
return &errs.NetworkError{Problem: base}
|
|
case errs.CategoryInternal:
|
|
return &errs.InternalError{Problem: base}
|
|
case errs.CategoryConfirmation:
|
|
// Risk + Action are non-omitempty wire fields. Derive from
|
|
// CodeMeta when available; otherwise emit RiskUnknown +
|
|
// ctx.LarkCmd placeholder so the envelope is never wire-invalid.
|
|
risk := meta.Risk
|
|
if risk == "" {
|
|
risk = errs.RiskUnknown
|
|
}
|
|
action := meta.Action
|
|
if action == "" {
|
|
action = cc.LarkCmd
|
|
}
|
|
if action == "" {
|
|
action = "unknown"
|
|
}
|
|
return &errs.ConfirmationRequiredError{
|
|
Problem: base,
|
|
Risk: risk,
|
|
Action: action,
|
|
}
|
|
case errs.CategoryAPI:
|
|
// A server-supplied detail (lifted into base.Hint above) wins over the
|
|
// context-free APIHint default; only fall back to APIHint when absent.
|
|
if base.Hint == "" {
|
|
base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
|
|
}
|
|
return &errs.APIError{Problem: base}
|
|
default:
|
|
// Fail closed: an unrecognized Category routes to InternalError
|
|
// instead of emitting an empty Problem on the wire.
|
|
return &errs.InternalError{
|
|
Problem: errs.Problem{
|
|
Category: errs.CategoryInternal,
|
|
Subtype: errs.SubtypeSDKError,
|
|
Code: base.Code,
|
|
Message: fmt.Sprintf("unrecognized Category %q for code %d", base.Category, base.Code),
|
|
LogID: base.LogID,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
// buildSecurityPolicyError extracts challenge_url and the hint from a Lark API
|
|
// response's data block, so the typed SecurityPolicyError carries the same
|
|
// browser-challenge information that internal/auth/transport.go surfaces at
|
|
// the HTTP layer.
|
|
//
|
|
// Data shapes accepted (whichever the upstream sends):
|
|
//
|
|
// {"code": 21000, "msg": "...", "data": {"challenge_url": "...", "hint"|"cli_hint": "..."}}
|
|
// {"code": 21000, "error": {"data": {"challenge_url": "...", "hint"|"cli_hint": "..."}}}
|
|
//
|
|
// challenge_url is dropped (set to "") if it is not an https:// URL — same
|
|
// validation policy as internal/auth/transport.go.isValidChallengeURL.
|
|
// Hint is read from `data.hint` first and falls back to `data.cli_hint` so
|
|
// either spelling surfaces, matching the transport layer.
|
|
func buildSecurityPolicyError(p errs.Problem, resp map[string]any) *errs.SecurityPolicyError {
|
|
dataMap, _ := resp["data"].(map[string]any)
|
|
if dataMap == nil {
|
|
if errBlock, ok := resp["error"].(map[string]any); ok {
|
|
dataMap, _ = errBlock["data"].(map[string]any)
|
|
}
|
|
}
|
|
if dataMap == nil {
|
|
return &errs.SecurityPolicyError{Problem: p}
|
|
}
|
|
|
|
challengeURL := strings.Trim(stringFromAny(dataMap["challenge_url"]), " `")
|
|
if challengeURL != "" && !isHTTPSURL(challengeURL) {
|
|
challengeURL = ""
|
|
}
|
|
|
|
hint := stringFromAny(dataMap["hint"])
|
|
if hint == "" {
|
|
hint = stringFromAny(dataMap["cli_hint"])
|
|
}
|
|
if hint != "" {
|
|
p.Hint = hint
|
|
}
|
|
|
|
return &errs.SecurityPolicyError{
|
|
Problem: p,
|
|
ChallengeURL: challengeURL,
|
|
}
|
|
}
|
|
|
|
// isHTTPSURL is the local-to-errclass duplicate of internal/auth/transport.go's
|
|
// isValidChallengeURL. Kept local to avoid coupling errclass to internal/auth;
|
|
// the two collapse once the auth transport adopts BuildAPIError directly.
|
|
func isHTTPSURL(rawURL string) bool {
|
|
if rawURL == "" {
|
|
return false
|
|
}
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return u.Scheme == "https"
|
|
}
|
|
|
|
// stringFromAny coerces a map value to string when it is a string, returning "" otherwise.
|
|
func stringFromAny(v any) string {
|
|
s, _ := v.(string)
|
|
return s
|
|
}
|
|
|
|
// buildConfigError enriches a typed ConfigError with the canonical
|
|
// per-subtype recovery hint before returning it, so the wire envelope
|
|
// emitted via BuildAPIError always carries a hint for known config subtypes.
|
|
func buildConfigError(p errs.Problem) *errs.ConfigError {
|
|
// Config categories have authoritative recovery guidance, so the curated
|
|
// ConfigHint deliberately overrides any server detail lifted into p.Hint
|
|
// (the opposite precedence from the CategoryAPI arm, where the lifted
|
|
// detail wins).
|
|
p.Hint = ConfigHint(p.Subtype)
|
|
return &errs.ConfigError{Problem: p}
|
|
}
|
|
|
|
// ConfigHint returns the canonical per-subtype recovery hint for a typed
|
|
// ConfigError emitted via BuildAPIError.
|
|
func ConfigHint(subtype errs.Subtype) string {
|
|
switch subtype {
|
|
case errs.SubtypeInvalidClient:
|
|
return "app secret invalid — rotate it: lark-cli config set-app-secret --app-secret-stdin (provide the new secret via stdin; previews the target first, then re-run with --profile <app_id> --yes to apply)"
|
|
case errs.SubtypeNotConfigured:
|
|
return "run `lark-cli config init` to set up app_id and app_secret"
|
|
case errs.SubtypeInvalidConfig:
|
|
return "check the config file for syntax errors; rerun `lark-cli config init` to reset"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// APIHint returns the canonical per-subtype recovery hint for a typed APIError
|
|
// emitted via BuildAPIError, for API subtypes whose recovery is context-free.
|
|
// Context-specific guidance (e.g. a command's flags, an API's own quota) is
|
|
// layered on by the caller after BuildAPIError returns and overrides this.
|
|
func APIHint(subtype errs.Subtype) string {
|
|
switch subtype {
|
|
case errs.SubtypeConflict:
|
|
return "retry later and avoid concurrent duplicate requests on the same resource"
|
|
case errs.SubtypeCrossTenant:
|
|
return "operate on source and target within the same tenant and region/unit"
|
|
case errs.SubtypeCrossBrand:
|
|
return "operate on source and target within the same brand environment"
|
|
case errs.SubtypeQuotaExceeded:
|
|
return "reduce the request volume or free quota, then retry after the relevant quota resets"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContext) *errs.PermissionError {
|
|
missing := extractMissingScopes(resp)
|
|
identity := cc.Identity
|
|
if identity == "" {
|
|
identity = "user"
|
|
}
|
|
consoleURL := ConsoleURL(cc.Brand, cc.AppID, missing)
|
|
p.Message = CanonicalPermissionMessage(p.Subtype, cc.AppID, missing, p.Message)
|
|
// Permission categories have authoritative recovery guidance (scopes to
|
|
// grant, console URL), so the curated PermissionHint deliberately overrides
|
|
// any server detail lifted into p.Hint (the opposite precedence from the
|
|
// CategoryAPI arm, where the lifted detail wins).
|
|
p.Hint = PermissionHint(missing, identity, p.Subtype, consoleURL)
|
|
permErr := &errs.PermissionError{
|
|
Problem: p,
|
|
MissingScopes: missing,
|
|
Identity: identity,
|
|
}
|
|
// ConsoleURL is the developer-console deep-link an app developer follows to
|
|
// apply for a missing scope. That action only resolves SubtypeAppScopeNotApplied,
|
|
// which is bot-perspective. The other authorization subtypes route to a
|
|
// different actor: SubtypeMissingScope / SubtypeTokenScopeInsufficient /
|
|
// SubtypeUserUnauthorized recover via `lark-cli auth login`; SubtypeAppUnavailable
|
|
// / SubtypeAppDisabled require tenant admin. Carrying ConsoleURL on those
|
|
// envelopes is dead weight and risks pointing an end user at a console they
|
|
// cannot modify; the URL is still computed so the hint composer can use it
|
|
// where appropriate.
|
|
if p.Subtype == errs.SubtypeAppScopeNotApplied {
|
|
permErr.ConsoleURL = consoleURL
|
|
}
|
|
return permErr
|
|
}
|
|
|
|
// CanonicalPermissionMessage returns the CLI-side canonical wording for a
|
|
// typed PermissionError, preserving the Lark official-API phrasing
|
|
// ("access denied" / "unauthorized" / "token has no permission") and
|
|
// enhancing it with CLI context (app ID, missing scope list). Subtypes
|
|
// outside the known set fall through to fallback so the upstream message
|
|
// is preserved.
|
|
func CanonicalPermissionMessage(subtype errs.Subtype, appID string, missing []string, fallback string) string {
|
|
switch subtype {
|
|
case errs.SubtypeAppScopeNotApplied:
|
|
if len(missing) > 0 {
|
|
scopes := strings.Join(missing, ", ")
|
|
if appID != "" {
|
|
return fmt.Sprintf("access denied: app %s has not applied for the required scope(s): %s", appID, scopes)
|
|
}
|
|
return fmt.Sprintf("access denied: app has not applied for the required scope(s): %s", scopes)
|
|
}
|
|
if appID != "" {
|
|
return fmt.Sprintf("access denied: app %s has not applied for the required scope(s)", appID)
|
|
}
|
|
return "access denied: app has not applied for the required scope(s)"
|
|
case errs.SubtypeMissingScope:
|
|
if len(missing) > 0 {
|
|
return fmt.Sprintf("unauthorized: user authorization does not cover the required scope(s): %s", strings.Join(missing, ", "))
|
|
}
|
|
return "unauthorized: user authorization does not cover the required scope"
|
|
case errs.SubtypeTokenScopeInsufficient:
|
|
return "token has no permission for this operation; required scope is missing"
|
|
case errs.SubtypeUserUnauthorized:
|
|
return "access denied for this operation; possible causes: missing scope, missing user authorization, or restricted by tenant policy"
|
|
case errs.SubtypeAppUnavailable:
|
|
if appID != "" {
|
|
return fmt.Sprintf("unauthorized app: app %s is not properly installed in this tenant", appID)
|
|
}
|
|
return "unauthorized app: app is not properly installed in this tenant"
|
|
case errs.SubtypeAppDisabled:
|
|
if appID != "" {
|
|
return fmt.Sprintf("app %s is not in use in this tenant (currently disabled)", appID)
|
|
}
|
|
return "app is not in use in this tenant (currently disabled)"
|
|
case errs.SubtypePermissionDenied:
|
|
return "user lacks permission for the requested resource"
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// PermissionHint returns the canonical per-subtype recovery hint for a typed
|
|
// PermissionError. The hint distinguishes authorization subtypes routing
|
|
// to different recovery paths: developer console for app_scope_not_applied,
|
|
// user re-login for missing_scope / token_scope_insufficient / user_unauthorized,
|
|
// and tenant admin for app_unavailable / app_disabled. The subtype
|
|
// argument is the primary discriminator; identity is retained for the
|
|
// generic permission_denied fallback so callers that do not yet route on
|
|
// subtype still get a sensible hint.
|
|
//
|
|
// Exported so direct construction sites (cmd/service/service.go's
|
|
// checkServiceScopes) can produce hints that match the dispatcher path
|
|
// byte-for-byte instead of hand-rolling divergent strings.
|
|
func PermissionHint(missing []string, identity string, subtype errs.Subtype, consoleURL string) string {
|
|
switch subtype {
|
|
case errs.SubtypeAppScopeNotApplied:
|
|
if consoleURL != "" {
|
|
return fmt.Sprintf("the app developer must apply for the required scope(s) at the developer console: %s", consoleURL)
|
|
}
|
|
return "the app developer must apply for the required scope(s) at the developer console"
|
|
case errs.SubtypeMissingScope:
|
|
if len(missing) > 0 {
|
|
return fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to re-authorize the user with the updated scope set", strings.Join(missing, " "))
|
|
}
|
|
return "run `lark-cli auth login` to re-authorize the user with the updated scope set"
|
|
case errs.SubtypeTokenScopeInsufficient:
|
|
return "check the token's granted scopes; run `lark-cli auth login` to refresh if the scope was added after the token was issued"
|
|
case errs.SubtypeUserUnauthorized:
|
|
return "run `lark-cli auth login` to re-authorize this user; if re-auth does not help, the operation may be blocked by external-chat or admin policy"
|
|
case errs.SubtypeAppUnavailable:
|
|
return "ask the tenant admin to check the app's install status in the Lark admin console"
|
|
case errs.SubtypeAppDisabled:
|
|
return "ask the tenant admin to re-enable the app in the Lark admin console"
|
|
case errs.SubtypePermissionDenied:
|
|
who := "this user"
|
|
if identity == "bot" {
|
|
who = "this bot"
|
|
}
|
|
return fmt.Sprintf("check the resource owner has granted access to %s", who)
|
|
}
|
|
return "check the calling identity has the required scope"
|
|
}
|
|
|
|
// liftErrorDetailValues collects the non-empty resp.error.details[].value reason
|
|
// strings and joins them with "; ". Returns "" when the structure is absent or
|
|
// carries no non-empty value. The shape (verified for code 190014) is
|
|
// {"error":{"details":[{"value":"<reason>"}]}}.
|
|
func liftErrorDetailValues(resp map[string]any) string {
|
|
errBlock, ok := resp["error"].(map[string]any)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
details, ok := errBlock["details"].([]any)
|
|
if !ok || len(details) == 0 {
|
|
return ""
|
|
}
|
|
var values []string
|
|
for _, d := range details {
|
|
m, ok := d.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if v, _ := m["value"].(string); v != "" {
|
|
values = append(values, v)
|
|
}
|
|
}
|
|
return strings.Join(values, "; ")
|
|
}
|
|
|
|
// extractMissingScopes walks resp["error"]["permission_violations"][].subject.
|
|
// Returns nil when the structure is absent.
|
|
func extractMissingScopes(resp map[string]any) []string {
|
|
errBlock, ok := resp["error"].(map[string]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
raw, ok := errBlock["permission_violations"].([]any)
|
|
if !ok || len(raw) == 0 {
|
|
return nil
|
|
}
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
for _, v := range raw {
|
|
m, ok := v.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
s, _ := m["subject"].(string)
|
|
if s == "" || seen[s] {
|
|
continue
|
|
}
|
|
seen[s] = true
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ConsoleURL composes the Feishu/Lark open-platform scope-grant console URL,
|
|
// suitable for PermissionError.ConsoleURL. Empty appID → empty string. Empty
|
|
// scopes list returns the bare /auth landing page; scopes are joined with
|
|
// commas in the `q` query parameter so the console can pre-select them.
|
|
//
|
|
// brand is "feishu" or "lark"; unknown values default to feishu.
|
|
func ConsoleURL(brand, appID string, scopes []string) string {
|
|
if appID == "" {
|
|
return ""
|
|
}
|
|
host := "open.feishu.cn"
|
|
if brand == "lark" {
|
|
host = "open.larksuite.com"
|
|
}
|
|
// PathEscape on appID — it sits in the URL path. QueryEscape on the
|
|
// comma-joined scopes — they sit in the `?q=` value, and untrusted scope
|
|
// content must not be able to inject extra query parameters via `&`/`#`.
|
|
pathID := url.PathEscape(appID)
|
|
if len(scopes) == 0 {
|
|
return fmt.Sprintf("https://%s/app/%s/auth", host, pathID)
|
|
}
|
|
return fmt.Sprintf("https://%s/app/%s/auth?q=%s", host, pathID, url.QueryEscape(strings.Join(scopes, ",")))
|
|
}
|
|
|
|
func intFromAny(v any) int {
|
|
switch n := v.(type) {
|
|
case int:
|
|
return n
|
|
case int64:
|
|
return int(n)
|
|
case float64:
|
|
return int(n)
|
|
case json.Number:
|
|
i, err := n.Int64()
|
|
if err == nil {
|
|
return int(i)
|
|
}
|
|
f, err := n.Float64()
|
|
if err == nil {
|
|
return int(f)
|
|
}
|
|
}
|
|
return 0
|
|
}
|