diff --git a/internal/app/runner.go b/internal/app/runner.go index 16388b97..6ad92fa7 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -88,6 +88,9 @@ const ( envDingtalkTraceID = "DINGTALK_TRACE_ID" envDingtalkSessionID = "DINGTALK_SESSION_ID" envDingtalkMessageID = "DINGTALK_MESSAGE_ID" + + // Environment variables for third-party channel integration + envDWSChannel = "DWS_CHANNEL" ) func newCommandRunnerWithFlags(loader cli.CatalogLoader, flags *GlobalFlags) executor.Runner { @@ -541,6 +544,12 @@ func resolveIdentityHeaders() map[string]string { headers[k] = v } } + + // Inject third-party channel headers + if v := os.Getenv(envDWSChannel); v != "" { + headers["x-dws-channel"] = v + } + if fn := edition.Get().MergeHeaders; fn != nil { headers = fn(headers) } diff --git a/internal/auth/cli_auth_check_test.go b/internal/auth/cli_auth_check_test.go index ca076908..73b0e33e 100644 --- a/internal/auth/cli_auth_check_test.go +++ b/internal/auth/cli_auth_check_test.go @@ -154,9 +154,7 @@ func TestCheckCLIAuthEnabled_TransientThenSuccess(t *testing.T) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(CLIAuthStatus{ Success: true, - Result: struct { - CLIAuthEnabled bool `json:"cliAuthEnabled"` - }{CLIAuthEnabled: true}, + Result: &CLIAuthResult{CLIAuthEnabled: true}, }) })) defer srv.Close() @@ -169,7 +167,7 @@ func TestCheckCLIAuthEnabled_TransientThenSuccess(t *testing.T) { if err != nil { t.Fatalf("expected success after transient failures, got error: %v", err) } - if !status.Success || !status.Result.CLIAuthEnabled { + if !status.Success || status.Result == nil || !status.Result.CLIAuthEnabled { t.Fatalf("expected CLIAuthEnabled=true, got %+v", status) } if c := calls.Load(); c != 3 { @@ -190,9 +188,7 @@ func TestCheckCLIAuthEnabled_Enabled(t *testing.T) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(CLIAuthStatus{ Success: true, - Result: struct { - CLIAuthEnabled bool `json:"cliAuthEnabled"` - }{CLIAuthEnabled: true}, + Result: &CLIAuthResult{CLIAuthEnabled: true}, }) })) defer srv.Close() @@ -204,7 +200,7 @@ func TestCheckCLIAuthEnabled_Enabled(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if !status.Result.CLIAuthEnabled { + if status.Result == nil || !status.Result.CLIAuthEnabled { t.Fatal("expected CLIAuthEnabled=true") } t.Logf("✅ Normal enabled response: success=%v, enabled=%v", status.Success, status.Result.CLIAuthEnabled) @@ -215,9 +211,7 @@ func TestCheckCLIAuthEnabled_Disabled(t *testing.T) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(CLIAuthStatus{ Success: true, - Result: struct { - CLIAuthEnabled bool `json:"cliAuthEnabled"` - }{CLIAuthEnabled: false}, + Result: &CLIAuthResult{CLIAuthEnabled: false}, }) })) defer srv.Close() @@ -229,7 +223,7 @@ func TestCheckCLIAuthEnabled_Disabled(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if status.Result.CLIAuthEnabled { + if status.Result == nil || status.Result.CLIAuthEnabled { t.Fatal("expected CLIAuthEnabled=false") } t.Logf("✅ Normal disabled response: success=%v, enabled=%v", status.Success, status.Result.CLIAuthEnabled) @@ -262,12 +256,10 @@ func TestOAuthCallback_CLIAuthEnabled_ShowsSuccessPage(t *testing.T) { var statusErr error authStatus := &CLIAuthStatus{ Success: true, - Result: struct { - CLIAuthEnabled bool `json:"cliAuthEnabled"` - }{CLIAuthEnabled: true}, + Result: &CLIAuthResult{CLIAuthEnabled: true}, } - cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled + cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result != nil && authStatus.Result.CLIAuthEnabled if !cliAuthEnabled { t.Fatal("cliAuthEnabled should be true when API returns enabled") } @@ -280,12 +272,10 @@ func TestOAuthCallback_CLIAuthDisabledByServer_ShowsNotEnabledPage(t *testing.T) var statusErr error authStatus := &CLIAuthStatus{ Success: true, - Result: struct { - CLIAuthEnabled bool `json:"cliAuthEnabled"` - }{CLIAuthEnabled: false}, + Result: &CLIAuthResult{CLIAuthEnabled: false}, } - cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled + cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result != nil && authStatus.Result.CLIAuthEnabled if cliAuthEnabled { t.Fatal("cliAuthEnabled should be false when server says disabled") } @@ -396,9 +386,7 @@ func TestDeviceFlow_LoginOnce_CLIAuthDisabled_ShowsError(t *testing.T) { case strings.HasSuffix(r.URL.Path, CLIAuthEnabledPath): json.NewEncoder(w).Encode(CLIAuthStatus{ Success: true, - Result: struct { - CLIAuthEnabled bool `json:"cliAuthEnabled"` - }{CLIAuthEnabled: false}, + Result: &CLIAuthResult{CLIAuthEnabled: false}, }) case strings.HasSuffix(r.URL.Path, SuperAdminPath): @@ -468,9 +456,7 @@ func TestDeviceFlow_LoginOnce_CLIAuthEnabled_Success(t *testing.T) { case strings.HasSuffix(r.URL.Path, CLIAuthEnabledPath): json.NewEncoder(w).Encode(CLIAuthStatus{ Success: true, - Result: struct { - CLIAuthEnabled bool `json:"cliAuthEnabled"` - }{CLIAuthEnabled: true}, + Result: &CLIAuthResult{CLIAuthEnabled: true}, }) default: diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go index 8b7a0d6c..c1c2fc0b 100644 --- a/internal/auth/device_flow.go +++ b/internal/auth/device_flow.go @@ -176,33 +176,61 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token _, _ = fmt.Fprintln(p.output(), i18n.T(" 请检查网络连接后重试。")) _, _ = fmt.Fprintln(p.output(), "") return nil, fmt.Errorf("%s: %w", i18n.T("检查 CLI 授权状态失败"), authErr) - } else if authStatus.Success && !authStatus.Result.CLIAuthEnabled { - // CLI auth is disabled - show detailed error with admin info - _, _ = fmt.Fprintln(p.output(), "") - _, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 该组织尚未开启 CLI 数据访问权限"))) - _, _ = fmt.Fprintln(p.output(), i18n.T(" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。")) + } + denialReason := classifyDenialReason(authStatus, os.Getenv("DWS_CHANNEL")) + if denialReason != "" { _, _ = fmt.Fprintln(p.output(), "") + switch denialReason { + case "user_forbidden": + _, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 该组织已禁止所有成员使用 CLI"))) + _, _ = fmt.Fprintln(p.output(), "") + return nil, errors.New(i18n.T("该组织已禁止所有成员使用 CLI")) + case "user_not_allowed": + _, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 您不在该组织的 CLI 授权人员范围内"))) + _, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织管理员将您加入 CLI 授权人员名单。")) + _, _ = fmt.Fprintln(p.output(), "") + return nil, errors.New(i18n.T("您不在该组织的 CLI 授权人员范围内,请联系组织管理员")) + case "channel_not_allowed": + ch := os.Getenv("DWS_CHANNEL") + _, _ = fmt.Fprintf(p.output(), dfRed(i18n.T("⚠️ 当前渠道 %s 未获得该组织授权"))+"\n", ch) + _, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织管理员开通该渠道的访问权限。")) + _, _ = fmt.Fprintln(p.output(), "") + return nil, fmt.Errorf(i18n.T("当前渠道 %s 未获得该组织授权,请联系组织管理员"), ch) + case "channel_required": + _, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 当前组织已开启渠道管控"))) + _, _ = fmt.Fprintln(p.output(), i18n.T(" 请升级到最新版本的 CLI 后重试。")) + _, _ = fmt.Fprintln(p.output(), "") + return nil, errors.New(i18n.T("当前组织已开启渠道管控,请升级到最新版本的 CLI 后重试")) + case "no_auth": + _, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 认证已失效"))) + _, _ = fmt.Fprintln(p.output(), i18n.T(" 请执行 dws auth 重新登录。")) + _, _ = fmt.Fprintln(p.output(), "") + return nil, errors.New(i18n.T("认证已失效,请执行 dws auth 重新登录")) + default: + // cli_not_enabled or unknown — show existing admin-apply flow + _, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 该组织尚未开启 CLI 数据访问权限"))) + _, _ = fmt.Fprintln(p.output(), i18n.T(" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。")) + _, _ = fmt.Fprintln(p.output(), "") - // Try to get super admin list - admins, adminErr := GetSuperAdmins(ctx, tokenData.AccessToken) - if adminErr == nil && admins.Success && len(admins.Result) > 0 { - // Show up to 3 admins - maxAdmins := 3 - if len(admins.Result) < maxAdmins { - maxAdmins = len(admins.Result) + admins, adminErr := GetSuperAdmins(ctx, tokenData.AccessToken) + if adminErr == nil && admins.Success && len(admins.Result) > 0 { + maxAdmins := 3 + if len(admins.Result) < maxAdmins { + maxAdmins = len(admins.Result) + } + var adminNames []string + for i := 0; i < maxAdmins; i++ { + adminNames = append(adminNames, admins.Result[i].Name) + } + _, _ = fmt.Fprintf(p.output(), " %s%s\n", i18n.T("组织主管理员:"), strings.Join(adminNames, "、")) } - var adminNames []string - for i := 0; i < maxAdmins; i++ { - adminNames = append(adminNames, admins.Result[i].Name) - } - _, _ = fmt.Fprintf(p.output(), " %s%s\n", i18n.T("组织主管理员:"), strings.Join(adminNames, "、")) + + _, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织主管理员开启后重新登录。")) + _, _ = fmt.Fprintln(p.output(), "") + _, _ = fmt.Fprintln(p.output(), i18n.T(" 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings")) + _, _ = fmt.Fprintln(p.output(), "") + return nil, errors.New(i18n.T("该组织尚未开启 CLI 数据访问权限,请联系管理员开启")) } - - _, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织主管理员开启后重新登录。")) - _, _ = fmt.Fprintln(p.output(), "") - _, _ = fmt.Fprintln(p.output(), i18n.T(" 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings")) - _, _ = fmt.Fprintln(p.output(), "") - return nil, errors.New(i18n.T("该组织尚未开启 CLI 数据访问权限,请联系管理员开启")) } // Save token data with associated client ID for refresh diff --git a/internal/auth/endpoints.go b/internal/auth/endpoints.go index 0014862d..bcc5500f 100644 --- a/internal/auth/endpoints.go +++ b/internal/auth/endpoints.go @@ -38,6 +38,11 @@ func init() { DefaultValue: "(内置)", Sensitive: true, }) + configmeta.Register(configmeta.ConfigItem{ + Name: "DWS_CHANNEL", + Category: configmeta.CategoryExternal, + Description: "第三方渠道编码 (channelCode),如 Qoderwork", + }) } const ( diff --git a/internal/auth/oauth_helpers.go b/internal/auth/oauth_helpers.go index 0a2fceab..3966bae2 100644 --- a/internal/auth/oauth_helpers.go +++ b/internal/auth/oauth_helpers.go @@ -21,6 +21,7 @@ import ( "io" "net/http" "net/url" + "os" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" @@ -919,14 +920,235 @@ const notEnabledHTML = ` ` +const accessDeniedHTML = ` + + + + + 钉钉 CLI + + + +
+ lock icon +

无权限访问

+

您不在该组织的 CLI 授权人员范围内。请联系组织管理员将您加入授权名单。此页面可以关闭。

+
+ +` + +const channelDeniedHTML = ` + + + + + 钉钉 CLI + + + +
+ lock icon +

渠道未授权

+

当前渠道未获得该组织授权,或组织已开启渠道管控。请联系组织管理员开通渠道访问权限,或升级到最新版本的 CLI。此页面可以关闭。

+
+ +` + // CLIAuthStatus represents the response from /cli/cliAuthEnabled API. type CLIAuthStatus struct { - Success bool `json:"success"` - ErrorCode string `json:"errorCode,omitempty"` - ErrorMsg string `json:"errorMsg,omitempty"` - Result struct { - CLIAuthEnabled bool `json:"cliAuthEnabled"` - } `json:"result"` + Success bool `json:"success"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorMsg string `json:"errorMsg,omitempty"` + Result *CLIAuthResult `json:"result"` +} + +// CLIAuthResult holds the business data returned by /cli/cliAuthEnabled. +// The server computes cliAuthEnabled by considering the org switch, userScope, +// and channelScope together; the CLI uses it as-is. +type CLIAuthResult struct { + CLIAuthEnabled bool `json:"cliAuthEnabled"` + UserScope string `json:"userScope,omitempty"` // "all" | "specified" | "forbidden" + AllowedUsers []string `json:"allowedUsers,omitempty"` // staffId list when userScope="specified" + ChannelScope string `json:"channelScope,omitempty"` // "all" | "specified" + AllowedChannels []string `json:"allowedChannels,omitempty"` // channelCode list when channelScope="specified" +} + +// classifyDenialReason inspects a CLIAuthStatus response and returns a machine-readable +// denial reason string. Returns "" when access is granted. +func classifyDenialReason(status *CLIAuthStatus, currentChannel string) string { + if status.ErrorCode == "CHANNEL_REQUIRED" { + return "channel_required" + } + if status.ErrorCode == "NO_AUTH" { + return "no_auth" + } + if status.Result == nil || !status.Success { + return "unknown" + } + if status.Result.CLIAuthEnabled { + return "" + } + // cliAuthEnabled=false — infer reason from auxiliary fields (same priority as server) + if status.Result.UserScope == "forbidden" { + return "user_forbidden" + } + if status.Result.UserScope == "specified" { + return "user_not_allowed" + } + if status.Result.ChannelScope == "specified" && currentChannel != "" { + return "channel_not_allowed" + } + return "cli_not_enabled" } // SuperAdmin represents a corp super admin. @@ -985,6 +1207,9 @@ func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken s return nil, fmt.Errorf("creating request: %w", err) } req.Header.Set("x-user-access-token", accessToken) + if ch := os.Getenv("DWS_CHANNEL"); ch != "" { + req.Header.Set("x-dws-channel", ch) + } client := p.httpClient if client == nil { diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index 071f9237..34112feb 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -124,6 +124,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro token *TokenData err error cliAuthDisabled bool + denialReason string } resultCh := make(chan callbackResult, 1) errCh := make(chan error, 1) @@ -236,19 +237,30 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro // Check CLI auth enabled status (fail-closed: treat errors as disabled) authStatus, statusErr := p.CheckCLIAuthEnabled(ctx, tokenData.AccessToken) - cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled + var denialReason string + if statusErr != nil { + denialReason = "unknown" + } else { + denialReason = classifyDenialReason(authStatus, os.Getenv("DWS_CHANNEL")) + } + cliAuthEnabled := denialReason == "" // Update CLI auth disabled state callbackTokenMu.Lock() callbackAuthDisabled = !cliAuthEnabled callbackTokenMu.Unlock() - // Display appropriate HTML based on CLI auth status + // Display appropriate HTML based on auth status and denial reason w.Header().Set("Content-Type", "text/html; charset=utf-8") - if !cliAuthEnabled { - _, _ = fmt.Fprint(w, notEnabledHTML) - } else { + switch { + case cliAuthEnabled: _, _ = fmt.Fprint(w, successHTML) + case denialReason == "user_forbidden" || denialReason == "user_not_allowed": + _, _ = fmt.Fprint(w, accessDeniedHTML) + case denialReason == "channel_not_allowed" || denialReason == "channel_required": + _, _ = fmt.Fprint(w, channelDeniedHTML) + default: + _, _ = fmt.Fprint(w, notEnabledHTML) } // Ensure response is flushed to client if f, ok := w.(http.Flusher); ok { @@ -256,7 +268,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro } // Notify main goroutine with full result select { - case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: !cliAuthEnabled}: + case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: !cliAuthEnabled, denialReason: denialReason}: default: } }) @@ -395,8 +407,18 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), result.err) } - // Handle CLI auth disabled - keep server running for user to apply + // Handle CLI auth disabled - for terminal denial reasons, exit immediately + // (page shows accessDeniedHTML/channelDeniedHTML with no apply button, + // so polling for apply submission would hang forever). + // Error messages are kept consistent with the text shown on the HTML pages. if result.cliAuthDisabled { + switch result.denialReason { + case "user_forbidden", "user_not_allowed": + return nil, errors.New(i18n.T("您不在该组织的 CLI 授权人员范围内,请联系组织管理员将您加入授权名单")) + case "channel_not_allowed", "channel_required": + return nil, errors.New(i18n.T("当前渠道未获得该组织授权,或组织已开启渠道管控,请联系组织管理员开通渠道访问权限,或升级到最新版本的 CLI")) + } + _, _ = fmt.Fprintln(p.output(), "") _, _ = fmt.Fprintln(p.output(), i18n.T("⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...")) @@ -435,7 +457,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro // Check if CLI auth is now enabled (admin approved) if currentToken != nil { authStatus, err := p.CheckCLIAuthEnabled(ctx, currentToken.AccessToken) - if err == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled { + if err == nil && classifyDenialReason(authStatus, os.Getenv("DWS_CHANNEL")) == "" { _, _ = fmt.Fprintf(p.output(), "\r%s\n", i18n.T("✅ 权限已开启,继续登录...")) time.Sleep(2 * time.Second) result.token = currentToken