From 13fc132052c71f6cd5471fd5bb1fdd27690ed4a9 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:00:16 +0700 Subject: [PATCH 1/2] feat(auth): redirect-only login mode with break-glass admin allowlist - /dashboard/login auto-redirects to OIDC when it is the only transport; suppressed on ?oidcError bounce (no loop), WxWork-only environments, and ?direct=1 - break-glass: auth.breakGlassEmails (env BREAK_GLASS_LOGIN_EMAILS) keeps password login reachable for allowlisted admin emails while password login is disabled suite-wide; matched by email only, so the seeded default-password account can never qualify; PublicConfig exposes breakGlassLoginEnabled so the form renders only when armed - JIT role mapping follows the suite standard: default cs_user, team LEAD claims grant cs_team_leader, org ADMIN/OWNER claims grant admin; role absence no longer defaults to admin - the default-password bootstrap admin is not seeded in SSO-only mode (oidc enabled + password login disabled); allowlisted admins bootstrap the first admin on OIDC login instead - support portal is never auto-redirected (it serves customer accounts) - tests: break-glass config helpers, allowlist matching, bootstrap seed gating in SSO-only and mixed modes --- .env.example | 4 + config/config.example.yaml | 5 ++ docker/agent-desk.supabase.example.yaml | 3 + docs/ARCHITECTURE.md | 15 ++++ internal/handlers/api/auth_handler.go | 17 ++-- internal/migration/000002_init_auth_data.go | 14 +++ ...0_repair_bootstrap_admin_user_type_test.go | 60 +++++++++++++ internal/pkg/config/auth_break_glass_test.go | 87 +++++++++++++++++++ internal/pkg/config/config.go | 49 +++++++++++ internal/pkg/dto/response/auth_response.go | 15 ++-- .../auth_break_glass_internal_test.go | 62 +++++++++++++ internal/services/auth_service.go | 26 ++++++ internal/services/oidc_login_service.go | 72 +++++++++++++-- .../support/login/_components/login-page.tsx | 14 ++- web/components/login-form.tsx | 46 +++++++++- web/lib/api/config.ts | 1 + web/messages/en-US.json | 1 + web/messages/vi-VN.json | 1 + web/messages/zh-CN.json | 1 + 19 files changed, 464 insertions(+), 29 deletions(-) create mode 100644 internal/pkg/config/auth_break_glass_test.go create mode 100644 internal/services/auth_break_glass_internal_test.go diff --git a/.env.example b/.env.example index 4e9d1af4..2b356256 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,10 @@ DATABASE_URL="host=aws-1-ap-southeast-1.pooler.supabase.com user=postgres.gulptw # Auth & Security PASSWORD_LOGIN_ENABLED=true +# OPTIONAL: Comma-separated admin emails that keep password login reachable +# via /dashboard/login?direct=1 while PASSWORD_LOGIN_ENABLED=false (SSO-only +# deployments). Leave empty to disable the escape hatch. +BREAK_GLASS_LOGIN_EMAILS= # AUTH_TOKEN_TTL_HOURS=12 # CUSTOMER_SESSION_SECRET=replace-with-a-random-secret-at-least-32-chars diff --git a/config/config.example.yaml b/config/config.example.yaml index 9a6722b4..85772b0a 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -69,6 +69,11 @@ auth: # Enable username and password login. When set to false, users must sign in with SSO (e.g. OIDC or WeCom). # Default is true. passwordLoginEnabled: true + # Comma-separated allowlist of admin emails that keep password login reachable + # via /dashboard/login?direct=1 while password login is disabled (SSO-only + # deployments). Regular users have no password path. Leave empty to disable + # the escape hatch. Env: BREAK_GLASS_LOGIN_EMAILS. + breakGlassEmails: "" # Login access token lifetime, in hours. Values <= 0 fall back to 12 hours. # Applies to password login, OIDC login, and WeCom login sessions. tokenTTLHours: 12 diff --git a/docker/agent-desk.supabase.example.yaml b/docker/agent-desk.supabase.example.yaml index b6a4409f..d09c38e0 100644 --- a/docker/agent-desk.supabase.example.yaml +++ b/docker/agent-desk.supabase.example.yaml @@ -27,6 +27,9 @@ logger: auth: passwordLoginEnabled: false + # Break-glass: allowlisted admin emails keep password login reachable via + # /dashboard/login?direct=1 while the OIDC provider is unreachable. + breakGlassEmails: "" tokenTTLHours: 12 maxFailedAttempts: 5 credentialLockMinute: 15 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 05ff6a21..97bac18f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -193,6 +193,21 @@ When a user logs in via DOS.Me OIDC, the `userinfo` claim supplies both organiza ``` * **Crove Desk Action**: Automatically ensures `t_organization`, provisions default/mapped `t_agent_team`, creates `t_user`, and guarantees 1-to-1 `t_agent_profile` association. +##### JIT Role Mapping (suite standard) + +Application roles are derived from claims, never defaulted to admin: + +| DOS ID claim | Crove Desk role | +| --- | --- | +| no role / `MEMBER` org claim | `cs_user` (support agent) | +| team claim role `LEAD` | `cs_team_leader` | +| organization claim role `ADMIN` / `OWNER` | `admin` | +| email on the break-glass allowlist (`auth.breakGlassEmails`) | `admin` (first-login bootstrap for fresh SSO-only deployments) | + +##### SSO-Only Mode & Break-Glass + +When `passwordLoginEnabled: false` and OIDC is the only staff transport, `/dashboard/login` auto-redirects to the provider. The redirect is suppressed when the provider bounced back with `?oidcError=` (prevents a loop), in WxWork-only environments, or via `?direct=1` with the break-glass allowlist configured (`auth.breakGlassEmails`, env `BREAK_GLASS_LOGIN_EMAILS`): allowlisted admin emails keep password login reachable for IdP outages. In this mode the default-password bootstrap admin (`admin` / `ChangeMe123!`) is not seeded, and its emailless account can never pass the email-only allowlist. The support portal is never auto-redirected (it serves customer accounts); note that customer portal sign-in shares the staff password endpoint, so in SSO-only mode only break-glass principals can password-sign in there. + #### Phase 2: Real-time Event-Driven Webhooks (`X-DOS-Signature: sha256=...`) When administrators create, update, or reorganize Teams/Projects in DOS.Me, webhook events are broadcast to member apps: ```json diff --git a/internal/handlers/api/auth_handler.go b/internal/handlers/api/auth_handler.go index 964b15a7..c81029e7 100644 --- a/internal/handlers/api/auth_handler.go +++ b/internal/handlers/api/auth_handler.go @@ -19,7 +19,7 @@ import ( func Login(ctx *gin.Context) { cfg := config.Current() - if !cfg.Auth.IsPasswordLoginEnabled() { + if !cfg.Auth.IsPasswordLoginEnabled() && !cfg.Auth.HasBreakGlassEmails() { httpx.WriteJSON(ctx, errorsx.ForbiddenI18n("error.auth.passwordLoginDisabled")) return } @@ -40,13 +40,14 @@ func Login(ctx *gin.Context) { func PublicConfig(ctx *gin.Context) { cfg := config.Current() httpx.WriteJSON(ctx, &response.PublicConfigResponse{ - Language: cfg.LanguageOrDefault(), - CompanyName: cfg.Server.CompanyName, - CompanyLogoURL: cfg.Server.CompanyLogoURL, - CompanyFaviconURL: cfg.Server.CompanyFaviconURL, - PasswordLoginEnabled: cfg.Auth.IsPasswordLoginEnabled(), - WxWorkEnabled: cfg.WxWork.Enabled, - OIDCEnabled: cfg.OIDC.Enabled, + Language: cfg.LanguageOrDefault(), + CompanyName: cfg.Server.CompanyName, + CompanyLogoURL: cfg.Server.CompanyLogoURL, + CompanyFaviconURL: cfg.Server.CompanyFaviconURL, + PasswordLoginEnabled: cfg.Auth.IsPasswordLoginEnabled(), + BreakGlassLoginEnabled: cfg.Auth.IsBreakGlassLoginEnabled(), + WxWorkEnabled: cfg.WxWork.Enabled, + OIDCEnabled: cfg.OIDC.Enabled, }) } diff --git a/internal/migration/000002_init_auth_data.go b/internal/migration/000002_init_auth_data.go index 59380ed9..0b474178 100644 --- a/internal/migration/000002_init_auth_data.go +++ b/internal/migration/000002_init_auth_data.go @@ -2,6 +2,7 @@ package migration import ( "agent-desk/internal/models" + "agent-desk/internal/pkg/config" "agent-desk/internal/pkg/constants" "agent-desk/internal/pkg/enums" "agent-desk/internal/repositories" @@ -184,6 +185,19 @@ func ensureBootstrapAdmin(tx *gorm.DB, superAdminRole *models.Role) error { return errors.New("super admin role not found") } + // In SSO-only mode (OIDC enabled, password login disabled) the known + // default-password account must not exist at all: it would be a standing + // backdoor the moment password login is ever re-enabled. Fresh SSO-only + // deployments bootstrap their first admin through the break-glass email + // allowlist (auth.breakGlassEmails) on first OIDC login instead. + cfg := config.Current() + if cfg.OIDC.Enabled && !cfg.Auth.IsPasswordLoginEnabled() { + slog.Info("skipping bootstrap admin seed: SSO-only login mode is active", + "oidc_enabled", true, + "password_login_enabled", false) + return nil + } + username := constants.BootstrapAdminUsername nickname := constants.BootstrapAdminNickname password := constants.BootstrapAdminPassword diff --git a/internal/migration/000010_repair_bootstrap_admin_user_type_test.go b/internal/migration/000010_repair_bootstrap_admin_user_type_test.go index f7f436bf..30d3e1b4 100644 --- a/internal/migration/000010_repair_bootstrap_admin_user_type_test.go +++ b/internal/migration/000010_repair_bootstrap_admin_user_type_test.go @@ -6,6 +6,7 @@ import ( "time" "agent-desk/internal/models" + "agent-desk/internal/pkg/config" "agent-desk/internal/pkg/constants" "agent-desk/internal/pkg/enums" "github.com/glebarez/sqlite" @@ -33,8 +34,16 @@ func setupBootstrapAdminTestDB(t *testing.T) *gorm.DB { return db } +func setTestConfig(t *testing.T, cfg *config.Config) { + t.Helper() + previous := config.GetCurrent() + config.SetCurrent(cfg) + t.Cleanup(func() { config.SetCurrent(previous) }) +} + func TestBootstrapAdminCreatedAsEmployee(t *testing.T) { db := setupBootstrapAdminTestDB(t) + setTestConfig(t, &config.Config{}) role := models.Role{Code: constants.RoleCodeSuperAdmin, Status: enums.StatusOk} if err := db.Create(&role).Error; err != nil { t.Fatal(err) @@ -58,6 +67,57 @@ func TestBootstrapAdminCreatedAsEmployee(t *testing.T) { } } +// SSO-only deployments (OIDC enabled, password login disabled) must not seed +// the known default-password bootstrap account: it is a standing backdoor the +// moment password login is ever re-enabled. +func TestBootstrapAdminSkippedInSSOOnlyMode(t *testing.T) { + db := setupBootstrapAdminTestDB(t) + passwordLoginEnabled := false + setTestConfig(t, &config.Config{ + OIDC: config.OIDCConfig{Enabled: true}, + Auth: config.AuthConfig{PasswordLoginEnabled: &passwordLoginEnabled}, + }) + role := models.Role{Code: constants.RoleCodeSuperAdmin, Status: enums.StatusOk} + if err := db.Create(&role).Error; err != nil { + t.Fatal(err) + } + if err := ensureBootstrapAdmin(db, &role); err != nil { + t.Fatal(err) + } + var count int64 + if err := db.Model(&models.User{}).Where("username = ?", constants.BootstrapAdminUsername).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("bootstrap admin seeded %d times in SSO-only mode, want 0", count) + } +} + +// When password login stays enabled the seed must keep running even with OIDC +// enabled (mixed deployments still need the local break-glass account). +func TestBootstrapAdminStillSeededWhenPasswordLoginEnabled(t *testing.T) { + db := setupBootstrapAdminTestDB(t) + passwordLoginEnabled := true + setTestConfig(t, &config.Config{ + OIDC: config.OIDCConfig{Enabled: true}, + Auth: config.AuthConfig{PasswordLoginEnabled: &passwordLoginEnabled}, + }) + role := models.Role{Code: constants.RoleCodeSuperAdmin, Status: enums.StatusOk} + if err := db.Create(&role).Error; err != nil { + t.Fatal(err) + } + if err := ensureBootstrapAdmin(db, &role); err != nil { + t.Fatal(err) + } + var count int64 + if err := db.Model(&models.User{}).Where("username = ?", constants.BootstrapAdminUsername).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("bootstrap admin seed count = %d, want 1", count) + } +} + func TestRepairBootstrapAdminUserType(t *testing.T) { repair, ok := migrationFuncs[10] if !ok { diff --git a/internal/pkg/config/auth_break_glass_test.go b/internal/pkg/config/auth_break_glass_test.go new file mode 100644 index 00000000..14c7b58f --- /dev/null +++ b/internal/pkg/config/auth_break_glass_test.go @@ -0,0 +1,87 @@ +package config + +import ( + "reflect" + "testing" +) + +func boolPtr(v bool) *bool { + return &v +} + +func TestAuthConfigBreakGlassEmailList(t *testing.T) { + cases := []struct { + name string + raw string + expected []string + }{ + {name: "empty", raw: "", expected: []string{}}, + {name: "blank only", raw: " , ,", expected: []string{}}, + {name: "trims and lowercases", raw: " Joy@Dos.AI , admin@crove.com ", expected: []string{"joy@dos.ai", "admin@crove.com"}}, + {name: "single email", raw: "joy@dos.ai", expected: []string{"joy@dos.ai"}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := AuthConfig{BreakGlassEmails: tc.raw} + + if got := cfg.BreakGlassEmailList(); !reflect.DeepEqual(got, tc.expected) { + t.Fatalf("BreakGlassEmailList(%q) = %v, want %v", tc.raw, got, tc.expected) + } + + if got := cfg.HasBreakGlassEmails(); got != (len(tc.expected) > 0) { + t.Fatalf("HasBreakGlassEmails(%q) = %v, want %v", tc.raw, got, len(tc.expected) > 0) + } + }) + } +} + +func TestAuthConfigIsBreakGlassEmail(t *testing.T) { + cfg := AuthConfig{BreakGlassEmails: "Joy@Dos.AI,ops@crove.com"} + + cases := []struct { + email string + expected bool + }{ + {email: "joy@dos.ai", expected: true}, + {email: " JOY@DOS.AI ", expected: true}, + {email: "OPS@crove.com", expected: true}, + {email: "someone@dos.ai", expected: false}, + {email: "", expected: false}, + {email: " ", expected: false}, + } + + for _, tc := range cases { + if got := cfg.IsBreakGlassEmail(tc.email); got != tc.expected { + t.Fatalf("IsBreakGlassEmail(%q) = %v, want %v", tc.email, got, tc.expected) + } + } +} + +func TestAuthConfigIsBreakGlassLoginEnabled(t *testing.T) { + allowlist := "joy@dos.ai" + + // Password login enabled: the door is irrelevant. + enabled := AuthConfig{PasswordLoginEnabled: boolPtr(true), BreakGlassEmails: allowlist} + if enabled.IsBreakGlassLoginEnabled() { + t.Fatal("break-glass must not be armed while password login is enabled") + } + + // Disabled with no allowlist: nothing is armed. + disabledNoList := AuthConfig{PasswordLoginEnabled: boolPtr(false)} + if disabledNoList.IsBreakGlassLoginEnabled() { + t.Fatal("break-glass must not be armed without an allowlist") + } + + // Disabled with an allowlist: the break-glass door is armed. + armed := AuthConfig{PasswordLoginEnabled: boolPtr(false), BreakGlassEmails: allowlist} + if !armed.IsBreakGlassLoginEnabled() { + t.Fatal("break-glass must be armed when password login is disabled and an allowlist exists") + } + + // Default (unset) password login stays enabled. + defaultCfg := AuthConfig{BreakGlassEmails: allowlist} + if defaultCfg.IsBreakGlassLoginEnabled() { + t.Fatal("unset passwordLoginEnabled must keep password login enabled") + } +} diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index d3549cae..c8336ff0 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -200,6 +200,11 @@ type AuthConfig struct { // is disabled. MaxFailedAttemptsPerIP int `yaml:"maxFailedAttemptsPerIP"` CredentialLockMinute int `yaml:"credentialLockMinute"` + // BreakGlassEmails is a comma-separated allowlist of admin emails that keep + // password login reachable via /dashboard/login?direct=1 while password + // login is disabled suite-wide (redirect-only OIDC deployments). Regular + // users have no password path. Leave empty to disable the escape hatch. + BreakGlassEmails string `yaml:"breakGlassEmails"` } // MaxFailedAttemptsPerIPOrDefault derives the per-address threshold from the @@ -222,6 +227,49 @@ func (a AuthConfig) IsPasswordLoginEnabled() bool { return *a.PasswordLoginEnabled } +// BreakGlassEmailList returns the parsed, normalised (trimmed + lowercased) +// break-glass allowlist. Empty entries are dropped. +func (a AuthConfig) BreakGlassEmailList() []string { + entries := strings.Split(a.BreakGlassEmails, ",") + emails := make([]string, 0, len(entries)) + + for _, entry := range entries { + if email := strings.ToLower(strings.TrimSpace(entry)); email != "" { + emails = append(emails, email) + } + } + + return emails +} + +func (a AuthConfig) HasBreakGlassEmails() bool { + return len(a.BreakGlassEmailList()) > 0 +} + +// IsBreakGlassEmail reports whether the given email (already normalised or +// not) is on the break-glass allowlist. +func (a AuthConfig) IsBreakGlassEmail(email string) bool { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return false + } + + for _, allowed := range a.BreakGlassEmailList() { + if allowed == email { + return true + } + } + + return false +} + +// IsBreakGlassLoginEnabled reports whether the break-glass door is armed: +// password login is disabled suite-wide but an admin allowlist exists, so +// allowlisted admins may still reach the password form via ?direct=1. +func (a AuthConfig) IsBreakGlassLoginEnabled() bool { + return !a.IsPasswordLoginEnabled() && a.HasBreakGlassEmails() +} + type CustomerSessionConfig struct { Secret string `yaml:"secret"` TTLMinutes int `yaml:"ttlMinutes"` @@ -615,6 +663,7 @@ func bindEnvironmentAliases(v *viper.Viper) { _ = v.BindEnv("db.type", "AGENT_DESK_DB_TYPE", "DATABASE_TYPE", "DB_TYPE") _ = v.BindEnv("db.dsn", "AGENT_DESK_DB_DSN", "DATABASE_URL", "DB_DSN") _ = v.BindEnv("auth.passwordLoginEnabled", "AGENT_DESK_AUTH_PASSWORDLOGINENABLED", "PASSWORD_LOGIN_ENABLED") + _ = v.BindEnv("auth.breakGlassEmails", "AGENT_DESK_AUTH_BREAKGLASSEMAILS", "BREAK_GLASS_LOGIN_EMAILS") _ = v.BindEnv("auth.tokenTTLHours", "AGENT_DESK_AUTH_TOKENTTLHOURS", "AUTH_TOKEN_TTL_HOURS") _ = v.BindEnv("customerSession.secret", "AGENT_DESK_CUSTOMERSESSION_SECRET", "CUSTOMER_SESSION_SECRET", "SESSION_SECRET", "JWT_SECRET") _ = v.BindEnv("storage.default", "AGENT_DESK_STORAGE_DEFAULT", "STORAGE_DEFAULT", "STORAGE_TYPE") diff --git a/internal/pkg/dto/response/auth_response.go b/internal/pkg/dto/response/auth_response.go index d3029035..d359d53d 100644 --- a/internal/pkg/dto/response/auth_response.go +++ b/internal/pkg/dto/response/auth_response.go @@ -22,11 +22,12 @@ type LoginResponse struct { } type PublicConfigResponse struct { - Language string `json:"language"` - CompanyName string `json:"companyName,omitempty"` - CompanyLogoURL string `json:"companyLogoUrl,omitempty"` - CompanyFaviconURL string `json:"companyFaviconUrl,omitempty"` - PasswordLoginEnabled bool `json:"passwordLoginEnabled"` - WxWorkEnabled bool `json:"wxworkEnabled"` - OIDCEnabled bool `json:"oidcEnabled"` + Language string `json:"language"` + CompanyName string `json:"companyName,omitempty"` + CompanyLogoURL string `json:"companyLogoUrl,omitempty"` + CompanyFaviconURL string `json:"companyFaviconUrl,omitempty"` + PasswordLoginEnabled bool `json:"passwordLoginEnabled"` + BreakGlassLoginEnabled bool `json:"breakGlassLoginEnabled"` + WxWorkEnabled bool `json:"wxworkEnabled"` + OIDCEnabled bool `json:"oidcEnabled"` } diff --git a/internal/services/auth_break_glass_internal_test.go b/internal/services/auth_break_glass_internal_test.go new file mode 100644 index 00000000..e8dd7c7b --- /dev/null +++ b/internal/services/auth_break_glass_internal_test.go @@ -0,0 +1,62 @@ +package services + +import ( + "testing" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" +) + +func TestIsBreakGlassUserMatchesAllowlistAgainstEmail(t *testing.T) { + email := "joy@dos.ai" + cfg := config.AuthConfig{BreakGlassEmails: "joy@dos.ai"} + + if !isBreakGlassUser(cfg, &models.User{Username: "joy", Email: &email}) { + t.Fatal("allowlisted email must pass the break-glass check") + } +} + +// The allowlist is email-only: usernames are never matched, so putting the +// seeded bootstrap admin's username on the list cannot reopen the known +// default-password account. +func TestIsBreakGlassUserNeverMatchesUsernames(t *testing.T) { + cfg := config.AuthConfig{BreakGlassEmails: "admin,ops-admin"} + + if isBreakGlassUser(cfg, &models.User{Username: "admin"}) { + t.Fatal("username-only match must not pass the break-glass check") + } + if isBreakGlassUser(cfg, &models.User{Username: "ops-admin"}) { + t.Fatal("username-only match must not pass the break-glass check") + } +} + +func TestIsBreakGlassUserRejectsNonAllowlistedAndNil(t *testing.T) { + email := "someone@dos.ai" + cfg := config.AuthConfig{BreakGlassEmails: "joy@dos.ai"} + + if isBreakGlassUser(cfg, nil) { + t.Fatal("nil user must not pass the break-glass check") + } + if isBreakGlassUser(cfg, &models.User{Username: "someone", Email: &email}) { + t.Fatal("non-allowlisted user must not pass the break-glass check") + } +} + +// The bootstrap admin is seeded without an email; the known default-password +// account must never qualify for the break-glass door. +func TestIsBreakGlassUserNeverMatchesEmaillessBootstrapAdmin(t *testing.T) { + cfg := config.AuthConfig{BreakGlassEmails: "joy@dos.ai"} + + if isBreakGlassUser(cfg, &models.User{Username: "admin"}) { + t.Fatal("the emailless bootstrap admin must not pass the break-glass check") + } +} + +func TestIsBreakGlassUserWithEmptyAllowlist(t *testing.T) { + email := "joy@dos.ai" + cfg := config.AuthConfig{} + + if isBreakGlassUser(cfg, &models.User{Username: "joy", Email: &email}) { + t.Fatal("empty allowlist must reject every user") + } +} diff --git a/internal/services/auth_service.go b/internal/services/auth_service.go index 4b259459..5a239e37 100644 --- a/internal/services/auth_service.go +++ b/internal/services/auth_service.go @@ -95,11 +95,26 @@ func (s *authService) Login(req request.LoginRequest, authCfg config.AuthConfig, return nil, errorsx.CredentialLockedI18n("error.e0270") } + passwordLoginEnabled := authCfg.IsPasswordLoginEnabled() + user := UserService.GetByUsername(username) + if user == nil && !passwordLoginEnabled { + // Break-glass door: in SSO-only mode an allowlisted admin signs in with + // the account email, which is not necessarily the username. + user = UserService.GetByEmail(strings.ToLower(username)) + } if user == nil || user.Status != enums.StatusOk { + if !passwordLoginEnabled { + _ = s.createLoginCredentialLog(principal, 0, false, clientIP, userAgent, "password login disabled") + return nil, errorsx.ForbiddenI18n("error.auth.passwordLoginDisabled") + } _ = s.createLoginCredentialLog(principal, 0, false, clientIP, userAgent, "user not found") return nil, errorsx.InvalidAccountI18n("error.e0260") } + if !passwordLoginEnabled && !isBreakGlassUser(authCfg, user) { + _ = s.createLoginCredentialLog(principal, user.ID, false, clientIP, userAgent, "password login disabled") + return nil, errorsx.ForbiddenI18n("error.auth.passwordLoginDisabled") + } if strs.IsBlank(user.Password) || bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil { _ = s.createLoginCredentialLog(principal, user.ID, false, clientIP, userAgent, "password mismatch") return nil, errorsx.InvalidAccountI18n("error.e0260") @@ -130,6 +145,17 @@ func (s *authService) Login(req request.LoginRequest, authCfg config.AuthConfig, return ret, nil } +// isBreakGlassUser reports whether the resolved user's email is on the +// break-glass allowlist (case-insensitively). The allowlist is email-only: +// usernames are never matched, so the emailless seeded bootstrap admin can +// never pass the check even if its username is put on the list. +func isBreakGlassUser(authCfg config.AuthConfig, user *models.User) bool { + if user == nil || user.Email == nil { + return false + } + return authCfg.IsBreakGlassEmail(*user.Email) +} + func (s *authService) Logout(accessToken string) error { accessToken = s.extractBearerToken(accessToken) now := time.Now() diff --git a/internal/services/oidc_login_service.go b/internal/services/oidc_login_service.go index d791c2c5..d0789ae4 100644 --- a/internal/services/oidc_login_service.go +++ b/internal/services/oidc_login_service.go @@ -113,6 +113,7 @@ func (s *oidcLoginService) loginWithOIDCProfile(profile *oidcLoginProfile, authC } s.ensureDefaultOIDCRole(ctx.Tx, user) + s.ensureBreakGlassAdminRole(ctx.Tx, authCfg, user, profile) s.syncOIDCUserOrganizations(ctx.Tx, user, profile) s.syncOIDCUserTeams(ctx.Tx, user, profile) _, _ = AgentProfileService.EnsureAgentProfileForUser(ctx.Tx, user) @@ -261,6 +262,10 @@ func shortSubjectHash(subject string) string { return hex.EncodeToString(sum[:])[:16] } +// ensureDefaultOIDCRole gives a first-time OIDC user the lowest staff role. +// Administrative roles are only derived from explicit DOS ID organization or +// team claims (see syncOIDCUserOrganizations / syncOIDCUserTeams); a missing +// or empty claim set must never escalate to admin. func (s *oidcLoginService) ensureDefaultOIDCRole(tx *gorm.DB, user *models.User) { if user == nil || user.ID <= 0 { return @@ -269,10 +274,7 @@ func (s *oidcLoginService) ensureDefaultOIDCRole(tx *gorm.DB, user *models.User) if existingRole != nil { return } - defaultRole := repositories.RoleRepository.GetByCode(tx, constants.RoleCodeAdmin) - if defaultRole == nil { - defaultRole = repositories.RoleRepository.GetByCode(tx, constants.RoleCodeSuperAdmin) - } + defaultRole := repositories.RoleRepository.GetByCode(tx, constants.RoleCodeCsUser) if defaultRole == nil { return } @@ -313,6 +315,12 @@ func (s *oidcLoginService) syncOIDCUserOrganizations(tx *gorm.DB, user *models.U role = "MEMBER" } + // Organization ADMIN/OWNER claims are the only OIDC path to the + // Desk admin role; plain members keep the default staff role. + if role == "ADMIN" || role == "OWNER" { + s.ensureOrganisationAdminRole(tx, user) + } + org := repositories.OrganizationRepository.GetByCode(tx, orgCode) if org == nil { org = &models.Organization{ @@ -494,11 +502,63 @@ func (s *oidcLoginService) syncOIDCUserTeams(tx *gorm.DB, user *models.User, pro } if isTeamLead { - s.ensureSupervisorRole(tx, user) + s.ensureTeamLeaderRole(tx, user) + } +} + +// ensureBreakGlassAdminRole elevates an allowlisted break-glass admin to the +// admin role on OIDC login. This is what gives a fresh SSO-only deployment +// (where the default-password bootstrap admin is not seeded) its first +// administrator, and it lets the allowlist owner recover while the IdP is up. +// The claim may have just been backfilled in this transaction, so the profile +// email is checked alongside the stored one. +func (s *oidcLoginService) ensureBreakGlassAdminRole(tx *gorm.DB, authCfg config.AuthConfig, user *models.User, profile *oidcLoginProfile) { + if user == nil || user.ID <= 0 { + return + } + email := "" + if user.Email != nil { + email = *user.Email + } + if !authCfg.IsBreakGlassEmail(email) && (profile == nil || !authCfg.IsBreakGlassEmail(profile.Email)) { + return + } + s.ensureOrganisationAdminRole(tx, user) +} + +// ensureTeamLeaderRole grants the support team leader role to users whose +// DOS ID team claims mark them as LEAD. It deliberately does not grant the +// admin role: administrative access comes from organization ADMIN/OWNER +// claims only (see ensureOrganisationAdminRole). +func (s *oidcLoginService) ensureTeamLeaderRole(tx *gorm.DB, user *models.User) { + if user == nil || user.ID <= 0 { + return + } + leaderRole := repositories.RoleRepository.GetByCode(tx, constants.RoleCodeCsTeamLeader) + if leaderRole == nil { + return + } + existing := repositories.UserRoleRepository.FindOne(tx, sqls.NewCnd().Eq("user_id", user.ID).Eq("role_id", leaderRole.ID)) + if existing == nil { + now := time.Now() + _ = repositories.UserRoleRepository.Create(tx, &models.UserRole{ + UserID: user.ID, + RoleID: leaderRole.ID, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: user.ID, + CreateUserName: user.Username, + UpdatedAt: now, + UpdateUserID: user.ID, + UpdateUserName: user.Username, + }, + }) } } -func (s *oidcLoginService) ensureSupervisorRole(tx *gorm.DB, user *models.User) { +// ensureOrganisationAdminRole grants the admin role when the DOS ID +// organization claim marks the user as ADMIN or OWNER. +func (s *oidcLoginService) ensureOrganisationAdminRole(tx *gorm.DB, user *models.User) { if user == nil || user.ID <= 0 { return } diff --git a/web/app/(support)/support/login/_components/login-page.tsx b/web/app/(support)/support/login/_components/login-page.tsx index 8efad755..25d048e6 100644 --- a/web/app/(support)/support/login/_components/login-page.tsx +++ b/web/app/(support)/support/login/_components/login-page.tsx @@ -40,8 +40,14 @@ export function SupportLoginPage() { const wxworkError = searchParams.get("wxworkError") const oidcError = searchParams.get("oidcError") const passwordLoginEnabled = publicConfig?.passwordLoginEnabled !== false + // Break-glass door: with password login disabled suite-wide, an allowlisted + // admin can still reach the password form via ?direct=1. The portal is + // never auto-redirected: it primarily serves customer accounts. + const isBreakGlassRequested = + searchParams.get("direct") === "1" && publicConfig?.breakGlassLoginEnabled === true + const showPasswordForm = passwordLoginEnabled || isBreakGlassRequested const providerCount = Number(publicConfig?.wxworkEnabled) + Number(publicConfig?.oidcEnabled) - const hasAnyLoginMethod = passwordLoginEnabled || providerCount > 0 + const hasAnyLoginMethod = showPasswordForm || providerCount > 0 useEffect(() => { if (ready && session) router.replace(nextDestination) @@ -84,7 +90,7 @@ export function SupportLoginPage() { }, [t]) const submit = async () => { - if (submitting || !passwordLoginEnabled) return + if (submitting || !showPasswordForm) return setSubmitting(true) try { await (mode === "login" @@ -127,7 +133,7 @@ export function SupportLoginPage() { ) : null} {publicConfig && hasAnyLoginMethod ? (