Compare commits

..

4 Commits

13 changed files with 532 additions and 61 deletions
+175 -10
View File
@@ -2,6 +2,7 @@ package db
import "database/sql"
import "errors"
import "fmt"
import "strings"
import "time"
@@ -14,6 +15,10 @@ const AuthProviderTypeOIDC = "oidc"
const BuiltinDBProviderPublicID = "db"
const GroupSyncModeOff string = "off"
const GroupSyncModeFirstLogin string = "first_login"
const GroupSyncModeSync string = "sync"
func scanAuthProvider(row interface {
Scan(dest ...any) error
}, item *models.AuthProvider) error {
@@ -40,6 +45,8 @@ func scanAuthProvider(row interface {
&item.OIDCTLSInsecureSkipVerify,
&item.OIDCGroupsClaim,
&item.OIDCAdmissionExpr,
&item.OIDCEndSessionURL,
&item.GroupSyncMode,
&defaultUserGroupID,
&item.UserCount,
&item.CreatedAt,
@@ -58,7 +65,7 @@ const authProviderSelectCols = `
p.ldap_tls_insecure_skip_verify,
p.oidc_client_id, p.oidc_client_secret, p.oidc_authorize_url, p.oidc_token_url,
p.oidc_userinfo_url, p.oidc_redirect_url, p.oidc_scopes, p.oidc_tls_insecure_skip_verify,
p.oidc_groups_claim, p.oidc_admission_expr,
p.oidc_groups_claim, p.oidc_admission_expr, p.oidc_end_session_url, p.group_sync_mode,
COALESCE(ug.public_id, ''),
(SELECT COUNT(*) FROM users WHERE auth_provider_id = p.id),
p.created_at, p.updated_at`
@@ -67,6 +74,105 @@ const authProviderFromClause = `
FROM auth_providers p
LEFT JOIN user_groups ug ON ug.id = p.default_user_group_id`
func normalizeAuthProviderGroupSyncMode(value string) (string, error) {
var mode string
mode = strings.TrimSpace(value)
if mode == "" {
return GroupSyncModeOff, nil
}
if mode == GroupSyncModeOff || mode == GroupSyncModeFirstLogin || mode == GroupSyncModeSync {
return mode, nil
}
return "", errors.New("invalid group_sync_mode")
}
func scanAuthProviderGroupMappings(rows *sql.Rows) ([]models.AuthProviderGroupMapping, error) {
var items []models.AuthProviderGroupMapping
var item models.AuthProviderGroupMapping
var err error
for rows.Next() {
err = rows.Scan(&item.ClaimValue, &item.GroupID, &item.GroupName, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Store) ListAuthProviderGroupMappings(providerID string) ([]models.AuthProviderGroupMapping, error) {
var rows *sql.Rows
var err error
rows, err = s.Query(`
SELECT m.claim_value, g.public_id, g.name, m.created_at, m.updated_at
FROM auth_provider_group_mappings m
JOIN auth_providers p ON p.id = m.auth_provider_id
JOIN user_groups g ON g.id = m.group_id
WHERE p.public_id = ?
ORDER BY m.claim_value, g.name`, strings.TrimSpace(providerID))
if err != nil {
return nil, err
}
defer rows.Close()
return scanAuthProviderGroupMappings(rows)
}
func (s *Store) fillAuthProviderGroupMappings(items []models.AuthProvider) ([]models.AuthProvider, error) {
var err error
var i int
for i = 0; i < len(items); i++ {
items[i].GroupMappings, err = s.ListAuthProviderGroupMappings(items[i].ID)
if err != nil {
return nil, err
}
}
return items, nil
}
func insertAuthProviderGroupMappings(exec sqlExecutor, providerID string, mappings []models.AuthProviderGroupMapping, now int64) error {
var result sql.Result
var err error
var mapping models.AuthProviderGroupMapping
var claimValue string
var groupID string
var affected int64
var i int
for i = 0; i < len(mappings); i++ {
mapping = mappings[i]
claimValue = strings.TrimSpace(mapping.ClaimValue)
groupID = strings.TrimSpace(mapping.GroupID)
if claimValue == "" {
return errors.New("group mapping claim_value is required")
}
if groupID == "" {
return errors.New("group mapping group_id is required")
}
result, err = exec.Exec(`
INSERT INTO auth_provider_group_mappings (auth_provider_id, group_id, claim_value, created_at, updated_at)
SELECT p.id, g.id, ?, ?, ?
FROM auth_providers p
JOIN user_groups g ON g.public_id = ?
WHERE p.public_id = ? AND g.scope = ?`,
claimValue, now, now, groupID, strings.TrimSpace(providerID), UserGroupScopeExplicit)
if err != nil {
return err
}
affected, err = result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return fmt.Errorf("explicit user group not found for group mapping: %s", groupID)
}
}
return nil
}
func (s *Store) ListAuthProviders() ([]models.AuthProvider, error) {
var rows *sql.Rows
var items []models.AuthProvider
@@ -85,7 +191,11 @@ func (s *Store) ListAuthProviders() ([]models.AuthProvider, error) {
}
items = append(items, item)
}
return items, rows.Err()
err = rows.Err()
if err != nil {
return nil, err
}
return s.fillAuthProviderGroupMappings(items)
}
func (s *Store) ListEnabledAuthProviders() ([]models.AuthProvider, error) {
@@ -106,7 +216,11 @@ func (s *Store) ListEnabledAuthProviders() ([]models.AuthProvider, error) {
}
items = append(items, item)
}
return items, rows.Err()
err = rows.Err()
if err != nil {
return nil, err
}
return s.fillAuthProviderGroupMappings(items)
}
func (s *Store) GetAuthProvider(id string) (models.AuthProvider, error) {
@@ -116,6 +230,10 @@ func (s *Store) GetAuthProvider(id string) (models.AuthProvider, error) {
row = s.QueryRow(`SELECT`+authProviderSelectCols+authProviderFromClause+` WHERE p.public_id = ?`, strings.TrimSpace(id))
err = scanAuthProvider(row, &item)
if err != nil {
return item, err
}
item.GroupMappings, err = s.ListAuthProviderGroupMappings(item.ID)
return item, err
}
@@ -130,7 +248,9 @@ func (s *Store) CountAuthProviderUsers(id string) (int, error) {
}
func (s *Store) CreateAuthProvider(item models.AuthProvider) (models.AuthProvider, error) {
var tx *sql.Tx
var err error
var owned bool
var now int64
now = time.Now().Unix()
@@ -149,26 +269,45 @@ func (s *Store) CreateAuthProvider(item models.AuthProvider) (models.AuthProvide
if item.Type == AuthProviderTypeOIDC && strings.TrimSpace(item.OIDCScopes) == "" {
item.OIDCScopes = "openid profile email"
}
item.GroupSyncMode, err = normalizeAuthProviderGroupSyncMode(item.GroupSyncMode)
if err != nil {
return item, err
}
_, err = s.Exec(`INSERT INTO auth_providers (
tx, owned, err = s.begin()
if err != nil {
return item, err
}
_, err = tx.Exec(`INSERT INTO auth_providers (
public_id, name, type, enabled,
ldap_url, ldap_bind_dn, ldap_bind_password, ldap_user_base_dn, ldap_user_filter,
ldap_tls_insecure_skip_verify,
oidc_client_id, oidc_client_secret, oidc_authorize_url, oidc_token_url,
oidc_userinfo_url, oidc_redirect_url, oidc_scopes, oidc_tls_insecure_skip_verify,
oidc_groups_claim, oidc_admission_expr,
oidc_groups_claim, oidc_admission_expr, oidc_end_session_url, group_sync_mode,
default_user_group_id,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM user_groups WHERE public_id = ?) END, ?, ?)`,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM user_groups WHERE public_id = ?) END, ?, ?)`,
item.ID, item.Name, item.Type, item.Enabled,
item.LDAPUrl, item.LDAPBindDN, item.LDAPBindPassword, item.LDAPUserBaseDN, item.LDAPUserFilter,
item.LDAPTLSInsecureSkipVerify,
item.OIDCClientID, item.OIDCClientSecret, item.OIDCAuthorizeURL, item.OIDCTokenURL,
item.OIDCUserInfoURL, item.OIDCRedirectURL, item.OIDCScopes, item.OIDCTLSInsecureSkipVerify,
item.OIDCGroupsClaim, item.OIDCAdmissionExpr,
item.OIDCGroupsClaim, item.OIDCAdmissionExpr, item.OIDCEndSessionURL, item.GroupSyncMode,
item.DefaultUserGroupID, item.DefaultUserGroupID,
item.CreatedAt, item.UpdatedAt,
)
if err != nil {
rollbackIfOwned(tx, owned)
return item, err
}
err = insertAuthProviderGroupMappings(tx, item.ID, item.GroupMappings, now)
if err != nil {
rollbackIfOwned(tx, owned)
return item, err
}
err = commitIfOwned(tx, owned)
if err != nil {
return item, err
}
@@ -176,7 +315,9 @@ func (s *Store) CreateAuthProvider(item models.AuthProvider) (models.AuthProvide
}
func (s *Store) UpdateAuthProvider(item models.AuthProvider) (models.AuthProvider, error) {
var tx *sql.Tx
var err error
var owned bool
var now int64
now = time.Now().Unix()
@@ -188,14 +329,23 @@ func (s *Store) UpdateAuthProvider(item models.AuthProvider) (models.AuthProvide
if item.Type == AuthProviderTypeOIDC && strings.TrimSpace(item.OIDCScopes) == "" {
item.OIDCScopes = "openid profile email"
}
item.GroupSyncMode, err = normalizeAuthProviderGroupSyncMode(item.GroupSyncMode)
if err != nil {
return item, err
}
_, err = s.Exec(`UPDATE auth_providers SET
tx, owned, err = s.begin()
if err != nil {
return item, err
}
_, err = tx.Exec(`UPDATE auth_providers SET
name = ?, enabled = ?,
ldap_url = ?, ldap_bind_dn = ?, ldap_bind_password = ?, ldap_user_base_dn = ?, ldap_user_filter = ?,
ldap_tls_insecure_skip_verify = ?,
oidc_client_id = ?, oidc_client_secret = ?, oidc_authorize_url = ?, oidc_token_url = ?,
oidc_userinfo_url = ?, oidc_redirect_url = ?, oidc_scopes = ?, oidc_tls_insecure_skip_verify = ?,
oidc_groups_claim = ?, oidc_admission_expr = ?,
oidc_groups_claim = ?, oidc_admission_expr = ?, oidc_end_session_url = ?, group_sync_mode = ?,
default_user_group_id = CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM user_groups WHERE public_id = ?) END,
updated_at = ?
WHERE public_id = ?`,
@@ -204,11 +354,26 @@ func (s *Store) UpdateAuthProvider(item models.AuthProvider) (models.AuthProvide
item.LDAPTLSInsecureSkipVerify,
item.OIDCClientID, item.OIDCClientSecret, item.OIDCAuthorizeURL, item.OIDCTokenURL,
item.OIDCUserInfoURL, item.OIDCRedirectURL, item.OIDCScopes, item.OIDCTLSInsecureSkipVerify,
item.OIDCGroupsClaim, item.OIDCAdmissionExpr,
item.OIDCGroupsClaim, item.OIDCAdmissionExpr, item.OIDCEndSessionURL, item.GroupSyncMode,
item.DefaultUserGroupID, item.DefaultUserGroupID,
item.UpdatedAt,
strings.TrimSpace(item.ID),
)
if err != nil {
rollbackIfOwned(tx, owned)
return item, err
}
_, err = tx.Exec(`DELETE FROM auth_provider_group_mappings WHERE auth_provider_id = (SELECT id FROM auth_providers WHERE public_id = ?)`, strings.TrimSpace(item.ID))
if err != nil {
rollbackIfOwned(tx, owned)
return item, err
}
err = insertAuthProviderGroupMappings(tx, item.ID, item.GroupMappings, now)
if err != nil {
rollbackIfOwned(tx, owned)
return item, err
}
err = commitIfOwned(tx, owned)
if err != nil {
return item, err
}
+13
View File
@@ -90,9 +90,22 @@ func (s *Store) CreateUserGroup(item models.UserGroup) (models.UserGroup, error)
func (s *Store) UpdateUserGroup(item models.UserGroup) (models.UserGroup, error) {
var now int64
var err error
var mappingCount int
now = time.Now().UTC().Unix()
item.UpdatedAt = now
item.Scope = NormalizeUserGroupScope(item.Scope)
if item.Scope != UserGroupScopeExplicit {
err = s.QueryRow(`SELECT COUNT(*)
FROM auth_provider_group_mappings m
JOIN user_groups g ON g.id = m.group_id
WHERE g.public_id = ?`, strings.TrimSpace(item.ID)).Scan(&mappingCount)
if err != nil {
return item, err
}
if mappingCount > 0 {
return item, errors.New("group is used by auth provider group mappings")
}
}
_, err = s.Exec(`UPDATE user_groups
SET name = ?, description = ?, disabled = ?, totp_required = ?, scope = ?, updated_at = ?
WHERE public_id = ?`,
+17
View File
@@ -561,9 +561,22 @@ func (api *API) issueSession(w http.ResponseWriter, r *http.Request, user models
func (api *API) Logout(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var cookie *http.Cookie
var endSessionURL string
var err error
cookie, err = r.Cookie(api.sessionCookieName())
if err == nil {
var user models.User
var userErr error
user, _, _, userErr = api.store(r).GetSessionUser(cookie.Value)
if userErr == nil && strings.TrimSpace(user.AuthProviderID) != "" {
var provider models.AuthProvider
var provErr error
provider, provErr = api.store(r).GetAuthProvider(user.AuthProviderID)
if provErr == nil {
endSessionURL = strings.TrimSpace(provider.OIDCEndSessionURL)
}
}
_ = api.store(r).DeleteSession(cookie.Value)
}
cookie = &http.Cookie{
@@ -574,6 +587,10 @@ func (api *API) Logout(w http.ResponseWriter, r *http.Request, _ map[string]stri
HttpOnly: true,
}
http.SetCookie(w, cookie)
if endSessionURL != "" {
WriteJSON(w, http.StatusOK, map[string]string{"oidc_end_session_url": endSessionURL})
return
}
w.WriteHeader(http.StatusNoContent)
}
+81 -3
View File
@@ -387,6 +387,8 @@ func (api *API) oidcGetOrCreateUser(ctx context.Context, claims oidcUserClaims,
if user.Disabled {
return user, errors.New("user disabled")
}
err = api.oidcSyncUserGroups(ctx, provider, user, claims, false)
if err != nil { return user, err }
return user, nil
}
if !errors.Is(err, sql.ErrNoRows) { return user, err }
@@ -408,12 +410,88 @@ func (api *API) oidcGetOrCreateUser(ctx context.Context, claims oidcUserClaims,
created, err = api.storeContext(ctx).CreateUser(user, "")
if err != nil { return created, err }
if provider.DefaultUserGroupID != "" {
_ = api.storeContext(ctx).AddUserGroupMember(provider.DefaultUserGroupID, created.ID)
}
err = api.oidcSyncUserGroups(ctx, provider, created, claims, true)
if err != nil { return created, err }
return created, nil
}
func (api *API) oidcSyncUserGroups(ctx context.Context, provider models.AuthProvider, user models.User, claims oidcUserClaims, isNew bool) error {
var mode string
var groupsClaim string
var claimValues []string
var claimSet map[string]bool
var mappings []models.AuthProviderGroupMapping
var groupMatched map[string]bool
var err error
var assignedAny bool
var i int
var groupID string
var matched bool
mode = strings.TrimSpace(provider.GroupSyncMode)
if mode == db.GroupSyncModeOff || mode == "" {
if isNew && strings.TrimSpace(provider.DefaultUserGroupID) != "" {
err = api.storeContext(ctx).AddUserGroupMember(provider.DefaultUserGroupID, user.ID)
if err != nil { return fmt.Errorf("oidc default user group assignment failed: %w", err) }
}
return nil
}
if mode == db.GroupSyncModeFirstLogin && !isNew {
return nil
}
if mode != db.GroupSyncModeFirstLogin && mode != db.GroupSyncModeSync {
return fmt.Errorf("invalid oidc group sync mode: %s", mode)
}
groupsClaim = strings.TrimSpace(provider.OIDCGroupsClaim)
if groupsClaim == "" {
groupsClaim = "groups"
}
claimValues = oidcExtractGroups(claims.Raw, groupsClaim)
claimSet = make(map[string]bool, len(claimValues))
for i = 0; i < len(claimValues); i++ {
claimSet[claimValues[i]] = true
}
mappings, err = api.storeContext(ctx).ListAuthProviderGroupMappings(provider.ID)
if err != nil {
return fmt.Errorf("oidc group mapping lookup failed: %w", err)
}
groupMatched = make(map[string]bool)
for i = 0; i < len(mappings); i++ {
groupID = strings.TrimSpace(mappings[i].GroupID)
if groupID == "" {
continue
}
if _, matched = groupMatched[groupID]; !matched {
groupMatched[groupID] = false
}
if claimSet[mappings[i].ClaimValue] {
groupMatched[groupID] = true
}
}
assignedAny = false
for groupID, matched = range groupMatched {
if matched {
err = api.storeContext(ctx).AddUserGroupMember(groupID, user.ID)
if err != nil { return fmt.Errorf("oidc sso group assignment failed: %w", err) }
assignedAny = true
} else if mode == db.GroupSyncModeSync {
err = api.storeContext(ctx).RemoveUserGroupMember(groupID, user.ID)
if err != nil { return fmt.Errorf("oidc sso group removal failed: %w", err) }
}
}
if isNew && !assignedAny && strings.TrimSpace(provider.DefaultUserGroupID) != "" {
err = api.storeContext(ctx).AddUserGroupMember(provider.DefaultUserGroupID, user.ID)
if err != nil { return fmt.Errorf("oidc default user group assignment failed: %w", err) }
}
return nil
}
// oidcAdmissionEnvType defines the expression environment for type-checking at save time.
// Variables exposed: email, email_domain, sub, username, groups ([]string), claims (raw map).
var oidcAdmissionEnvType = map[string]any{
+11
View File
@@ -49,12 +49,23 @@ type AuthProvider struct {
OIDCTLSInsecureSkipVerify bool `json:"oidc_tls_insecure_skip_verify"`
OIDCGroupsClaim string `json:"oidc_groups_claim"`
OIDCAdmissionExpr string `json:"oidc_admission_expr"`
OIDCEndSessionURL string `json:"oidc_end_session_url"`
GroupSyncMode string `json:"group_sync_mode"`
GroupMappings []AuthProviderGroupMapping `json:"group_mappings,omitempty"`
DefaultUserGroupID string `json:"default_user_group_id"`
UserCount int `json:"user_count,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type AuthProviderGroupMapping struct {
ClaimValue string `json:"claim_value"`
GroupID string `json:"group_id"`
GroupName string `json:"group_name,omitempty"`
CreatedAt int64 `json:"created_at,omitempty"`
UpdatedAt int64 `json:"updated_at,omitempty"`
}
type Project struct {
ID string `json:"id"`
Slug string `json:"slug"`
@@ -0,0 +1 @@
ALTER TABLE auth_providers ADD COLUMN oidc_end_session_url TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,16 @@
ALTER TABLE auth_providers ADD COLUMN group_sync_mode TEXT NOT NULL DEFAULT 'off';
CREATE TABLE IF NOT EXISTS auth_provider_group_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
auth_provider_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
claim_value TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (auth_provider_id) REFERENCES auth_providers(id) ON DELETE CASCADE,
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
UNIQUE (auth_provider_id, claim_value, group_id)
);
CREATE INDEX IF NOT EXISTS idx_auth_provider_group_mappings_provider ON auth_provider_group_mappings(auth_provider_id);
CREATE INDEX IF NOT EXISTS idx_auth_provider_group_mappings_claim ON auth_provider_group_mappings(auth_provider_id, claim_value);
+12 -1
View File
@@ -437,6 +437,14 @@ export interface PrincipalProjectRole {
created_at: number
}
export interface AuthProviderGroupMapping {
claim_value: string
group_id: string
group_name?: string
created_at?: number
updated_at?: number
}
export interface AuthProvider {
id: string
name: string
@@ -458,6 +466,9 @@ export interface AuthProvider {
oidc_tls_insecure_skip_verify: boolean
oidc_groups_claim: string
oidc_admission_expr: string
oidc_end_session_url: string
group_sync_mode: 'off' | 'first_login' | 'sync'
group_mappings?: AuthProviderGroupMapping[]
default_user_group_id: string
user_count?: number
created_at: number
@@ -1539,7 +1550,7 @@ export const api = {
method: 'POST',
body: JSON.stringify({ username, password, otp_code: otpCode || '' })
}),
logout: () => request<void>('/api/logout', { method: 'POST' }),
logout: () => request<{ oidc_end_session_url?: string } | null>('/api/logout', { method: 'POST' }),
me: () => request<User>('/api/me'),
updateMe: (payload: MeUpdatePayload) =>
request<User>('/api/me', { method: 'PATCH', body: JSON.stringify(payload) }),
+5 -1
View File
@@ -130,10 +130,14 @@ export default function Layout() {
const handleLogout = async () => {
closeAccountMenu()
await api.logout()
const result = await api.logout()
setUser(null)
if (result?.oidc_end_session_url) {
window.location.href = result.oidc_end_session_url
} else {
navigate('/login')
}
}
const openAccountMenu = (event: React.MouseEvent<HTMLElement>) => {
setAccountMenuAnchor(event.currentTarget)
@@ -7,7 +7,7 @@ import DialogTitle from '@mui/material/DialogTitle'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import FormDialogContent from './FormDialogContent'
import { AuthProvider, UserGroup } from '../api'
import { AuthProvider, AuthProviderGroupMapping, UserGroup } from '../api'
type AuthProviderDetailsDialogProps = {
item: AuthProvider | null
@@ -49,7 +49,6 @@ export default function AuthProviderDetailsDialog(props: AuthProviderDetailsDial
<TextField label="Type" value={item?.type || ''} InputProps={{ readOnly: true }} />
<TextField label="Enabled" value={yesNo(Boolean(item?.enabled))} InputProps={{ readOnly: true }} />
<TextField label="Users" value={String(item?.user_count ?? 0)} InputProps={{ readOnly: true }} />
<TextField label="Default User Group" value={defaultGroup ? `${defaultGroup.name} (${defaultGroup.id})` : item?.default_user_group_id || '-'} InputProps={{ readOnly: true }} />
{item?.type === 'ldap' ? (
<>
<TextField label="LDAP URL" value={item.ldap_url || ''} InputProps={{ readOnly: true }} />
@@ -58,6 +57,7 @@ export default function AuthProviderDetailsDialog(props: AuthProviderDetailsDial
<TextField label="User Base DN" value={item.ldap_user_base_dn || ''} InputProps={{ readOnly: true }} />
<TextField label="User Filter" value={item.ldap_user_filter || ''} InputProps={{ readOnly: true }} />
<TextField label="TLS Insecure Skip Verify" value={yesNo(item.ldap_tls_insecure_skip_verify)} InputProps={{ readOnly: true }} />
<TextField label="Fallback User Group" value={defaultGroup ? `${defaultGroup.name} (${defaultGroup.id})` : item.default_user_group_id || '-'} InputProps={{ readOnly: true }} />
</>
) : null}
{item?.type === 'oidc' ? (
@@ -70,8 +70,25 @@ export default function AuthProviderDetailsDialog(props: AuthProviderDetailsDial
<TextField label="Redirect URL" value={item.oidc_redirect_url || ''} InputProps={{ readOnly: true }} />
<TextField label="Scopes" value={item.oidc_scopes || ''} InputProps={{ readOnly: true }} />
<TextField label="TLS Insecure Skip Verify" value={yesNo(item.oidc_tls_insecure_skip_verify)} InputProps={{ readOnly: true }} />
<TextField label="Group Sync Mode" value={item.group_sync_mode || 'off'} InputProps={{ readOnly: true }} />
<TextField label="Fallback User Group" value={defaultGroup ? `${defaultGroup.name} (${defaultGroup.id})` : item.default_user_group_id || '-'} InputProps={{ readOnly: true }} />
<TextField label="Groups Claim" value={item.oidc_groups_claim || 'groups'} InputProps={{ readOnly: true }} />
<Box>
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>Group Mappings</Typography>
{item.group_mappings && item.group_mappings.length > 0 ? (
<Box sx={{ display: 'grid', gap: 0.5 }}>
{item.group_mappings.map((mapping: AuthProviderGroupMapping, index: number) => (
<Typography key={`${mapping.claim_value}:${mapping.group_id}:${index}`} variant="body2">
{mapping.claim_value} {mapping.group_name || mapping.group_id}
</Typography>
))}
</Box>
) : (
<Typography variant="body2" color="text.secondary">No group mappings configured.</Typography>
)}
</Box>
<TextField label="Admission Expression" value={item.oidc_admission_expr || '-'} InputProps={{ readOnly: true }} multiline minRows={2} />
<TextField label="End Session URL" value={item.oidc_end_session_url || '-'} InputProps={{ readOnly: true }} />
</>
) : null}
<TextField label="Created At" value={fmt(item?.created_at)} InputProps={{ readOnly: true }} />
@@ -5,17 +5,19 @@ import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel'
import IconButton from '@mui/material/IconButton'
import MenuItem from '@mui/material/MenuItem'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
import DeleteIcon from '@mui/icons-material/Delete'
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
import { useEffect, useRef, useState } from 'react'
import Autocomplete from './Autocomplete'
import FormDialogContent from './FormDialogContent'
import PageAlert from './PageAlert'
import SelectField from './SelectField'
import { api, AuthProvider, UserGroup } from '../api'
import { api, AuthProvider, AuthProviderGroupMapping, UserGroup } from '../api'
export type AuthProviderFormState = {
name: string
@@ -37,6 +39,9 @@ export type AuthProviderFormState = {
oidc_tls_insecure_skip_verify: boolean
oidc_groups_claim: string
oidc_admission_expr: string
oidc_end_session_url: string
group_sync_mode: 'off' | 'first_login' | 'sync'
group_mappings: AuthProviderGroupMapping[]
default_user_group_id: string
}
@@ -74,6 +79,9 @@ export function emptyAuthProviderForm(): AuthProviderFormState {
oidc_tls_insecure_skip_verify: false,
oidc_groups_claim: 'groups',
oidc_admission_expr: '',
oidc_end_session_url: '',
group_sync_mode: 'off',
group_mappings: [],
default_user_group_id: ''
}
}
@@ -99,6 +107,13 @@ export function authProviderToForm(item: AuthProvider): AuthProviderFormState {
oidc_tls_insecure_skip_verify: item.oidc_tls_insecure_skip_verify,
oidc_groups_claim: item.oidc_groups_claim || 'groups',
oidc_admission_expr: item.oidc_admission_expr || '',
oidc_end_session_url: item.oidc_end_session_url || '',
group_sync_mode: (item.group_sync_mode as 'off' | 'first_login' | 'sync') || 'off',
group_mappings: Array.isArray(item.group_mappings) ? item.group_mappings.map((mapping: AuthProviderGroupMapping) => ({
claim_value: mapping.claim_value || '',
group_id: mapping.group_id || '',
group_name: mapping.group_name || ''
})) : [],
default_user_group_id: item.default_user_group_id
}
}
@@ -113,6 +128,49 @@ export default function AuthProviderFormDialog(props: AuthProviderFormDialogProp
const [testPassword, setTestPassword] = useState<string>('')
const testControllerRef = useRef<AbortController | null>(null)
const updateGroupMapping = (index: number, patch: Partial<AuthProviderGroupMapping>) => {
props.setForm((prev: AuthProviderFormState) => {
const mappings: AuthProviderGroupMapping[] = prev.group_mappings.map((mapping: AuthProviderGroupMapping, mappingIndex: number) => (
mappingIndex === index ? { ...mapping, ...patch } : mapping
))
return { ...prev, group_mappings: mappings }
})
}
const addGroupMapping = () => {
props.setForm((prev: AuthProviderFormState) => ({
...prev,
group_mappings: [...prev.group_mappings, { claim_value: '', group_id: '' }]
}))
}
const removeGroupMapping = (index: number) => {
props.setForm((prev: AuthProviderFormState) => ({
...prev,
group_mappings: prev.group_mappings.filter((_mapping: AuthProviderGroupMapping, mappingIndex: number) => mappingIndex !== index)
}))
}
function fallbackUserGroupField(helperText: string) {
return (
<Autocomplete<UserGroup, false, false, false>
options={props.userGroups}
value={props.userGroups.find((group: UserGroup) => group.id === props.form.default_user_group_id) ?? null}
onChange={(_event, value) => props.setForm((prev) => ({ ...prev, default_user_group_id: value?.id ?? '' }))}
getOptionLabel={(group) => group.name}
isOptionEqualToValue={(option, value) => option.id === value.id}
noOptionsText="No user groups"
renderInput={(params) => (
<TextField
{...params}
label="Fallback User Group"
helperText={helperText}
/>
)}
/>
)
}
useEffect(() => {
if (!props.open) {
setTesting(false)
@@ -191,21 +249,6 @@ export default function AuthProviderFormDialog(props: AuthProviderFormDialogProp
/>
{props.editingProvider?.type !== 'db' ? (
<>
<Autocomplete<UserGroup, false, false, false>
options={props.userGroups}
value={props.userGroups.find((group: UserGroup) => group.id === props.form.default_user_group_id) ?? null}
onChange={(_event, value) => props.setForm((prev) => ({ ...prev, default_user_group_id: value?.id ?? '' }))}
getOptionLabel={(group) => group.name}
isOptionEqualToValue={(option, value) => option.id === value.id}
noOptionsText="No user groups"
renderInput={(params) => (
<TextField
{...params}
label="Default User Group"
helperText="New users from this provider are added to this group automatically."
/>
)}
/>
{isLDAP ? (
<>
<TextField label="LDAP URL" value={props.form.ldap_url} onChange={(event) => props.setForm((prev) => ({ ...prev, ldap_url: event.target.value }))} helperText="Example: ldap://ldap.example.com:389" />
@@ -217,6 +260,10 @@ export default function AuthProviderFormDialog(props: AuthProviderFormDialogProp
control={<Checkbox checked={props.form.ldap_tls_insecure_skip_verify} onChange={(event) => props.setForm((prev) => ({ ...prev, ldap_tls_insecure_skip_verify: event.target.checked }))} />}
label="TLS insecure skip verify (testing/self-signed only)"
/>
<Box sx={{ borderTop: 1, borderColor: 'divider', pt: 1.5, mt: 0.5, display: 'grid', gap: 1 }}>
<Typography variant="subtitle2">Group</Typography>
{fallbackUserGroupField('New users from this provider are added to this group automatically.')}
</Box>
</>
) : null}
{isOIDC ? (
@@ -238,8 +285,60 @@ export default function AuthProviderFormDialog(props: AuthProviderFormDialogProp
label="OIDC TLS insecure skip verify (testing/self-signed only)"
/>
<Box sx={{ borderTop: 1, borderColor: 'divider', pt: 1.5, mt: 0.5, display: 'grid', gap: 1 }}>
<Typography variant="subtitle2">Admission</Typography>
<Typography variant="subtitle2">Group</Typography>
{fallbackUserGroupField('New users from this provider are added to this group when no OIDC group mapping matches.')}
<SelectField
select
label="Group Sync Mode"
value={props.form.group_sync_mode}
onChange={(event) => props.setForm((prev) => ({ ...prev, group_sync_mode: event.target.value as 'off' | 'first_login' | 'sync' }))}
helperText="How OIDC group claims map to local user groups (matched by SSO Group Claim on each group)."
>
<MenuItem value="off">Off no claim-based assignment</MenuItem>
<MenuItem value="first_login">First login assign matching groups on account creation only</MenuItem>
<MenuItem value="sync">Sync re-sync on every login (adds and removes SSO-mapped groups)</MenuItem>
</SelectField>
<TextField label="Groups Claim" value={props.form.oidc_groups_claim} onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_groups_claim: event.target.value }))} helperText="Claim name containing group membership. Default: groups" />
<Box sx={{ display: 'grid', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Typography variant="subtitle2">Group Mappings</Typography>
<Button size="small" variant="outlined" onClick={addGroupMapping}>Add Mapping</Button>
</Box>
{props.form.group_mappings.length === 0 ? (
<Typography variant="caption" color="text.secondary">
No claim-to-group mappings configured.
</Typography>
) : null}
{props.form.group_mappings.map((mapping: AuthProviderGroupMapping, index: number) => (
<Box key={index} sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr auto' }, gap: 1, alignItems: 'center' }}>
<TextField
label="Claim Value"
value={mapping.claim_value}
onChange={(event) => updateGroupMapping(index, { claim_value: event.target.value })}
/>
<Autocomplete<UserGroup, false, false, false>
options={props.userGroups}
value={props.userGroups.find((group: UserGroup) => group.id === mapping.group_id) ?? null}
onChange={(_event, value) => updateGroupMapping(index, { group_id: value?.id ?? '', group_name: value?.name ?? '' })}
getOptionLabel={(group) => group.name}
isOptionEqualToValue={(option, value) => option.id === value.id}
noOptionsText="No explicit user groups"
renderInput={(params) => (
<TextField
{...params}
label="User Group"
/>
)}
/>
<IconButton size="small" onClick={() => removeGroupMapping(index)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
))}
</Box>
</Box>
<Box sx={{ borderTop: 1, borderColor: 'divider', pt: 1.5, mt: 0.5, display: 'grid', gap: 1 }}>
<Typography variant="subtitle2">Admission</Typography>
<TextField
label="Admission Expression (optional)"
value={props.form.oidc_admission_expr}
@@ -256,6 +355,15 @@ export default function AuthProviderFormDialog(props: AuthProviderFormDialogProp
}
/>
</Box>
<Box sx={{ borderTop: 1, borderColor: 'divider', pt: 1.5, mt: 0.5, display: 'grid', gap: 1 }}>
<Typography variant="subtitle2">Sign-out</Typography>
<TextField
label="End Session URL (optional)"
value={props.form.oidc_end_session_url}
onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_end_session_url: event.target.value }))}
helperText="RP-initiated logout endpoint (end_session_endpoint). When set, users are redirected here on logout to also clear their SSO session."
/>
</Box>
</>
) : null}
{props.editingProvider && props.editingProvider.type === 'ldap' ? (
+4 -4
View File
@@ -44,7 +44,7 @@ function defaultUserGroupForm(): UserGroupFormState {
description: '',
disabled: false,
totpRequired: false,
scope: 'explicit'
scope: 'explicit',
}
}
@@ -166,7 +166,7 @@ export default function AdminUserGroupsPage() {
description: item.description || '',
disabled: item.disabled,
totpRequired: Boolean(item.totp_required),
scope: item.scope === 'all_users' ? 'all_users' : 'explicit'
scope: item.scope === 'all_users' ? 'all_users' : 'explicit',
})
setEditOpen(true)
}
@@ -182,7 +182,7 @@ export default function AdminUserGroupsPage() {
description: form.description.trim(),
disabled: form.disabled,
totp_required: form.totpRequired,
scope: form.scope
scope: form.scope,
}
if (editID) {
await api.updateUserGroup(editID, payload)
@@ -321,7 +321,7 @@ export default function AdminUserGroupsPage() {
description: selectedGroup.description || '',
disabled: selectedGroup.disabled,
totp_required: next,
scope: selectedGroup.scope === 'all_users' ? 'all_users' : 'explicit'
scope: selectedGroup.scope === 'all_users' ? 'all_users' : 'explicit',
}
await api.updateUserGroup(selectedGroup.id, payload)
await loadGroups()
+35 -5
View File
@@ -14,7 +14,8 @@ import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import UserFormDialog, { UserFormState } from '../components/UserFormDialog'
import { api, SubjectPermission, User, UserCreatePayload, UserUpdatePayload } from '../api'
import AuthProviderDetailsDialog from '../components/AuthProviderDetailsDialog'
import { api, AuthProvider, SubjectPermission, User, UserCreatePayload, UserUpdatePayload, UserGroup } from '../api'
import CompactListItemText from '../components/CompactListItemText'
import PageAlert from '../components/PageAlert'
@@ -53,6 +54,9 @@ export default function AdminUsersPage() {
const [resetTOTPConfirm, setResetTOTPConfirm] = useState('')
const [resettingTOTP, setResettingTOTP] = useState(false)
const [userSearch, setUserSearch] = useState('')
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([])
const [authProviderDetail, setAuthProviderDetail] = useState<AuthProvider | null>(null)
const [authProviderUserGroups, setAuthProviderUserGroups] = useState<UserGroup[]>([])
const filteredUsers = useMemo(() => {
const normalized: string = userSearch.trim().toLowerCase()
@@ -77,12 +81,14 @@ export default function AdminUsersPage() {
}, [])
const reloadUsers = async () => {
const [list, permissions] = await Promise.all([
const [list, permissions, providers] = await Promise.all([
api.listUsers(),
api.listSubjectPermissions(projectCreatePermission, 'user')
api.listSubjectPermissions(projectCreatePermission, 'user'),
api.listAuthProviders(),
])
setUsers(Array.isArray(list) ? list : [])
setSubjectPermissions(Array.isArray(permissions) ? permissions : [])
setAuthProviders(Array.isArray(providers) ? providers : [])
}
const hasDirectProjectCreate = (userID: string) => {
@@ -264,6 +270,15 @@ export default function AdminUsersPage() {
}
}
const openAuthProviderDetail = (id: string) => {
Promise.all([api.getAuthProvider(id), api.listUserGroups()])
.then(([provider, groups]) => {
setAuthProviderUserGroups(Array.isArray(groups) ? groups : [])
setAuthProviderDetail(provider)
})
.catch(() => {})
}
return (
<Box>
<Typography variant="h5" sx={{ mb: 2 }}>Admin: Users</Typography>
@@ -304,8 +319,18 @@ export default function AdminUsersPage() {
</Typography>
}
secondary={
<Typography variant="caption" color="text.secondary">
{u.is_admin ? 'admin' : 'user'} · source: {u.auth_provider_id || 'db'}{u.totp_required ? ' · TOTP required' : ''}{u.totp_enabled ? ' · TOTP enabled' : ''}{hasDirectProjectCreate(u.id) ? ' · direct project.create' : ''}
<Typography variant="caption" color="text.secondary" component="span">
{u.is_admin ? 'admin' : 'user'} · source:{' '}
{u.auth_provider_id ? (
<Typography
component="span" variant="caption"
sx={{ color: 'primary.main', cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}
onClick={() => openAuthProviderDetail(u.auth_provider_id!)}
>
{authProviders.find((p) => p.id === u.auth_provider_id)?.name ?? u.auth_provider_id}
</Typography>
) : 'db'}
{u.totp_required ? ' · TOTP required' : ''}{u.totp_enabled ? ' · TOTP enabled' : ''}{hasDirectProjectCreate(u.id) ? ' · direct project.create' : ''}
</Typography>
}
/>
@@ -381,6 +406,11 @@ export default function AdminUsersPage() {
onConfirm={handleResetTOTP}
onClose={() => { setResetTOTPUser(null); setResetTOTPConfirm('') }}
/>
<AuthProviderDetailsDialog
item={authProviderDetail}
userGroups={authProviderUserGroups}
onClose={() => setAuthProviderDetail(null)}
/>
</Box>
)
}