Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions cmd/migration/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ func main() {
slog.Error("load config failed", "error", err)
return
}
// Migrations read config (e.g. the SSO-only bootstrap-admin gate), so the
// loaded config must be registered as the process-wide current one.
config.SetCurrent(cfg)
logx.Init(logx.Config{
Level: cfg.Logger.Level,
Format: cfg.Logger.Format,
Expand Down
3 changes: 3 additions & 0 deletions cmd/testdata/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ func run() error {
if err != nil {
return fmt.Errorf("load config failed: %w", err)
}
// Migrations read config (e.g. the SSO-only bootstrap-admin gate), so the
// loaded config must be registered as the process-wide current one.
config.SetCurrent(cfg)

db, err := bootstrap.InitDB(cfg.DB)
if err != nil {
Expand Down
5 changes: 5 additions & 0 deletions config/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docker/agent-desk.supabase.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions internal/bootstrap/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ func Init(configPath string) error {
config.SetCurrent(cfg)
i18nx.SetDefaultLocale(cfg.LanguageOrDefault())

// A fresh SSO-only deployment without a break-glass allowlist has no way
// to bootstrap its first admin or recover when the provider is down.
if cfg.OIDC.Enabled && !cfg.Auth.IsPasswordLoginEnabled() && !cfg.Auth.HasBreakGlassEmails() {
slog.Warn("SSO-only login mode is active without a break-glass allowlist; "+
"set auth.breakGlassEmails (env BREAK_GLASS_LOGIN_EMAILS) so an admin "+
"can bootstrap and recover",
"oidc_enabled", true,
"password_login_enabled", false)
}

logx.Init(logx.Config{
Level: cfg.Logger.Level,
Format: cfg.Logger.Format,
Expand Down
17 changes: 9 additions & 8 deletions internal/handlers/api/auth_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment on lines +22 to 25

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

To allow customers (enums.UserTypeUser) to continue logging in via password when staff password login is disabled, we should remove this handler-level restriction. The AuthService.Login method will handle the password login restrictions specifically for employees (enums.UserTypeEmployee), making the handler-level check redundant and overly restrictive.

	// Password login enablement is checked per-user in AuthService.Login to support customer login.

Expand All @@ -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,
})
}

Expand Down
17 changes: 17 additions & 0 deletions internal/migration/000002_init_auth_data.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -184,6 +185,22 @@ 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) a fresh
// deployment must not seed the known default-password account: 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.
// Deployments that seeded the account before flipping to SSO-only keep it;
// it stays unusable while password login is disabled because the allowlist
// only ever matches an email and this account has none.
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
}
Comment on lines +196 to +202

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add a nil check for cfg before accessing its fields to prevent potential nil pointer dereference panics during migrations (especially in test environments where the global config might not be fully initialized).

Suggested change
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
}
cfg := config.Current()
if cfg != nil && 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
Expand Down
60 changes: 60 additions & 0 deletions internal/migration/000010_repair_bootstrap_admin_user_type_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
87 changes: 87 additions & 0 deletions internal/pkg/config/auth_break_glass_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
49 changes: 49 additions & 0 deletions internal/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"`
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading