From 8849a56a6f9812c49b8b701a458652a00698b7cf Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:12:02 +0700 Subject: [PATCH 1/2] fix(oidc): portal-origin logins provision customer-type users The support portal's DOS ID button round-trips a /support/* return path, so the signed state already identifies the entry surface. Use it to decide provisioning at creation time: - portal-origin first logins create UserTypeUser customers with no staff role, no org/team sync, no agent profile; the break-glass allowlist never elevates on the portal surface - staff-surface logins keep the Wave 2 behaviour (employee type plus claim/allowlist-derived roles) - user type is fixed at creation and never flipped by the other surface (fail-closed; the dashboard middleware already rejects non-employees) Before this, a customer signing in through the portal became an employee user (and, before the redirect-only change, received the admin role - a defect inherited from upstream agent-desk, which still grants admin to every first-time OIDC user). --- docs/ARCHITECTURE.md | 4 + internal/services/oidc_login_service.go | 46 +++++-- internal/services/oidc_login_service_test.go | 119 ++++++++++++++++++- 3 files changed, 155 insertions(+), 14 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 97bac18f..5d2fed7a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -208,6 +208,10 @@ Application roles are derived from claims, never defaulted to admin: 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. +##### Portal OIDC Login Provisions Customers + +The support portal's OIDC button targets `/support/*` return paths, which the signed OIDC state carries through the round-trip. Portal-origin logins provision **customer-type** users (`UserTypeUser`) with no staff role, no organization/team provisioning, and no agent profile, and the break-glass allowlist never elevates on the portal surface. Only the staff surface (`/dashboard/login`) grants staff access; the dashboard middleware rejects non-employee users, so a portal customer cannot open the staff dashboard. The user type is decided at creation time from the entry surface and is never changed by a later login on the other surface (fail-closed: a staff account keeps its type wherever it signs in). Note this fixes the upstream default, where every OIDC first login created an employee user - upstream `huabeitech/agent-desk` also still grants the admin role to every first-time OIDC user (fixed here in the Wave 2 redirect-only change). + #### 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/services/oidc_login_service.go b/internal/services/oidc_login_service.go index 995f9a84..bd75e7dd 100644 --- a/internal/services/oidc_login_service.go +++ b/internal/services/oidc_login_service.go @@ -47,7 +47,7 @@ func (s *oidcLoginService) LoginByOIDC(ctx context.Context, code, state string, if err != nil { return "", "", err } - loginResp, err := s.loginWithOIDCProfile(profile, authCfg, clientIP, userAgent) + loginResp, err := s.loginWithOIDCProfile(profile, authCfg, clientIP, userAgent, isSupportPortalNext(next)) if err != nil { return "", "", err } @@ -62,7 +62,18 @@ func (s *oidcLoginService) ExchangeOIDCLoginTicket(ticket string) (*response.Log return oidcclient.ConsumeLoginTicket(ticket) } -func (s *oidcLoginService) loginWithOIDCProfile(profile *oidcLoginProfile, authCfg config.AuthConfig, clientIP, userAgent string) (*response.LoginResponse, error) { +// isSupportPortalNext reports whether the OIDC round-trip started from the +// customer support portal rather than the staff dashboard. The portal always +// targets /support/* paths (see getSupportLoginDestination), so the signed +// state's next path identifies the entry surface without extra parameters. +// Portal-origin logins provision customer-type users without staff roles; +// only the staff surface (/dashboard/login) grants staff access. +func isSupportPortalNext(next string) bool { + next = strings.TrimSpace(next) + return next == "/support" || strings.HasPrefix(next, "/support/") +} + +func (s *oidcLoginService) loginWithOIDCProfile(profile *oidcLoginProfile, authCfg config.AuthConfig, clientIP, userAgent string, portalOrigin bool) (*response.LoginResponse, error) { if profile == nil || strings.TrimSpace(profile.Subject) == "" { return nil, errorsx.BusinessErrorI18n(2, "error.oidc.profileMissing") } @@ -75,7 +86,7 @@ func (s *oidcLoginService) loginWithOIDCProfile(profile *oidcLoginProfile, authC err error ) if identity == nil { - user, identity, err = s.createOIDCUser(ctx, profile) + user, identity, err = s.createOIDCUser(ctx, profile, portalOrigin) if err != nil { return err } @@ -112,11 +123,17 @@ func (s *oidcLoginService) loginWithOIDCProfile(profile *oidcLoginProfile, authC return err } - 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) + // Staff provisioning (roles, org/team sync, agent profile) runs only + // for staff-surface logins. A customer signing in through the support + // portal must never receive a staff seat: the dashboard middleware + // rejects non-employee users, and that type is set at creation only. + if !portalOrigin { + 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) + } if err = repositories.UserIdentityRepository.Updates(ctx.Tx, identity.ID, map[string]any{ "provider_name": enums.GetThirdProviderLabel(enums.ThirdProviderOIDC), @@ -139,11 +156,16 @@ func (s *oidcLoginService) loginWithOIDCProfile(profile *oidcLoginProfile, authC return ret, nil } -func (s *oidcLoginService) createOIDCUser(ctx *sqls.TxContext, profile *oidcLoginProfile) (*models.User, *models.UserIdentity, error) { +func (s *oidcLoginService) createOIDCUser(ctx *sqls.TxContext, profile *oidcLoginProfile, portalOrigin bool) (*models.User, *models.UserIdentity, error) { now := time.Now() email := s.availableEmail(ctx.Tx, profile.Email) username := s.availableUsername(ctx.Tx, profile) + userType := enums.UserTypeEmployee + if portalOrigin { + userType = enums.UserTypeUser + } + user := &models.User{ Username: username, Nickname: s.resolveOIDCNickname("", profile), @@ -151,7 +173,7 @@ func (s *oidcLoginService) createOIDCUser(ctx *sqls.TxContext, profile *oidcLogi Email: email, Password: "", PasswordSalt: "", - UserType: enums.UserTypeEmployee, + UserType: userType, Status: enums.StatusOk, AuditFields: models.AuditFields{ CreatedAt: now, @@ -187,7 +209,9 @@ func (s *oidcLoginService) createOIDCUser(ctx *sqls.TxContext, profile *oidcLogi if err := repositories.UserIdentityRepository.Create(ctx.Tx, identity); err != nil { return nil, nil, err } - s.ensureDefaultOIDCRole(ctx.Tx, user) + if !portalOrigin { + s.ensureDefaultOIDCRole(ctx.Tx, user) + } return user, identity, nil } diff --git a/internal/services/oidc_login_service_test.go b/internal/services/oidc_login_service_test.go index eacb0f29..9030c50c 100644 --- a/internal/services/oidc_login_service_test.go +++ b/internal/services/oidc_login_service_test.go @@ -20,7 +20,7 @@ func TestOIDCLoginAutoCreatesSystemUser(t *testing.T) { Name: "Ada Lovelace", Picture: "https://example.com/ada.png", RawProfile: `{"sub":"sub-123"}`, - }, config.AuthConfig{TokenTTLHours: 2}, "127.0.0.1", "go-test") + }, config.AuthConfig{TokenTTLHours: 2}, "127.0.0.1", "go-test", false) if err != nil { t.Fatalf("loginWithOIDCProfile() error = %v", err) } @@ -78,7 +78,7 @@ func TestOIDCLoginReusesExistingIdentity(t *testing.T) { Name: "Updated Name", Picture: "https://example.com/updated.png", RawProfile: `{"sub":"sub-123"}`, - }, config.AuthConfig{TokenTTLHours: 2}, "127.0.0.1", "go-test") + }, config.AuthConfig{TokenTTLHours: 2}, "127.0.0.1", "go-test", false) if err != nil { t.Fatalf("loginWithOIDCProfile() error = %v", err) } @@ -133,7 +133,7 @@ func TestOIDCLoginSyncsOrganizationsAndTeams(t *testing.T) { RawProfile: `{"sub":"7a3562bb-f529-45e0-bdfa-b73ca55ce8c8"}`, } - ret, err := svc.loginWithOIDCProfile(profile, config.AuthConfig{TokenTTLHours: 2}, "127.0.0.1", "go-test") + ret, err := svc.loginWithOIDCProfile(profile, config.AuthConfig{TokenTTLHours: 2}, "127.0.0.1", "go-test", false) if err != nil { t.Fatalf("loginWithOIDCProfile() error = %v", err) } @@ -176,3 +176,116 @@ func TestOIDCLoginSyncsOrganizationsAndTeams(t *testing.T) { t.Fatalf("expected priority level 10 for LEAD, got %d", agentProfile.PriorityLevel) } } + +// A first-time OIDC login from the support portal must provision a +// customer-type user with no staff role, no agent profile, and no +// organization/team provisioning: the dashboard middleware rejects +// non-employee users, so the portal door cannot open the staff surface. +func TestOIDCLoginPortalOriginCreatesCustomerUserWithoutStaffProvisioning(t *testing.T) { + db := setupAuthServiceTestDB(t) + svc := newOIDCLoginService() + + ret, err := svc.loginWithOIDCProfile(&oidcLoginProfile{ + Subject: "customer-sub-1", + Email: "customer@example.com", + PreferredUsername: "portalcustomer", + Name: "Portal Customer", + RawProfile: `{"sub":"customer-sub-1"}`, + }, config.AuthConfig{TokenTTLHours: 2}, "127.0.0.1", "go-test", true) + if err != nil { + t.Fatalf("loginWithOIDCProfile() error = %v", err) + } + if ret == nil || !strings.HasPrefix(ret.AccessToken, "ak_") { + t.Fatalf("expected ak_ access token, got %+v", ret) + } + + var user models.User + if err := db.Take(&user, "username = ?", "portalcustomer").Error; err != nil { + t.Fatalf("expected portal customer user to be created: %v", err) + } + if user.UserType != enums.UserTypeUser { + t.Fatalf("portal-origin user type = %q, want customer (%q)", user.UserType, enums.UserTypeUser) + } + + var roleCount int64 + if err := db.Model(&models.UserRole{}).Where("user_id = ?", user.ID).Count(&roleCount).Error; err != nil { + t.Fatalf("count user roles: %v", err) + } + if roleCount != 0 { + t.Fatalf("portal-origin user must not receive staff roles, got %d", roleCount) + } + + var orgCount int64 + if err := db.Model(&models.Organization{}).Count(&orgCount).Error; err != nil { + t.Fatalf("count organizations: %v", err) + } + if orgCount != 0 { + t.Fatalf("portal-origin login must not provision organizations, got %d", orgCount) + } + + var profileCount int64 + if err := db.Model(&models.AgentProfile{}).Where("user_id = ?", user.ID).Count(&profileCount).Error; err != nil { + t.Fatalf("count agent profiles: %v", err) + } + if profileCount != 0 { + t.Fatalf("portal-origin user must not receive an agent profile, got %d", profileCount) + } +} + +// Even an allowlisted break-glass admin must stay a role-less customer when +// entering through the support portal: the allowlist elevates on the staff +// surface only. +func TestOIDCLoginPortalOriginIgnoresBreakGlassAllowlist(t *testing.T) { + db := setupAuthServiceTestDB(t) + svc := newOIDCLoginService() + + ret, err := svc.loginWithOIDCProfile(&oidcLoginProfile{ + Subject: "admin-sub-1", + Email: "joy@dos.ai", + PreferredUsername: "joy", + Name: "Joy", + RawProfile: `{"sub":"admin-sub-1"}`, + }, config.AuthConfig{TokenTTLHours: 2, BreakGlassEmails: "joy@dos.ai"}, "127.0.0.1", "go-test", true) + if err != nil { + t.Fatalf("loginWithOIDCProfile() error = %v", err) + } + if ret == nil { + t.Fatalf("expected non-nil login response") + } + + var user models.User + if err := db.Take(&user, "username = ?", "joy").Error; err != nil { + t.Fatalf("expected portal user to be created: %v", err) + } + if user.UserType != enums.UserTypeUser { + t.Fatalf("portal-origin admin email user type = %q, want customer", user.UserType) + } + + var roleCount int64 + if err := db.Model(&models.UserRole{}).Where("user_id = ?", user.ID).Count(&roleCount).Error; err != nil { + t.Fatalf("count user roles: %v", err) + } + if roleCount != 0 { + t.Fatalf("break-glass allowlist must not elevate on the portal surface, got %d roles", roleCount) + } +} + +func TestIsSupportPortalNext(t *testing.T) { + cases := []struct { + next string + expected bool + }{ + {next: "/support/community", expected: true}, + {next: "/support", expected: true}, + {next: " /support/tickets ", expected: true}, + {next: "/dashboard", expected: false}, + {next: "", expected: false}, + {next: "/supportevil", expected: false}, + } + + for _, tc := range cases { + if got := isSupportPortalNext(tc.next); got != tc.expected { + t.Fatalf("isSupportPortalNext(%q) = %v, want %v", tc.next, got, tc.expected) + } + } +} From 475de6fe75314d45699c573717df820829b8c4c0 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:21:23 +0700 Subject: [PATCH 2/2] docs(oidc): record the fail-closed type recovery path and scope the portal note to OIDC --- docs/ARCHITECTURE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5d2fed7a..8a1e97a7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -210,7 +210,7 @@ When `passwordLoginEnabled: false` and OIDC is the only staff transport, `/dashb ##### Portal OIDC Login Provisions Customers -The support portal's OIDC button targets `/support/*` return paths, which the signed OIDC state carries through the round-trip. Portal-origin logins provision **customer-type** users (`UserTypeUser`) with no staff role, no organization/team provisioning, and no agent profile, and the break-glass allowlist never elevates on the portal surface. Only the staff surface (`/dashboard/login`) grants staff access; the dashboard middleware rejects non-employee users, so a portal customer cannot open the staff dashboard. The user type is decided at creation time from the entry surface and is never changed by a later login on the other surface (fail-closed: a staff account keeps its type wherever it signs in). Note this fixes the upstream default, where every OIDC first login created an employee user - upstream `huabeitech/agent-desk` also still grants the admin role to every first-time OIDC user (fixed here in the Wave 2 redirect-only change). +The support portal's OIDC button targets `/support/*` return paths, which the signed OIDC state carries through the round-trip. Portal-origin logins provision **customer-type** users (`UserTypeUser`) with no staff role, no organization/team provisioning, and no agent profile, and the break-glass allowlist never elevates on the portal surface. Only the staff surface (`/dashboard/login`) grants staff access; the dashboard middleware rejects non-employee users, so a portal customer cannot open the staff dashboard. The user type is decided at creation time from the entry surface and is never changed by a later login on the other surface (fail-closed: a staff account keeps its type wherever it signs in). If a staff member's very first OIDC login happens through a shared `/support/*` link, they are provisioned as a customer and the dashboard keeps rejecting them; no admin API changes user type today, so recovery is a one-row update (`UPDATE t_users SET user_type='employee' WHERE id=...`) or deleting the user to re-provision via the staff surface. The portal also renders a WxWork button whose login still provisions employee users (upstream behaviour, disabled in DOS deployments). Note this fixes the upstream default, where every OIDC first login created an employee user - upstream `huabeitech/agent-desk` also still grants the admin role to every first-time OIDC user (fixed here in the Wave 2 redirect-only change). #### 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: