Compare commits
4 Commits
fa44e0a762
...
f88195aba9
| Author | SHA1 | Date | |
|---|---|---|---|
| f88195aba9 | |||
| 048454600d | |||
| cd6f2ef5e3 | |||
| 307e856dfe |
@@ -2,6 +2,7 @@ package db
|
|||||||
|
|
||||||
import "database/sql"
|
import "database/sql"
|
||||||
import "errors"
|
import "errors"
|
||||||
|
import "fmt"
|
||||||
import "strings"
|
import "strings"
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
@@ -14,6 +15,10 @@ const AuthProviderTypeOIDC = "oidc"
|
|||||||
|
|
||||||
const BuiltinDBProviderPublicID = "db"
|
const BuiltinDBProviderPublicID = "db"
|
||||||
|
|
||||||
|
const GroupSyncModeOff string = "off"
|
||||||
|
const GroupSyncModeFirstLogin string = "first_login"
|
||||||
|
const GroupSyncModeSync string = "sync"
|
||||||
|
|
||||||
func scanAuthProvider(row interface {
|
func scanAuthProvider(row interface {
|
||||||
Scan(dest ...any) error
|
Scan(dest ...any) error
|
||||||
}, item *models.AuthProvider) error {
|
}, item *models.AuthProvider) error {
|
||||||
@@ -40,6 +45,8 @@ func scanAuthProvider(row interface {
|
|||||||
&item.OIDCTLSInsecureSkipVerify,
|
&item.OIDCTLSInsecureSkipVerify,
|
||||||
&item.OIDCGroupsClaim,
|
&item.OIDCGroupsClaim,
|
||||||
&item.OIDCAdmissionExpr,
|
&item.OIDCAdmissionExpr,
|
||||||
|
&item.OIDCEndSessionURL,
|
||||||
|
&item.GroupSyncMode,
|
||||||
&defaultUserGroupID,
|
&defaultUserGroupID,
|
||||||
&item.UserCount,
|
&item.UserCount,
|
||||||
&item.CreatedAt,
|
&item.CreatedAt,
|
||||||
@@ -58,7 +65,7 @@ const authProviderSelectCols = `
|
|||||||
p.ldap_tls_insecure_skip_verify,
|
p.ldap_tls_insecure_skip_verify,
|
||||||
p.oidc_client_id, p.oidc_client_secret, p.oidc_authorize_url, p.oidc_token_url,
|
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_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, ''),
|
COALESCE(ug.public_id, ''),
|
||||||
(SELECT COUNT(*) FROM users WHERE auth_provider_id = p.id),
|
(SELECT COUNT(*) FROM users WHERE auth_provider_id = p.id),
|
||||||
p.created_at, p.updated_at`
|
p.created_at, p.updated_at`
|
||||||
@@ -67,6 +74,105 @@ const authProviderFromClause = `
|
|||||||
FROM auth_providers p
|
FROM auth_providers p
|
||||||
LEFT JOIN user_groups ug ON ug.id = p.default_user_group_id`
|
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) {
|
func (s *Store) ListAuthProviders() ([]models.AuthProvider, error) {
|
||||||
var rows *sql.Rows
|
var rows *sql.Rows
|
||||||
var items []models.AuthProvider
|
var items []models.AuthProvider
|
||||||
@@ -85,7 +191,11 @@ func (s *Store) ListAuthProviders() ([]models.AuthProvider, error) {
|
|||||||
}
|
}
|
||||||
items = append(items, item)
|
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) {
|
func (s *Store) ListEnabledAuthProviders() ([]models.AuthProvider, error) {
|
||||||
@@ -106,7 +216,11 @@ func (s *Store) ListEnabledAuthProviders() ([]models.AuthProvider, error) {
|
|||||||
}
|
}
|
||||||
items = append(items, item)
|
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) {
|
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))
|
row = s.QueryRow(`SELECT`+authProviderSelectCols+authProviderFromClause+` WHERE p.public_id = ?`, strings.TrimSpace(id))
|
||||||
err = scanAuthProvider(row, &item)
|
err = scanAuthProvider(row, &item)
|
||||||
|
if err != nil {
|
||||||
|
return item, err
|
||||||
|
}
|
||||||
|
item.GroupMappings, err = s.ListAuthProviderGroupMappings(item.ID)
|
||||||
return item, err
|
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) {
|
func (s *Store) CreateAuthProvider(item models.AuthProvider) (models.AuthProvider, error) {
|
||||||
|
var tx *sql.Tx
|
||||||
var err error
|
var err error
|
||||||
|
var owned bool
|
||||||
var now int64
|
var now int64
|
||||||
|
|
||||||
now = time.Now().Unix()
|
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) == "" {
|
if item.Type == AuthProviderTypeOIDC && strings.TrimSpace(item.OIDCScopes) == "" {
|
||||||
item.OIDCScopes = "openid profile email"
|
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,
|
public_id, name, type, enabled,
|
||||||
ldap_url, ldap_bind_dn, ldap_bind_password, ldap_user_base_dn, ldap_user_filter,
|
ldap_url, ldap_bind_dn, ldap_bind_password, ldap_user_base_dn, ldap_user_filter,
|
||||||
ldap_tls_insecure_skip_verify,
|
ldap_tls_insecure_skip_verify,
|
||||||
oidc_client_id, oidc_client_secret, oidc_authorize_url, oidc_token_url,
|
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_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,
|
default_user_group_id,
|
||||||
created_at, updated_at
|
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.ID, item.Name, item.Type, item.Enabled,
|
||||||
item.LDAPUrl, item.LDAPBindDN, item.LDAPBindPassword, item.LDAPUserBaseDN, item.LDAPUserFilter,
|
item.LDAPUrl, item.LDAPBindDN, item.LDAPBindPassword, item.LDAPUserBaseDN, item.LDAPUserFilter,
|
||||||
item.LDAPTLSInsecureSkipVerify,
|
item.LDAPTLSInsecureSkipVerify,
|
||||||
item.OIDCClientID, item.OIDCClientSecret, item.OIDCAuthorizeURL, item.OIDCTokenURL,
|
item.OIDCClientID, item.OIDCClientSecret, item.OIDCAuthorizeURL, item.OIDCTokenURL,
|
||||||
item.OIDCUserInfoURL, item.OIDCRedirectURL, item.OIDCScopes, item.OIDCTLSInsecureSkipVerify,
|
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.DefaultUserGroupID, item.DefaultUserGroupID,
|
||||||
item.CreatedAt, item.UpdatedAt,
|
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 {
|
if err != nil {
|
||||||
return item, err
|
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) {
|
func (s *Store) UpdateAuthProvider(item models.AuthProvider) (models.AuthProvider, error) {
|
||||||
|
var tx *sql.Tx
|
||||||
var err error
|
var err error
|
||||||
|
var owned bool
|
||||||
var now int64
|
var now int64
|
||||||
|
|
||||||
now = time.Now().Unix()
|
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) == "" {
|
if item.Type == AuthProviderTypeOIDC && strings.TrimSpace(item.OIDCScopes) == "" {
|
||||||
item.OIDCScopes = "openid profile email"
|
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 = ?,
|
name = ?, enabled = ?,
|
||||||
ldap_url = ?, ldap_bind_dn = ?, ldap_bind_password = ?, ldap_user_base_dn = ?, ldap_user_filter = ?,
|
ldap_url = ?, ldap_bind_dn = ?, ldap_bind_password = ?, ldap_user_base_dn = ?, ldap_user_filter = ?,
|
||||||
ldap_tls_insecure_skip_verify = ?,
|
ldap_tls_insecure_skip_verify = ?,
|
||||||
oidc_client_id = ?, oidc_client_secret = ?, oidc_authorize_url = ?, oidc_token_url = ?,
|
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_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,
|
default_user_group_id = CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM user_groups WHERE public_id = ?) END,
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
WHERE public_id = ?`,
|
WHERE public_id = ?`,
|
||||||
@@ -204,11 +354,26 @@ func (s *Store) UpdateAuthProvider(item models.AuthProvider) (models.AuthProvide
|
|||||||
item.LDAPTLSInsecureSkipVerify,
|
item.LDAPTLSInsecureSkipVerify,
|
||||||
item.OIDCClientID, item.OIDCClientSecret, item.OIDCAuthorizeURL, item.OIDCTokenURL,
|
item.OIDCClientID, item.OIDCClientSecret, item.OIDCAuthorizeURL, item.OIDCTokenURL,
|
||||||
item.OIDCUserInfoURL, item.OIDCRedirectURL, item.OIDCScopes, item.OIDCTLSInsecureSkipVerify,
|
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.DefaultUserGroupID, item.DefaultUserGroupID,
|
||||||
item.UpdatedAt,
|
item.UpdatedAt,
|
||||||
strings.TrimSpace(item.ID),
|
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 {
|
if err != nil {
|
||||||
return item, err
|
return item, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,9 +90,22 @@ func (s *Store) CreateUserGroup(item models.UserGroup) (models.UserGroup, error)
|
|||||||
func (s *Store) UpdateUserGroup(item models.UserGroup) (models.UserGroup, error) {
|
func (s *Store) UpdateUserGroup(item models.UserGroup) (models.UserGroup, error) {
|
||||||
var now int64
|
var now int64
|
||||||
var err error
|
var err error
|
||||||
|
var mappingCount int
|
||||||
now = time.Now().UTC().Unix()
|
now = time.Now().UTC().Unix()
|
||||||
item.UpdatedAt = now
|
item.UpdatedAt = now
|
||||||
item.Scope = NormalizeUserGroupScope(item.Scope)
|
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
|
_, err = s.Exec(`UPDATE user_groups
|
||||||
SET name = ?, description = ?, disabled = ?, totp_required = ?, scope = ?, updated_at = ?
|
SET name = ?, description = ?, disabled = ?, totp_required = ?, scope = ?, updated_at = ?
|
||||||
WHERE public_id = ?`,
|
WHERE public_id = ?`,
|
||||||
|
|||||||
@@ -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) {
|
func (api *API) Logout(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
||||||
var cookie *http.Cookie
|
var cookie *http.Cookie
|
||||||
|
var endSessionURL string
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
cookie, err = r.Cookie(api.sessionCookieName())
|
cookie, err = r.Cookie(api.sessionCookieName())
|
||||||
if err == nil {
|
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)
|
_ = api.store(r).DeleteSession(cookie.Value)
|
||||||
}
|
}
|
||||||
cookie = &http.Cookie{
|
cookie = &http.Cookie{
|
||||||
@@ -574,6 +587,10 @@ func (api *API) Logout(w http.ResponseWriter, r *http.Request, _ map[string]stri
|
|||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
}
|
}
|
||||||
http.SetCookie(w, cookie)
|
http.SetCookie(w, cookie)
|
||||||
|
if endSessionURL != "" {
|
||||||
|
WriteJSON(w, http.StatusOK, map[string]string{"oidc_end_session_url": endSessionURL})
|
||||||
|
return
|
||||||
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -387,6 +387,8 @@ func (api *API) oidcGetOrCreateUser(ctx context.Context, claims oidcUserClaims,
|
|||||||
if user.Disabled {
|
if user.Disabled {
|
||||||
return user, errors.New("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
|
return user, nil
|
||||||
}
|
}
|
||||||
if !errors.Is(err, sql.ErrNoRows) { return user, err }
|
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, "")
|
created, err = api.storeContext(ctx).CreateUser(user, "")
|
||||||
if err != nil { return created, err }
|
if err != nil { return created, err }
|
||||||
|
|
||||||
if provider.DefaultUserGroupID != "" {
|
err = api.oidcSyncUserGroups(ctx, provider, created, claims, true)
|
||||||
_ = api.storeContext(ctx).AddUserGroupMember(provider.DefaultUserGroupID, created.ID)
|
if err != nil { return created, err }
|
||||||
}
|
|
||||||
return created, nil
|
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.
|
// oidcAdmissionEnvType defines the expression environment for type-checking at save time.
|
||||||
// Variables exposed: email, email_domain, sub, username, groups ([]string), claims (raw map).
|
// Variables exposed: email, email_domain, sub, username, groups ([]string), claims (raw map).
|
||||||
var oidcAdmissionEnvType = map[string]any{
|
var oidcAdmissionEnvType = map[string]any{
|
||||||
|
|||||||
@@ -49,12 +49,23 @@ type AuthProvider struct {
|
|||||||
OIDCTLSInsecureSkipVerify bool `json:"oidc_tls_insecure_skip_verify"`
|
OIDCTLSInsecureSkipVerify bool `json:"oidc_tls_insecure_skip_verify"`
|
||||||
OIDCGroupsClaim string `json:"oidc_groups_claim"`
|
OIDCGroupsClaim string `json:"oidc_groups_claim"`
|
||||||
OIDCAdmissionExpr string `json:"oidc_admission_expr"`
|
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"`
|
DefaultUserGroupID string `json:"default_user_group_id"`
|
||||||
UserCount int `json:"user_count,omitempty"`
|
UserCount int `json:"user_count,omitempty"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
UpdatedAt int64 `json:"updated_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 {
|
type Project struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Slug string `json:"slug"`
|
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
@@ -437,6 +437,14 @@ export interface PrincipalProjectRole {
|
|||||||
created_at: number
|
created_at: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuthProviderGroupMapping {
|
||||||
|
claim_value: string
|
||||||
|
group_id: string
|
||||||
|
group_name?: string
|
||||||
|
created_at?: number
|
||||||
|
updated_at?: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthProvider {
|
export interface AuthProvider {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -458,6 +466,9 @@ export interface AuthProvider {
|
|||||||
oidc_tls_insecure_skip_verify: boolean
|
oidc_tls_insecure_skip_verify: boolean
|
||||||
oidc_groups_claim: string
|
oidc_groups_claim: string
|
||||||
oidc_admission_expr: 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
|
default_user_group_id: string
|
||||||
user_count?: number
|
user_count?: number
|
||||||
created_at: number
|
created_at: number
|
||||||
@@ -1539,7 +1550,7 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ username, password, otp_code: otpCode || '' })
|
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'),
|
me: () => request<User>('/api/me'),
|
||||||
updateMe: (payload: MeUpdatePayload) =>
|
updateMe: (payload: MeUpdatePayload) =>
|
||||||
request<User>('/api/me', { method: 'PATCH', body: JSON.stringify(payload) }),
|
request<User>('/api/me', { method: 'PATCH', body: JSON.stringify(payload) }),
|
||||||
|
|||||||
@@ -130,10 +130,14 @@ export default function Layout() {
|
|||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
closeAccountMenu()
|
closeAccountMenu()
|
||||||
await api.logout()
|
const result = await api.logout()
|
||||||
setUser(null)
|
setUser(null)
|
||||||
|
if (result?.oidc_end_session_url) {
|
||||||
|
window.location.href = result.oidc_end_session_url
|
||||||
|
} else {
|
||||||
navigate('/login')
|
navigate('/login')
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const openAccountMenu = (event: React.MouseEvent<HTMLElement>) => {
|
const openAccountMenu = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
setAccountMenuAnchor(event.currentTarget)
|
setAccountMenuAnchor(event.currentTarget)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import DialogTitle from '@mui/material/DialogTitle'
|
|||||||
import TextField from '@mui/material/TextField'
|
import TextField from '@mui/material/TextField'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import FormDialogContent from './FormDialogContent'
|
import FormDialogContent from './FormDialogContent'
|
||||||
import { AuthProvider, UserGroup } from '../api'
|
import { AuthProvider, AuthProviderGroupMapping, UserGroup } from '../api'
|
||||||
|
|
||||||
type AuthProviderDetailsDialogProps = {
|
type AuthProviderDetailsDialogProps = {
|
||||||
item: AuthProvider | null
|
item: AuthProvider | null
|
||||||
@@ -49,7 +49,6 @@ export default function AuthProviderDetailsDialog(props: AuthProviderDetailsDial
|
|||||||
<TextField label="Type" value={item?.type || ''} InputProps={{ readOnly: true }} />
|
<TextField label="Type" value={item?.type || ''} InputProps={{ readOnly: true }} />
|
||||||
<TextField label="Enabled" value={yesNo(Boolean(item?.enabled))} 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="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' ? (
|
{item?.type === 'ldap' ? (
|
||||||
<>
|
<>
|
||||||
<TextField label="LDAP URL" value={item.ldap_url || ''} InputProps={{ readOnly: true }} />
|
<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 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="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="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}
|
) : null}
|
||||||
{item?.type === 'oidc' ? (
|
{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="Redirect URL" value={item.oidc_redirect_url || ''} InputProps={{ readOnly: true }} />
|
||||||
<TextField label="Scopes" value={item.oidc_scopes || ''} 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="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 }} />
|
<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="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}
|
) : null}
|
||||||
<TextField label="Created At" value={fmt(item?.created_at)} InputProps={{ readOnly: true }} />
|
<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 DialogActions from '@mui/material/DialogActions'
|
||||||
import DialogTitle from '@mui/material/DialogTitle'
|
import DialogTitle from '@mui/material/DialogTitle'
|
||||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||||
|
import IconButton from '@mui/material/IconButton'
|
||||||
import MenuItem from '@mui/material/MenuItem'
|
import MenuItem from '@mui/material/MenuItem'
|
||||||
import TextField from '@mui/material/TextField'
|
import TextField from '@mui/material/TextField'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
|
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
|
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import Autocomplete from './Autocomplete'
|
import Autocomplete from './Autocomplete'
|
||||||
import FormDialogContent from './FormDialogContent'
|
import FormDialogContent from './FormDialogContent'
|
||||||
import PageAlert from './PageAlert'
|
import PageAlert from './PageAlert'
|
||||||
import SelectField from './SelectField'
|
import SelectField from './SelectField'
|
||||||
import { api, AuthProvider, UserGroup } from '../api'
|
import { api, AuthProvider, AuthProviderGroupMapping, UserGroup } from '../api'
|
||||||
|
|
||||||
export type AuthProviderFormState = {
|
export type AuthProviderFormState = {
|
||||||
name: string
|
name: string
|
||||||
@@ -37,6 +39,9 @@ export type AuthProviderFormState = {
|
|||||||
oidc_tls_insecure_skip_verify: boolean
|
oidc_tls_insecure_skip_verify: boolean
|
||||||
oidc_groups_claim: string
|
oidc_groups_claim: string
|
||||||
oidc_admission_expr: 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
|
default_user_group_id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +79,9 @@ export function emptyAuthProviderForm(): AuthProviderFormState {
|
|||||||
oidc_tls_insecure_skip_verify: false,
|
oidc_tls_insecure_skip_verify: false,
|
||||||
oidc_groups_claim: 'groups',
|
oidc_groups_claim: 'groups',
|
||||||
oidc_admission_expr: '',
|
oidc_admission_expr: '',
|
||||||
|
oidc_end_session_url: '',
|
||||||
|
group_sync_mode: 'off',
|
||||||
|
group_mappings: [],
|
||||||
default_user_group_id: ''
|
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_tls_insecure_skip_verify: item.oidc_tls_insecure_skip_verify,
|
||||||
oidc_groups_claim: item.oidc_groups_claim || 'groups',
|
oidc_groups_claim: item.oidc_groups_claim || 'groups',
|
||||||
oidc_admission_expr: item.oidc_admission_expr || '',
|
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
|
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 [testPassword, setTestPassword] = useState<string>('')
|
||||||
const testControllerRef = useRef<AbortController | null>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
if (!props.open) {
|
if (!props.open) {
|
||||||
setTesting(false)
|
setTesting(false)
|
||||||
@@ -191,21 +249,6 @@ export default function AuthProviderFormDialog(props: AuthProviderFormDialogProp
|
|||||||
/>
|
/>
|
||||||
{props.editingProvider?.type !== 'db' ? (
|
{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 ? (
|
{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" />
|
<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 }))} />}
|
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)"
|
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}
|
) : null}
|
||||||
{isOIDC ? (
|
{isOIDC ? (
|
||||||
@@ -238,8 +285,60 @@ export default function AuthProviderFormDialog(props: AuthProviderFormDialogProp
|
|||||||
label="OIDC TLS insecure skip verify (testing/self-signed only)"
|
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 }}>
|
<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" />
|
<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
|
<TextField
|
||||||
label="Admission Expression (optional)"
|
label="Admission Expression (optional)"
|
||||||
value={props.form.oidc_admission_expr}
|
value={props.form.oidc_admission_expr}
|
||||||
@@ -256,6 +355,15 @@ export default function AuthProviderFormDialog(props: AuthProviderFormDialogProp
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</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}
|
) : null}
|
||||||
{props.editingProvider && props.editingProvider.type === 'ldap' ? (
|
{props.editingProvider && props.editingProvider.type === 'ldap' ? (
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ function defaultUserGroupForm(): UserGroupFormState {
|
|||||||
description: '',
|
description: '',
|
||||||
disabled: false,
|
disabled: false,
|
||||||
totpRequired: false,
|
totpRequired: false,
|
||||||
scope: 'explicit'
|
scope: 'explicit',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ export default function AdminUserGroupsPage() {
|
|||||||
description: item.description || '',
|
description: item.description || '',
|
||||||
disabled: item.disabled,
|
disabled: item.disabled,
|
||||||
totpRequired: Boolean(item.totp_required),
|
totpRequired: Boolean(item.totp_required),
|
||||||
scope: item.scope === 'all_users' ? 'all_users' : 'explicit'
|
scope: item.scope === 'all_users' ? 'all_users' : 'explicit',
|
||||||
})
|
})
|
||||||
setEditOpen(true)
|
setEditOpen(true)
|
||||||
}
|
}
|
||||||
@@ -182,7 +182,7 @@ export default function AdminUserGroupsPage() {
|
|||||||
description: form.description.trim(),
|
description: form.description.trim(),
|
||||||
disabled: form.disabled,
|
disabled: form.disabled,
|
||||||
totp_required: form.totpRequired,
|
totp_required: form.totpRequired,
|
||||||
scope: form.scope
|
scope: form.scope,
|
||||||
}
|
}
|
||||||
if (editID) {
|
if (editID) {
|
||||||
await api.updateUserGroup(editID, payload)
|
await api.updateUserGroup(editID, payload)
|
||||||
@@ -321,7 +321,7 @@ export default function AdminUserGroupsPage() {
|
|||||||
description: selectedGroup.description || '',
|
description: selectedGroup.description || '',
|
||||||
disabled: selectedGroup.disabled,
|
disabled: selectedGroup.disabled,
|
||||||
totp_required: next,
|
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 api.updateUserGroup(selectedGroup.id, payload)
|
||||||
await loadGroups()
|
await loadGroups()
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import HeaderActionButton from '../components/HeaderActionButton'
|
|||||||
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
|
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
|
||||||
import SectionCard from '../components/SectionCard'
|
import SectionCard from '../components/SectionCard'
|
||||||
import UserFormDialog, { UserFormState } from '../components/UserFormDialog'
|
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 CompactListItemText from '../components/CompactListItemText'
|
||||||
import PageAlert from '../components/PageAlert'
|
import PageAlert from '../components/PageAlert'
|
||||||
|
|
||||||
@@ -53,6 +54,9 @@ export default function AdminUsersPage() {
|
|||||||
const [resetTOTPConfirm, setResetTOTPConfirm] = useState('')
|
const [resetTOTPConfirm, setResetTOTPConfirm] = useState('')
|
||||||
const [resettingTOTP, setResettingTOTP] = useState(false)
|
const [resettingTOTP, setResettingTOTP] = useState(false)
|
||||||
const [userSearch, setUserSearch] = useState('')
|
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 filteredUsers = useMemo(() => {
|
||||||
const normalized: string = userSearch.trim().toLowerCase()
|
const normalized: string = userSearch.trim().toLowerCase()
|
||||||
@@ -77,12 +81,14 @@ export default function AdminUsersPage() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const reloadUsers = async () => {
|
const reloadUsers = async () => {
|
||||||
const [list, permissions] = await Promise.all([
|
const [list, permissions, providers] = await Promise.all([
|
||||||
api.listUsers(),
|
api.listUsers(),
|
||||||
api.listSubjectPermissions(projectCreatePermission, 'user')
|
api.listSubjectPermissions(projectCreatePermission, 'user'),
|
||||||
|
api.listAuthProviders(),
|
||||||
])
|
])
|
||||||
setUsers(Array.isArray(list) ? list : [])
|
setUsers(Array.isArray(list) ? list : [])
|
||||||
setSubjectPermissions(Array.isArray(permissions) ? permissions : [])
|
setSubjectPermissions(Array.isArray(permissions) ? permissions : [])
|
||||||
|
setAuthProviders(Array.isArray(providers) ? providers : [])
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasDirectProjectCreate = (userID: string) => {
|
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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h5" sx={{ mb: 2 }}>Admin: Users</Typography>
|
<Typography variant="h5" sx={{ mb: 2 }}>Admin: Users</Typography>
|
||||||
@@ -304,8 +319,18 @@ export default function AdminUsersPage() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
}
|
}
|
||||||
secondary={
|
secondary={
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary" component="span">
|
||||||
{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' : ''}
|
{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>
|
</Typography>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -381,6 +406,11 @@ export default function AdminUsersPage() {
|
|||||||
onConfirm={handleResetTOTP}
|
onConfirm={handleResetTOTP}
|
||||||
onClose={() => { setResetTOTPUser(null); setResetTOTPConfirm('') }}
|
onClose={() => { setResetTOTPUser(null); setResetTOTPConfirm('') }}
|
||||||
/>
|
/>
|
||||||
|
<AuthProviderDetailsDialog
|
||||||
|
item={authProviderDetail}
|
||||||
|
userGroups={authProviderUserGroups}
|
||||||
|
onClose={() => setAuthProviderDetail(null)}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user