7 Commits

Author SHA1 Message Date
GL.iNet-Yongping.Xie 80112c7978 fix: resolve incorrect conflicts when merging dev v2.4.0
Fix incorrect merge conflicts in the dev v2.4.0 branch.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-03-25 01:06:33 -07:00
GL.iNet-Yongping.Xie b943a18959 fix: test device group sorting and LDAP/OIDC user creation
Verify device group sorting.
Test automatic user creation for LDAP and OIDC login.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-03-25 01:03:56 -07:00
pengyu.lu ea37a171e9 Merge branch 'main' into dev-ui-0129 2026-03-25 11:45:00 +08:00
pengyu.lu 689793d47f feat: Some UI experiences have been optimized 2026-03-25 11:44:22 +08:00
GL.iNet-Yongping.Xie 55854afba8 feat: add device group sorting and auto-create LDAP/OIDC users
- support device group sorting
- auto-create local users for LDAP and OIDC login

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-03-24 02:00:09 -07:00
iclannad c8c67d5f0e Merge pull request #34 from PiexlPuck/Docker-compose-file-fixes
chore: remove unused service and update env template
2026-03-18 19:30:49 +08:00
Brayden 66566e74a8 chore: remove unused service and update env template
- Commented out the deprecated configuration line in `docker-compose/.env.example`
- Removed the associated service from Docker Compose files as it is no longer required
2026-03-18 21:28:00 +11:00
32 changed files with 536 additions and 74 deletions
+14
View File
@@ -102,6 +102,13 @@ LDAP_USER_FILTER=(uid=%s)
LDAP_ALLOWED_GROUPS=admins,operators
LDAP_ALLOWED_USERS=user1,user2
# LDAP admin group: users in these groups are assigned the "admin" role.
# Comma-separated list of group CNs. Leave empty to default all LDAP users to "user" role.
LDAP_ADMIN_GROUP=
# LDAP admin users: these usernames are directly assigned the "admin" role.
# Comma-separated list of usernames. Leave empty to skip user-based admin assignment.
LDAP_ADMIN_USERS=
# OIDC Authentication (Optional, generic OIDC provider)
OIDC_ENABLED=false
OIDC_ISSUER=
@@ -126,3 +133,10 @@ OIDC_ALLOWED_SUBS=
OIDC_ALLOWED_USERNAMES=
# Groups whitelist (e.g. admin, devops)
OIDC_ALLOWED_GROUPS=
# OIDC admin group: users in these groups are assigned the "admin" role.
# Comma-separated list of group names. Leave empty to default all OIDC users to "user" role.
OIDC_ADMIN_GROUP=
# OIDC admin users: these users are directly assigned the "admin" role.
# Comma-separated list matching preferred_username or email. Leave empty to skip user-based admin assignment.
OIDC_ADMIN_USERS=
+15 -1
View File
@@ -63,7 +63,7 @@ DEVICE_ENDPOINT_HOST=
# - Leave empty to disable domain restriction (allow access via any domain)
WEB_UI_HOST=
GLKVM access IP seen by devices/users.
# GLKVM access IP seen by devices/users.
# Leave empty to auto-detect at container start.
GLKVM_ACCESS_IP=
@@ -101,6 +101,13 @@ LDAP_USER_FILTER=(uid=%s)
LDAP_ALLOWED_GROUPS=admins,operators
LDAP_ALLOWED_USERS=user1,user2
# LDAP admin group: users in these groups are assigned the "admin" role.
# Comma-separated list of group CNs. Leave empty to default all LDAP users to "user" role.
LDAP_ADMIN_GROUP=
# LDAP admin users: these usernames are directly assigned the "admin" role.
# Comma-separated list of usernames. Leave empty to skip user-based admin assignment.
LDAP_ADMIN_USERS=
# OIDC Authentication (Optional, generic OIDC provider)
OIDC_ENABLED=false
OIDC_ISSUER=
@@ -125,3 +132,10 @@ OIDC_ALLOWED_SUBS=
OIDC_ALLOWED_USERNAMES=
# Groups whitelist (e.g. admin, devops)
OIDC_ALLOWED_GROUPS=
# OIDC admin group: users in these groups are assigned the "admin" role.
# Comma-separated list of group names. Leave empty to default all OIDC users to "user" role.
OIDC_ADMIN_GROUP=
# OIDC admin users: these users are directly assigned the "admin" role.
# Comma-separated list matching preferred_username or email. Leave empty to skip user-based admin assignment.
OIDC_ADMIN_USERS=
+4 -2
View File
@@ -1,5 +1,3 @@
version: "2.0"
services:
rttys:
image: ${GLKVM_IMAGE:-glzhitong/glkvm-cloud:latest}
@@ -35,6 +33,8 @@ services:
LDAP_USER_FILTER: ${LDAP_USER_FILTER:-(uid=%s)}
LDAP_ALLOWED_GROUPS: ${LDAP_ALLOWED_GROUPS:-}
LDAP_ALLOWED_USERS: ${LDAP_ALLOWED_USERS:-}
LDAP_ADMIN_GROUP: ${LDAP_ADMIN_GROUP:-}
LDAP_ADMIN_USERS: ${LDAP_ADMIN_USERS:-}
# ---- OIDC Authentication ----
OIDC_ENABLED: ${OIDC_ENABLED:-false}
@@ -50,6 +50,8 @@ services:
OIDC_ALLOWED_SUBS: ${OIDC_ALLOWED_SUBS:-}
OIDC_ALLOWED_USERNAMES: ${OIDC_ALLOWED_USERNAMES:-}
OIDC_ALLOWED_GROUPS: ${OIDC_ALLOWED_GROUPS:-}
OIDC_ADMIN_GROUP: ${OIDC_ADMIN_GROUP:-}
OIDC_ADMIN_USERS: ${OIDC_ADMIN_USERS:-}
# ---- Reverse Proxy ----
REVERSE_PROXY_ENABLED: ${REVERSE_PROXY_ENABLED:-false}
+3 -1
View File
@@ -66,9 +66,11 @@ case "$1" in
LDAP_ENABLED LDAP_SERVER LDAP_PORT LDAP_USE_TLS \
LDAP_BIND_DN LDAP_BIND_PASSWORD LDAP_BASE_DN \
LDAP_USER_FILTER LDAP_ALLOWED_GROUPS LDAP_ALLOWED_USERS \
LDAP_ADMIN_GROUP LDAP_ADMIN_USERS \
OIDC_ENABLED OIDC_CLIENT_ID OIDC_AUTH_URL OIDC_TOKEN_URL \
OIDC_REDIRECT_URL OIDC_CLIENT_SECRET OIDC_SCOPES OIDC_ALLOWED_USERS OIDC_ISSUER \
OIDC_ALLOWED_SUBS OIDC_ALLOWED_USERNAMES OIDC_ALLOWED_GROUPS
OIDC_ALLOWED_SUBS OIDC_ALLOWED_USERNAMES OIDC_ALLOWED_GROUPS \
OIDC_ADMIN_GROUP OIDC_ADMIN_USERS
exec rttys -c /home/rttys.conf
;;
@@ -29,6 +29,8 @@ ldap-base-dn: {{LDAP_BASE_DN}}
ldap-user-filter: {{LDAP_USER_FILTER}}
ldap-allowed-groups: {{LDAP_ALLOWED_GROUPS}}
ldap-allowed-users: {{LDAP_ALLOWED_USERS}}
ldap-admin-group: {{LDAP_ADMIN_GROUP}}
ldap-admin-users: {{LDAP_ADMIN_USERS}}
# OIDC Authentication (generic OIDC provider)
oidc-enabled: {{OIDC_ENABLED}}
@@ -50,3 +52,5 @@ oidc-generic-allowed-users: {{OIDC_ALLOWED_USERS}}
oidc-generic-allowed-subs: {{OIDC_ALLOWED_SUBS}}
oidc-generic-allowed-usernames: {{OIDC_ALLOWED_USERNAMES}}
oidc-generic-allowed-groups: {{OIDC_ALLOWED_GROUPS}}
oidc-admin-group: {{OIDC_ADMIN_GROUP}}
oidc-admin-users: {{OIDC_ADMIN_USERS}}
+2
View File
@@ -20,4 +20,6 @@ type User struct {
Role identity.Role
Status Status
IsSystem bool
AuthProvider string // "local", "oidc", "ldap"
ExternalSub string // OIDC sub claim / LDAP user DN
}
+1
View File
@@ -5,6 +5,7 @@ import "context"
type Repository interface {
FindByID(ctx context.Context, id int64) (*User, error)
FindByUsername(ctx context.Context, username string) (*User, error)
FindByExternalID(ctx context.Context, provider, externalSub string) (*User, error)
FindSystemAdmin(ctx context.Context) (*User, error)
Create(ctx context.Context, u *User) (int64, error)
+89 -1
View File
@@ -3,8 +3,9 @@ package user
import (
"context"
"errors"
"rttys/internal/domain/identity"
"strconv"
"rttys/internal/domain/identity"
"rttys/internal/pkg/password"
)
@@ -108,3 +109,90 @@ func (s *Service) UpdateUser(ctx context.Context, id int64, username, descriptio
func (s *Service) DeleteUser(ctx context.Context, id int64) error {
return s.repo.Delete(ctx, id)
}
// FindOrCreateExternalUser looks up a user by (provider, externalSub).
// If found, it updates email/description and returns the user.
// If not found, it creates a new user with the given role and status=active.
//
// role is determined by the caller based on admin-group/admin-users membership
// and is only applied at user creation time. Existing users keep their current role.
func (s *Service) FindOrCreateExternalUser(ctx context.Context, provider, externalSub, preferredUsername, email, displayName string, role identity.Role) (*User, error) {
u, err := s.repo.FindByExternalID(ctx, provider, externalSub)
if err != nil {
return nil, err
}
if u != nil {
// Update email and display name on each login (IdP may change them).
changed := false
if email != "" && u.Email != email {
u.Email = email
changed = true
}
if displayName != "" && u.Description != displayName {
u.Description = displayName
changed = true
}
if changed {
_ = s.repo.Update(ctx, u)
}
return u, nil
}
// --- Create new user ---
username := s.pickUniqueUsername(ctx, preferredUsername, email, provider)
newUser := &User{
Username: username,
Email: email,
Description: displayName,
PasswordHash: "", // external users never authenticate via password
Role: role,
Status: StatusActive,
AuthProvider: provider,
ExternalSub: externalSub,
}
id, err := s.repo.Create(ctx, newUser)
if err != nil {
return nil, err
}
newUser.ID = id
return newUser, nil
}
// pickUniqueUsername tries candidate usernames until one doesn't conflict.
func (s *Service) pickUniqueUsername(ctx context.Context, preferredUsername, email, provider string) string {
candidates := make([]string, 0, 4)
if preferredUsername != "" {
candidates = append(candidates, preferredUsername)
}
if email != "" && email != preferredUsername {
candidates = append(candidates, email)
}
// Fallback with provider suffix
if preferredUsername != "" {
candidates = append(candidates, preferredUsername+"_"+provider)
}
if email != "" {
candidates = append(candidates, email+"_"+provider)
}
// Last resort
if len(candidates) == 0 {
candidates = append(candidates, provider+"_user")
}
for _, c := range candidates {
existing, _ := s.repo.FindByUsername(ctx, c)
if existing == nil {
return c
}
}
// All candidates taken — append a numeric suffix
base := candidates[0] + "_" + provider
for i := 2; ; i++ {
name := base + "_" + strconv.Itoa(i)
existing, _ := s.repo.FindByUsername(ctx, name)
if existing == nil {
return name
}
}
}
+5 -4
View File
@@ -1,10 +1,11 @@
package dto
type MeUser struct {
ID int64 `json:"id"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
ID int64 `json:"id"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
AuthProvider string `json:"authProvider"`
}
type MeResp struct {
+1
View File
@@ -11,6 +11,7 @@ type User struct {
Description string `json:"description"`
Role string `json:"role"`
IsSystem bool `json:"isSystem"`
AuthProvider string `json:"authProvider"`
UserGroupList []UserGroupRef `json:"userGroupList"`
}
+16 -4
View File
@@ -1,6 +1,7 @@
package handler
import (
"rttys/internal/domain/identity"
"rttys/internal/pkg/ldap"
"rttys/xconfig"
"strings"
@@ -12,6 +13,7 @@ import (
"rttys/internal/store/memory"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
)
type AuthHandler struct {
@@ -40,7 +42,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
// ---- LDAP ----
authMethod := req.AuthMethod
if authMethod == "ldap" {
ok, errorType := ldap.AuthenticateUserWithError(cfg, req.Username, req.Password, authMethod)
ok, errorType, userDN, isAdmin := ldap.AuthenticateUserWithError(cfg, req.Username, req.Password, authMethod)
if !ok {
if errorType == "authorization" {
dto.Write(c, dto.Err(traceID, dto.CodeForbidden, "User not authorized", nil))
@@ -49,12 +51,22 @@ func (h *AuthHandler) Login(c *gin.Context) {
}
return
}
sysAdmin, err := h.userSvc.GetSystemAdmin(c.Request.Context())
role := identity.RoleUser
if isAdmin {
role = identity.RoleAdmin
}
ldapUser, err := h.userSvc.FindOrCreateExternalUser(c.Request.Context(), "ldap", userDN, req.Username, "", req.Username, role)
if err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "System admin not found", nil))
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Failed to create LDAP user", nil))
return
}
userID = sysAdmin.ID
log.Info().
Str("username", req.Username).
Str("userDN", userDN).
Str("role", string(role)).
Int64("userID", ldapUser.ID).
Msg("LDAP user login completed")
userID = ldapUser.ID
} else {
u, err := h.userSvc.Authenticate(c.Request.Context(), req.Username, req.Password)
if err != nil || u == nil {
+9
View File
@@ -168,6 +168,15 @@ func (h *DeviceHandler) ListDevices(c *gin.Context) {
cmp = strings.Compare(items[i].Description, items[j].Description)
case "ddns":
cmp = strings.Compare(items[i].Ddns, items[j].Ddns)
case "deviceGroupName":
var gi, gj string
if items[i].DeviceGroupID != nil {
gi = groupNameByID[*items[i].DeviceGroupID]
}
if items[j].DeviceGroupID != nil {
gj = groupNameByID[*items[j].DeviceGroupID]
}
cmp = strings.Compare(gi, gj)
default:
cmp = strings.Compare(items[i].Ddns, items[j].Ddns)
}
+5 -4
View File
@@ -18,10 +18,11 @@ func (h *MeHandler) GetMe(c *gin.Context) {
dto.Write(c, dto.Ok(traceID, dto.MeResp{
User: dto.MeUser{
ID: p.UserID,
Username: p.Username,
DisplayName: p.DisplayName,
Role: string(p.Role),
ID: p.UserID,
Username: p.Username,
DisplayName: p.DisplayName,
Role: string(p.Role),
AuthProvider: p.AuthProvider,
},
Permissions: p.PermissionKeys,
}))
+13
View File
@@ -88,6 +88,7 @@ func (h *UserHandler) ListUsers(c *gin.Context) {
Username: u.Username,
Description: u.Description,
IsSystem: u.IsSystem,
AuthProvider: u.AuthProvider,
UserGroupList: groups,
})
}
@@ -161,6 +162,8 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
return
}
p := middleware.MustPrincipal(c)
target, err := h.userSvc.FindByID(c.Request.Context(), id)
if err != nil {
dto.Write(c, dto.Err(traceID, dto.CodeNotFound, "Not found", nil))
@@ -172,6 +175,16 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
req.Password = nil
req.Repassword = nil
}
// Users cannot change their own role
if id == p.UserID {
req.Role = nil
}
// External users (OIDC/LDAP): username and password are managed by the IdP
if target.AuthProvider != "" && target.AuthProvider != "local" {
req.Username = nil
req.Password = nil
req.Repassword = nil
}
if err := h.userSvc.UpdateUser(c.Request.Context(), id, req.Username, req.Description, req.Password, req.Role, nil); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "not found") {
+7
View File
@@ -20,6 +20,7 @@ type Principal struct {
Username string `json:"username"`
DisplayName string `json:"displayName"`
Role identity.Role `json:"role"`
AuthProvider string `json:"authProvider"`
PermissionKeys []string `json:"permissions"`
}
@@ -82,11 +83,17 @@ func Auth(sessionStore *memory.SessionStore, userSvc *user.Service, permSvc *per
displayName = u.Username
}
authProvider := u.AuthProvider
if authProvider == "" {
authProvider = "local"
}
c.Set(PrincipalKey, Principal{
UserID: u.ID,
Username: u.Username,
DisplayName: displayName,
Role: u.Role,
AuthProvider: authProvider,
PermissionKeys: perms,
})
+72 -22
View File
@@ -31,59 +31,71 @@ func NewLDAPAuthenticator(config *xconfig.Config) *LDAPAuthenticator {
}
// 执行用户LDAP认证 (Perform LDAP authentication for a user)
func (l *LDAPAuthenticator) Authenticate(username, password string) (bool, error) {
// Returns (success, userDN, isAdmin, error). userDN is the distinguished name of the authenticated user.
func (l *LDAPAuthenticator) Authenticate(username, password string) (bool, string, bool, error) {
if !l.config.LdapEnabled {
return false, fmt.Errorf("LDAP authentication is disabled")
return false, "", false, fmt.Errorf("LDAP authentication is disabled")
}
if username == "" || password == "" {
return false, fmt.Errorf("username and password are required")
return false, "", false, fmt.Errorf("username and password are required")
}
// 连接到LDAP服务器 (Connect to LDAP server)
conn, err := l.connect()
if err != nil {
return false, fmt.Errorf("failed to connect to LDAP server: %v", err)
return false, "", false, fmt.Errorf("failed to connect to LDAP server: %v", err)
}
defer conn.Close()
// 使用服务账户进行绑定和搜索 (Use service account for binding and searching)
if l.config.LdapBindDN == "" || l.config.LdapBindPassword == "" {
return false, fmt.Errorf("service account credentials are required for LDAP authentication - BindDN empty: %v, BindPassword empty: %v", l.config.LdapBindDN == "", l.config.LdapBindPassword == "")
return false, "", false, fmt.Errorf("service account credentials are required for LDAP authentication - BindDN empty: %v, BindPassword empty: %v", l.config.LdapBindDN == "", l.config.LdapBindPassword == "")
}
err = conn.Bind(l.config.LdapBindDN, l.config.LdapBindPassword)
if err != nil {
return false, fmt.Errorf("service account bind failed: %v", err)
return false, "", false, fmt.Errorf("service account bind failed: %v", err)
} // 使用服务账户搜索用户 (Use service account to search for user)
userDN, err := l.findUserDN(conn, username)
if err != nil {
return false, fmt.Errorf("user search failed: %v", err)
return false, "", false, fmt.Errorf("user search failed: %v", err)
}
// 找到用户,现在用用户凭证验证密码 (Found user, now validate password with user credentials)
err = conn.Bind(userDN, password)
if err != nil {
return false, fmt.Errorf("password validation failed: %v", err)
return false, "", false, fmt.Errorf("password validation failed: %v", err)
}
// 重新绑定为服务账户以进行授权检查 (Rebind as service account for authorization check)
err = conn.Bind(l.config.LdapBindDN, l.config.LdapBindPassword)
if err != nil {
return false, fmt.Errorf("failed to rebind as service account for authorization: %v", err)
return false, "", false, fmt.Errorf("failed to rebind as service account for authorization: %v", err)
}
// 检查用户授权 (Check user authorization)
authorized, err := l.checkAuthorization(conn, userDN, username)
if err != nil {
return false, fmt.Errorf("authorization check failed: %v", err)
return false, "", false, fmt.Errorf("authorization check failed: %v", err)
}
if !authorized {
return false, fmt.Errorf("user not authorized")
return false, "", false, fmt.Errorf("user not authorized")
}
return true, nil
// 检查用户是否为管理员 (Check if user is admin by group or username)
isAdmin := l.checkIsAdmin(conn, userDN, username)
log.Info().
Str("username", username).
Str("userDN", userDN).
Str("adminGroup", l.config.LdapAdminGroup).
Str("adminUsers", l.config.LdapAdminUsers).
Bool("isAdmin", isAdmin).
Msg("LDAP authentication successful")
return true, userDN, isAdmin, nil
}
// 建立到LDAP服务器的连接 (Establish connection to LDAP server)
@@ -342,35 +354,73 @@ func (l *LDAPAuthenticator) findActualUserDN(conn *ldap.Conn, username string) (
return sr.Entries[0].DN, nil
}
// checkIsAdmin checks whether the authenticated user should be assigned the admin role,
// by matching against LdapAdminUsers (username list) OR LdapAdminGroup (group membership).
func (l *LDAPAuthenticator) checkIsAdmin(conn *ldap.Conn, userDN, username string) bool {
// 1) Check admin users list
adminUsers := strings.TrimSpace(l.config.LdapAdminUsers)
if adminUsers != "" {
users := strings.Split(adminUsers, ",")
for _, u := range users {
if strings.TrimSpace(u) == username {
return true
}
}
}
// 2) Check admin group membership
adminGroups := strings.TrimSpace(l.config.LdapAdminGroup)
if adminGroups != "" {
groups := strings.Split(adminGroups, ",")
for _, group := range groups {
group = strings.TrimSpace(group)
if group == "" {
continue
}
isMember, err := l.isGroupMember(conn, userDN, username, group)
if err != nil {
log.Warn().Msgf("Error checking admin group membership for %s in %s: %v", username, group, err)
continue
}
if isMember {
return true
}
}
}
return false
}
// 执行用户认证,支持LDAP和传统密码认证 (Perform user authentication with LDAP and legacy password support)
func AuthenticateUser(cfg *xconfig.Config, username, password, authMethod string) bool {
success, _ := AuthenticateUserWithError(cfg, username, password, authMethod)
success, _, _, _ := AuthenticateUserWithError(cfg, username, password, authMethod)
return success
}
// 执行用户认证并返回错误类型,支持LDAP和传统密码认证 (Perform user authentication with error type, supporting LDAP and legacy password authentication)
func AuthenticateUserWithError(cfg *xconfig.Config, username, password, authMethod string) (bool, string) {
// AuthenticateUserWithError performs authentication and returns (success, errorType, userDN, isAdmin).
// userDN and isAdmin are only populated for successful LDAP authentication.
func AuthenticateUserWithError(cfg *xconfig.Config, username, password, authMethod string) (bool, string, string, bool) {
// 处理LDAP认证 (Handle LDAP authentication)
if cfg.LdapEnabled && authMethod == "ldap" && username != "" {
ldapAuth := NewLDAPAuthenticator(cfg)
success, err := ldapAuth.Authenticate(username, password)
success, userDN, isAdmin, err := ldapAuth.Authenticate(username, password)
if err != nil {
log.Error().Msgf("LDAP authentication error: %v", err)
// 检查错误类型以区分认证和授权错误 (Check error type to distinguish between authentication and authorization errors)
if strings.Contains(err.Error(), "user not authorized") {
return false, "authorization"
return false, "authorization", "", false
}
return false, "authentication"
return false, "authentication", "", false
}
return success, ""
return success, "", userDN, isAdmin
}
if authMethod == "legacy" || authMethod == "" {
if cfg.Password == password {
return true, ""
return true, "", "", false
}
return false, "authentication"
return false, "authentication", "", false
}
return false, "authentication"
return false, "authentication", "", false
}
+44 -5
View File
@@ -13,6 +13,7 @@ import (
"math/rand"
"net/http"
"net/url"
"rttys/internal/domain/identity"
"rttys/internal/domain/user"
"rttys/internal/pkg/randtoken"
"rttys/xconfig"
@@ -232,21 +233,59 @@ func oidcCallbackHandler(cfg *xconfig.Config, userSvc *user.Service) gin.Handler
return
}
// ==== Create application session (new session_store, same as LDAP) ====
sid, err := randtoken.New() // randtoken.New()
// ==== Create application session ====
sid, err := randtoken.New()
if err != nil {
log.Error().Err(err).Msg("Failed to create session token")
c.Redirect(http.StatusFound, "/?error=internal_error")
return
}
sysAdmin, err := userSvc.GetSystemAdmin(c.Request.Context())
preferredUsername, _ := claims["preferred_username"].(string)
// Determine role based on admin group / admin users
role := identity.RoleUser
hasAdminRule := len(cfg.OIDCAdminGroup) > 0 || len(cfg.OIDCAdminUsers) > 0
if hasAdminRule {
// Check admin users list (match preferred_username or email)
if len(cfg.OIDCAdminUsers) > 0 {
if contains(cfg.OIDCAdminUsers, preferredUsername) || contains(cfg.OIDCAdminUsers, userEmail) {
role = identity.RoleAdmin
}
}
// Check admin group membership
if role != identity.RoleAdmin && len(cfg.OIDCAdminGroup) > 0 {
groups := extractStringSlice(claims["groups"])
if intersects(groups, cfg.OIDCAdminGroup) {
role = identity.RoleAdmin
}
}
log.Info().
Str("sub", sub).
Str("email", userEmail).
Str("name", userName).
Str("preferredUsername", preferredUsername).
Strs("userGroups", extractStringSlice(claims["groups"])).
Strs("adminGroup", cfg.OIDCAdminGroup).
Strs("adminUsers", cfg.OIDCAdminUsers).
Str("role", string(role)).
Msg("OIDC admin role check")
}
oidcUser, err := userSvc.FindOrCreateExternalUser(c.Request.Context(), "oidc", sub, preferredUsername, userEmail, userName, role)
if err != nil {
log.Error().Err(err).Msg("Failed to find system admin user")
log.Error().Err(err).Msg("Failed to find or create OIDC user")
c.Redirect(http.StatusFound, "/?error=internal_error")
return
}
sessionStore.Create(sid, sysAdmin.ID)
log.Info().
Str("sub", sub).
Str("email", userEmail).
Str("preferredUsername", preferredUsername).
Str("role", string(role)).
Int64("userID", oidcUser.ID).
Msg("OIDC user login completed")
sessionStore.Create(sid, oidcUser.ID)
c.SetCookie("sid", sid, 0, "/", "", cfg.SslCert != "", false)
+1 -1
View File
@@ -1,7 +1,7 @@
package server
const RttysVersion = "5.2.0"
const KVMCloudVersion = "v2.3.0"
const KVMCloudVersion = "v2.4.0"
var (
GitCommit = ""
+49 -1
View File
@@ -81,7 +81,16 @@ func InitSchema(ctx context.Context, db *sql.DB, schemaPath string) error {
if err := ensureDeviceClientColumn(ctx, db); err != nil {
return err
}
return ensureUserIsSystemColumn(ctx, db)
if err := ensureUserIsSystemColumn(ctx, db); err != nil {
return err
}
if err := ensureAuthProviderColumn(ctx, db); err != nil {
return err
}
if err := ensureExternalSubColumn(ctx, db); err != nil {
return err
}
return ensureExternalIdentityIndex(ctx, db)
}
func ensureDeviceClientColumn(ctx context.Context, db *sql.DB) error {
@@ -111,3 +120,42 @@ func ensureUserIsSystemColumn(ctx context.Context, db *sql.DB) error {
}
return err
}
func ensureAuthProviderColumn(ctx context.Context, db *sql.DB) error {
if db == nil {
return nil
}
_, err := db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'local'`)
if err == nil {
return nil
}
if strings.Contains(err.Error(), "duplicate column name") {
return nil
}
return err
}
func ensureExternalSubColumn(ctx context.Context, db *sql.DB) error {
if db == nil {
return nil
}
_, err := db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN external_sub TEXT NOT NULL DEFAULT ''`)
if err == nil {
return nil
}
if strings.Contains(err.Error(), "duplicate column name") {
return nil
}
return err
}
func ensureExternalIdentityIndex(ctx context.Context, db *sql.DB) error {
if db == nil {
return nil
}
_, err := db.ExecContext(ctx,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_external_identity
ON users(auth_provider, external_sub)
WHERE external_sub != ''`)
return err
}
+41
View File
@@ -25,6 +25,8 @@ type userRow struct {
Role string `gorm:"column:role"`
Status string `gorm:"column:status"`
IsSystem bool `gorm:"column:is_system"`
AuthProvider string `gorm:"column:auth_provider"`
ExternalSub string `gorm:"column:external_sub"`
}
func (userRow) TableName() string { return "users" }
@@ -51,6 +53,8 @@ func (r *UserRepo) FindByID(ctx context.Context, id int64) (*user.User, error) {
Role: identity.Role(row.Role),
Status: user.Status(row.Status),
IsSystem: row.IsSystem,
AuthProvider: row.AuthProvider,
ExternalSub: row.ExternalSub,
}
return u, nil
}
@@ -77,6 +81,35 @@ func (r *UserRepo) FindByUsername(ctx context.Context, username string) (*user.U
Role: identity.Role(row.Role),
Status: user.Status(row.Status),
IsSystem: row.IsSystem,
AuthProvider: row.AuthProvider,
ExternalSub: row.ExternalSub,
}, nil
}
func (r *UserRepo) FindByExternalID(ctx context.Context, provider, externalSub string) (*user.User, error) {
var row userRow
err := r.db.WithContext(ctx).
Where("auth_provider = ? AND external_sub = ?", provider, externalSub).
Take(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &user.User{
ID: row.ID,
Username: row.Username,
Email: row.Email,
Description: row.Description,
PasswordHash: row.PasswordHash,
Role: identity.Role(row.Role),
Status: user.Status(row.Status),
IsSystem: row.IsSystem,
AuthProvider: row.AuthProvider,
ExternalSub: row.ExternalSub,
}, nil
}
@@ -102,6 +135,8 @@ func (r *UserRepo) FindSystemAdmin(ctx context.Context) (*user.User, error) {
Role: identity.Role(row.Role),
Status: user.Status(row.Status),
IsSystem: row.IsSystem,
AuthProvider: row.AuthProvider,
ExternalSub: row.ExternalSub,
}, nil
}
@@ -122,6 +157,8 @@ func (r *UserRepo) List(ctx context.Context) ([]user.User, error) {
Role: identity.Role(row.Role),
Status: user.Status(row.Status),
IsSystem: row.IsSystem,
AuthProvider: row.AuthProvider,
ExternalSub: row.ExternalSub,
})
}
return out, nil
@@ -136,6 +173,8 @@ func (r *UserRepo) Create(ctx context.Context, u *user.User) (int64, error) {
Role: string(u.Role),
Status: string(u.Status),
IsSystem: u.IsSystem,
AuthProvider: u.AuthProvider,
ExternalSub: u.ExternalSub,
}
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
@@ -157,6 +196,8 @@ func (r *UserRepo) Update(ctx context.Context, u *user.User) error {
"role": string(u.Role),
"status": string(u.Status),
"is_system": u.IsSystem,
"auth_provider": u.AuthProvider,
"external_sub": u.ExternalSub,
}).Error
}
+5 -1
View File
@@ -2,7 +2,7 @@
* @Author: LPY
* @Date: 2025-05-30 10:18:18
* @LastEditors: LPY
* @LastEditTime: 2026-03-10 11:52:27
* @LastEditTime: 2026-03-25 11:06:30
* @FilePath: \glkvm-cloud\ui\src\hooks\useLocalStorage.ts
* @Description: 存储hook
*/
@@ -10,6 +10,8 @@ import { ref } from 'vue'
/** 整个系统 */
export enum LocalStorageKeys {
/** 当前系统版本 */
APP_VERSION_KEY = 'app_version',
/** 存储语言的key */
STORAGE_LANGUAGE_KEY = 'language',
/** 主题色 */
@@ -22,6 +24,8 @@ export enum LocalStorageKeys {
VERSION = 'version',
/** 设备列表列表顺序 */
DEVICE_LIST_COLUMNS_KEY = 'device-list-columns',
/** 设备列表排序 */
DEVICE_LIST_SORT_KEY = 'device-list-sort',
}
/**
+5 -2
View File
@@ -40,7 +40,9 @@
"loginWithOidc": "Log in with OIDC",
"confirmPasswordValidateError": "The passwords you typed do not match.",
"accountLogin": "Account Login",
"ldap": "LDAP"
"ldap": "LDAP",
"local": "Local",
"oidc": "OIDC"
},
"device": {
"devices": "Devices",
@@ -158,7 +160,8 @@
"deleteUserGroupConfirmTips3": "Are you sure you want to delete it?",
"deleteOnlyOneAdminTips": "Cannot delete: Only system admin left.",
"myGroup": "My Group",
"userRoleDesc": "Administrators can view all devices, while ordinary users can only see the devices in the device group associated with the user group"
"userRoleDesc": "Administrators can view all devices, while ordinary users can only see the devices in the device group associated with the user group",
"userType": "User Type"
},
"rtty": {
"requestingDeviceToCreateTerminal": "Requesting device to create terminal...",
+5 -2
View File
@@ -40,7 +40,9 @@
"loginWithOidc": "使用OIDC登录",
"confirmPasswordValidateError": "密码不一致。",
"accountLogin": "账号登录",
"ldap": "LDAP"
"ldap": "LDAP",
"local": "本地",
"oidc": "OIDC"
},
"device": {
"devices": "设备数",
@@ -158,7 +160,8 @@
"deleteUserGroupConfirmTips3": "你确定要删除它吗?",
"deleteOnlyOneAdminTips": "无法删除:只剩一个系统管理员了。",
"myGroup": "我的用户组",
"userRoleDesc": "管理员可以看到所有设备,普通用户只能看到用户组关联设备组的设备"
"userRoleDesc": "管理员可以看到所有设备,普通用户只能看到用户组关联设备组的设备",
"userType": "用户类型"
},
"rtty": {
"requestingDeviceToCreateTerminal": "正在请求设备创建终端...",
+14 -1
View File
@@ -2,7 +2,7 @@
* @Author: LPY
* @Date: 2026-02-02 15:13:17
* @LastEditors: LPY
* @LastEditTime: 2026-02-09 09:13:22
* @LastEditTime: 2026-03-25 10:09:43
* @FilePath: \glkvm-cloud\ui\src\models\userManage.ts
* @Description: 用户管理相关类型声明
*/
@@ -22,12 +22,25 @@ export const UserRoleLabelMap = new Map([
[UserRoleEnum.USER, 'user.user'],
])
export enum AuthProviderEnum {
LOCAL = 'local',
LDAP = 'ldap',
OIDC = 'oidc',
}
export const AuthProviderLabelMap = new Map([
[AuthProviderEnum.LOCAL, 'login.local'],
[AuthProviderEnum.LDAP, 'login.ldap'],
[AuthProviderEnum.OIDC, 'login.oidc'],
])
export interface UserManage {
id: number
username: string
role: UserRoleEnum
description: string
isSystem: boolean
authProvider: AuthProviderEnum,
userGroupList: {
userGroupId: number
userGroupName: string
+6 -2
View File
@@ -2,8 +2,8 @@
* @Author: LPY
* @Date: 2025-05-30 09:44:40
* @LastEditors: LPY
* @LastEditTime: 2025-06-19 10:12:11
* @FilePath: /kvm-cloud-frontend/src/projectInitialize/index.ts
* @LastEditTime: 2026-03-25 11:09:30
* @FilePath: \glkvm-cloud\ui\src\projectInitialize\index.ts
* @Description: 项目初始化的操作
*/
import type { App } from 'vue'
@@ -12,6 +12,7 @@ import { initializeAllLanguage } from '@/lang'
import { installComponent } from './installComponent'
import loadAdvComponent from './loadAdvComponent'
import { installDirective } from './installDirective'
import { checkAndClearCache } from '@/utils/versionManager'
export default function (app: App ) {
/** 加载插件 */
@@ -28,4 +29,7 @@ export default function (app: App ) {
/** 初始化语言 */
initializeAllLanguage()
/** 检查并清理缓存 */
checkAndClearCache()
}
+32
View File
@@ -0,0 +1,32 @@
/*
* @Author: LPY
* @Date: 2026-03-25 11:01:34
* @LastEditors: LPY
* @LastEditTime: 2026-03-25 11:15:12
* @FilePath: \glkvm-cloud\ui\src\utils\versionManager.ts
* @Description: 版本管理工具,主要用于清理缓存
*/
import { LocalStorageKeys, useLocalStorage } from '@/hooks/useLocalStorage'
const APP_VERSION = '2.4.0' // 当前应用版本
const CACHE_KEYS_TO_CLEAR = [LocalStorageKeys.DEVICE_LIST_COLUMNS_KEY] // 需要清理的缓存key
export function checkAndClearCache () {
const cachedVersion = useLocalStorage(LocalStorageKeys.APP_VERSION_KEY).getValue()
if (cachedVersion !== APP_VERSION) {
// 版本不一致,清理指定缓存
CACHE_KEYS_TO_CLEAR.forEach(key => {
useLocalStorage(key).removeValue()
})
// 更新版本号
useLocalStorage(LocalStorageKeys.APP_VERSION_KEY).setValue(APP_VERSION)
console.log(`缓存已清理,版本从 ${cachedVersion} 升级到 ${APP_VERSION}`)
return true
}
return false
}
@@ -2,7 +2,7 @@
* @Author: LPY
* @Date: 2025-08-25 09:32:42
* @LastEditors: LPY
* @LastEditTime: 2026-02-11 09:31:33
* @LastEditTime: 2026-03-25 10:11:49
* @FilePath: \glkvm-cloud\ui\src\views\device\components\addDeviceDialog.vue
* @Description: 添加设备弹窗
-->
@@ -65,8 +65,8 @@ const OperatingSystemTranslated = computed(() => {
return useTranslatedOptions([
{ label: operatingSystemLabelMap.get(OperatingSystemEnum.GL_KVM), value: OperatingSystemEnum.GL_KVM },
{ label: operatingSystemLabelMap.get(OperatingSystemEnum.LINUX), value: OperatingSystemEnum.LINUX },
{ label: operatingSystemLabelMap.get(OperatingSystemEnum.WINDOWS), value: OperatingSystemEnum.WINDOWS },
{ label: operatingSystemLabelMap.get(OperatingSystemEnum.MAC_OS), value: OperatingSystemEnum.MAC_OS },
// { label: operatingSystemLabelMap.get(OperatingSystemEnum.WINDOWS), value: OperatingSystemEnum.WINDOWS },
// { label: operatingSystemLabelMap.get(OperatingSystemEnum.MAC_OS), value: OperatingSystemEnum.MAC_OS },
])
})
@@ -2,7 +2,7 @@
* @Author: shufei.han
* @Date: 2025-06-11 12:04:48
* @LastEditors: LPY
* @LastEditTime: 2026-03-10 11:47:57
* @LastEditTime: 2026-03-25 10:54:42
* @FilePath: \glkvm-cloud\ui\src\views\device\components\deviceListView.vue
* @Description:
-->
@@ -206,7 +206,9 @@ const deviceColumns = ref<TableColumnType[]>([
{title: t('device.connectedTime'), dataIndex: 'connectedTime', key: 'connectedTime', ellipsis: true,
sorter: true, customHeaderCell: () => {return {class: 'custom-table-header-cell-to-left'}},
},
{title: t('user.associatedDeviceGroup'), dataIndex: 'deviceGroupName', key: 'deviceGroupName', ellipsis: true, width: 190},
{title: t('user.associatedDeviceGroup'), dataIndex: 'deviceGroupName', key: 'deviceGroupName', ellipsis: true, width: 190,
sorter: true, customHeaderCell: () => {return {class: 'custom-table-header-cell-to-left'}},
},
{title: t('device.description'), dataIndex: 'description', key: 'description',
sorter: true, customHeaderCell: () => {return {class: 'custom-table-header-cell-to-left'}},
},
@@ -249,9 +251,17 @@ const onSelectChange = (selectedRowKeys: Key[], selectedRows: DeviceInfo[]) => {
const tableChange: TableProps['onChange'] = (pagination, filters, sorter: any) => {
console.log('params', pagination, filters, sorter)
if (sorter) {
if (sorter?.order) {
deviceStore.state.sortBy = sorter.field as string
deviceStore.state.order = sorter.order === 'ascend' ? 'asc' : 'desc'
useLocalStorage(LocalStorageKeys.DEVICE_LIST_SORT_KEY).setValue({
sortBy: deviceStore.state.sortBy,
order: deviceStore.state.order,
})
} else {
deviceStore.state.sortBy = undefined
deviceStore.state.order = undefined
useLocalStorage(LocalStorageKeys.DEVICE_LIST_SORT_KEY).removeValue()
}
}
@@ -442,6 +452,13 @@ const init = () => {
})
deviceColumns.value = parsedColumns.filter(col => (col as any).show)
}
const sortKey = useLocalStorage(LocalStorageKeys.DEVICE_LIST_SORT_KEY).getValue() as { sortBy: string, order: string }
if (sortKey) {
deviceStore.state.sortBy = sortKey.sortBy
deviceStore.state.order = sortKey.order
deviceColumns.value.find(col => col.key === sortKey.sortBy).defaultSortOrder = sortKey.order === 'asc' ? 'ascend' : 'descend'
}
}
init()
@@ -2,7 +2,7 @@
* @Author: LPY
* @Date: 2026-02-03 11:24:20
* @LastEditors: LPY
* @LastEditTime: 2026-02-28 09:51:05
* @LastEditTime: 2026-03-25 10:19:00
* @FilePath: \glkvm-cloud\ui\src\views\userManage\components\editUserDialog.vue
* @Description: 编辑用户弹窗
-->
@@ -48,7 +48,8 @@
v-model:value="state.formData.username"
:maxlength="32"
:placeholder="$t('device.requiredDeviceGroupName')"
:disabled="props.currentUser?.isSystem"
:disabled="props.currentUser?.isSystem ||
props.currentUser?.authProvider == AuthProviderEnum.LDAP || props.currentUser?.authProvider == AuthProviderEnum.OIDC"
style="width: 100%;" />
</AFormItem>
<AFormItem name="description" :label="$t('device.description')" labelAlign="left">
@@ -100,7 +101,7 @@ import { FormRules, OnBeforeOk } from 'gl-web-main'
import { t } from '@/hooks/useLanguage'
import { FormInstance, Tooltip } from 'ant-design-vue'
import { reqUserGroupListOptions } from '@/api/deviceGroup'
import { UserManage, UserRoleEnum, UserRoleLabelMap } from '@/models/userManage'
import { AuthProviderEnum, UserManage, UserRoleEnum, UserRoleLabelMap } from '@/models/userManage'
import { reqEditUser } from '@/api/userManage'
import AddUserGroupDialog from './addUserGroupDialog.vue'
import { useUserManageStore } from '@/stores/modules/userManage'
+6 -2
View File
@@ -2,7 +2,7 @@
* @Author: LPY
* @Date: 2026-02-02 14:32:56
* @LastEditors: LPY
* @LastEditTime: 2026-02-06 18:00:36
* @LastEditTime: 2026-03-25 10:10:53
* @FilePath: \glkvm-cloud\ui\src\views\userManage\userManagePage.vue
* @Description: 用户管理页
-->
@@ -50,6 +50,9 @@
style="background-color: var(--gl-color-warning-primary);color: var(--gl-color-warning-background);"
>{{ $t(UserRoleLabelMap.get(record.role)) }}</BaseTag>
</template>
<template #authProvider="{ record }">
{{ $t(AuthProviderLabelMap.get(record.authProvider || AuthProviderEnum.LOCAL)) }}
</template>
<template #userGroupList="{ record }">
<div class="groups-a">
<a
@@ -131,7 +134,7 @@ import BaseLoadingContainer from '@/components/base/baseLoadingContainer.vue'
import BasePagination from '@/components/base/basePagination.vue'
import BaseTable from '@/components/base/baseTable.vue'
import { t } from '@/hooks/useLanguage'
import { UserManage, UserRoleEnum, UserRoleLabelMap } from '@/models/userManage'
import { AuthProviderEnum, AuthProviderLabelMap, UserManage, UserRoleEnum, UserRoleLabelMap } from '@/models/userManage'
import { useUserManageStore } from '@/stores/modules/userManage'
import { message, TableColumnType, Tooltip } from 'ant-design-vue'
import { baseCustomModal, SelectOptions } from 'gl-web-main'
@@ -156,6 +159,7 @@ const userColumns = computed<TableColumnType[]>(() => {
return [
{title: t('user.userName'), dataIndex: 'username', ellipsis: true},
{title: t('user.role'), dataIndex: 'role', ellipsis: true},
{title: t('user.userType'), dataIndex: 'authProvider'},
{title: t('device.description'), dataIndex: 'description'},
{title: t('device.associatedUserGroups'), dataIndex: 'userGroupList', ellipsis: true},
{title: t('common.action'), dataIndex: 'action', width: 270},
+9 -9
View File
@@ -23,39 +23,39 @@ export default defineConfig(({ mode }) => {
port: 3011,
proxy: {
'/devs': {
target: 'https://107.173.152.173',
target: 'https://106.55.158.199/',
secure: false,
},
'/api': {
target: 'https://107.173.152.173',
target: 'https://106.55.158.199/',
secure: false,
changeOrigin: true,
},
'/signout': {
target: 'https://107.173.152.173',
target: 'https://106.55.158.199/',
secure: false,
},
'/alive': {
target: 'https://107.173.152.173',
target: 'https://106.55.158.199/',
secure: false,
},
'/get': {
target: 'https://107.173.152.173',
target: 'https://106.55.158.199/',
secure: false,
},
'^/cmd/.*': {
target: 'https://107.173.152.173',
target: 'https://106.55.158.199/',
secure: false,
},
'^/connect/.*': {
ws: true,
target: 'https://107.173.152.173',
target: 'https://106.55.158.199/',
},
'^/web/*': {
target: 'https://107.173.152.173',
target: 'https://106.55.158.199/',
},
'/auth-config': {
target: 'https://107.173.152.173',
target: 'https://106.55.158.199/',
secure: false,
changeOrigin: true,
},
+32
View File
@@ -70,6 +70,8 @@ type Config struct {
LdapUserFilter string
LdapAllowedGroups string
LdapAllowedUsers string
LdapAdminGroup string
LdapAdminUsers string
// Generic OIDC Provider (supports any standard OIDC provider)
OIDCEnabled bool
@@ -84,6 +86,8 @@ type Config struct {
OIDCGenericAllowedSubs []string
OIDCGenericAllowedUsernames []string
OIDCGenericAllowedGroups []string
OIDCAdminGroup []string
OIDCAdminUsers []string
// =====================================================
// Reverse Proxy / Proxy Mode
@@ -195,6 +199,8 @@ func parseYamlCfg(cfg *Config, conf string) error {
getConfigOpt(yamlCfg, "ldap-user-filter", &cfg.LdapUserFilter)
getConfigOpt(yamlCfg, "ldap-allowed-groups", &cfg.LdapAllowedGroups)
getConfigOpt(yamlCfg, "ldap-allowed-users", &cfg.LdapAllowedUsers)
getConfigOpt(yamlCfg, "ldap-admin-group", &cfg.LdapAdminGroup)
getConfigOpt(yamlCfg, "ldap-admin-users", &cfg.LdapAdminUsers)
// ===== OIDC configuration (generic OIDC provider) =====
// Switch and basic endpoints
@@ -236,6 +242,16 @@ func parseYamlCfg(cfg *Config, conf string) error {
cfg.OIDCGenericAllowedGroups = splitScopes(s)
}
// OIDC admin group
if s, err := yamlCfg.Get("oidc-admin-group"); err == nil && strings.TrimSpace(s) != "" {
cfg.OIDCAdminGroup = splitScopes(s)
}
// OIDC admin users (preferred_username / email whitelist for admin role)
if s, err := yamlCfg.Get("oidc-admin-users"); err == nil && strings.TrimSpace(s) != "" {
cfg.OIDCAdminUsers = splitScopes(s)
}
return nil
}
@@ -274,6 +290,22 @@ func applyEnvCfg(cfg *Config) error {
cfg.LdapBindPassword = envPassword
}
// LDAP admin group / admin users
if v := strings.TrimSpace(os.Getenv("LDAP_ADMIN_GROUP")); v != "" {
cfg.LdapAdminGroup = v
}
if v := strings.TrimSpace(os.Getenv("LDAP_ADMIN_USERS")); v != "" {
cfg.LdapAdminUsers = v
}
// OIDC admin group / admin users
if v := strings.TrimSpace(os.Getenv("OIDC_ADMIN_GROUP")); v != "" {
cfg.OIDCAdminGroup = splitScopes(v)
}
if v := strings.TrimSpace(os.Getenv("OIDC_ADMIN_USERS")); v != "" {
cfg.OIDCAdminUsers = splitScopes(v)
}
// Note: oidc-generic-client-secret is intentionally not read from YAML
// to avoid checking secrets into config files and leaking in logs.
// It is always read directly from the OIDC_CLIENT_SECRET environment variable below.