Compare commits

...

6 Commits

36 changed files with 1880 additions and 1131 deletions
+229
View File
@@ -0,0 +1,229 @@
package db
import "database/sql"
import "errors"
import "strings"
import "time"
import "codit/internal/models"
import "codit/internal/util"
const AuthProviderTypeDB = "db"
const AuthProviderTypeLDAP = "ldap"
const AuthProviderTypeOIDC = "oidc"
const BuiltinDBProviderPublicID = "db"
func scanAuthProvider(row interface {
Scan(dest ...any) error
}, item *models.AuthProvider) error {
var defaultUserGroupID sql.NullString
var err error
err = row.Scan(
&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,
&defaultUserGroupID,
&item.UserCount,
&item.CreatedAt,
&item.UpdatedAt,
)
if err != nil {
return err
}
item.DefaultUserGroupID = defaultUserGroupID.String
return nil
}
const authProviderSelectCols = `
p.public_id, p.name, p.type, p.enabled,
p.ldap_url, p.ldap_bind_dn, p.ldap_bind_password, p.ldap_user_base_dn, p.ldap_user_filter,
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,
COALESCE(ug.public_id, ''),
(SELECT COUNT(*) FROM users WHERE auth_provider_id = p.id),
p.created_at, p.updated_at`
const authProviderFromClause = `
FROM auth_providers p
LEFT JOIN user_groups ug ON ug.id = p.default_user_group_id`
func (s *Store) ListAuthProviders() ([]models.AuthProvider, error) {
var rows *sql.Rows
var items []models.AuthProvider
var item models.AuthProvider
var err error
rows, err = s.Query(`SELECT` + authProviderSelectCols + authProviderFromClause + ` ORDER BY p.name`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
err = scanAuthProvider(rows, &item)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Store) ListEnabledAuthProviders() ([]models.AuthProvider, error) {
var rows *sql.Rows
var items []models.AuthProvider
var item models.AuthProvider
var err error
rows, err = s.Query(`SELECT` + authProviderSelectCols + authProviderFromClause + ` WHERE p.enabled = 1 ORDER BY p.created_at`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
err = scanAuthProvider(rows, &item)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Store) GetAuthProvider(id string) (models.AuthProvider, error) {
var row *sql.Row
var item models.AuthProvider
var err error
row = s.QueryRow(`SELECT`+authProviderSelectCols+authProviderFromClause+` WHERE p.public_id = ?`, strings.TrimSpace(id))
err = scanAuthProvider(row, &item)
return item, err
}
func (s *Store) CountAuthProviderUsers(id string) (int, error) {
var row *sql.Row
var count int
var err error
row = s.QueryRow(`SELECT COUNT(*) FROM users WHERE auth_provider_id = (SELECT id FROM auth_providers WHERE public_id = ?)`, strings.TrimSpace(id))
err = row.Scan(&count)
return count, err
}
func (s *Store) CreateAuthProvider(item models.AuthProvider) (models.AuthProvider, error) {
var err error
var now int64
now = time.Now().Unix()
item.CreatedAt = now
item.UpdatedAt = now
if strings.TrimSpace(item.ID) == "" {
item.ID, err = util.NewID()
if err != nil {
return item, err
}
}
if item.Type == AuthProviderTypeLDAP && strings.TrimSpace(item.LDAPUserFilter) == "" {
item.LDAPUserFilter = "(uid={username})"
}
if item.Type == AuthProviderTypeOIDC && strings.TrimSpace(item.OIDCScopes) == "" {
item.OIDCScopes = "openid profile email"
}
_, err = s.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,
default_user_group_id,
created_at, updated_at
) 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.DefaultUserGroupID, item.DefaultUserGroupID,
item.CreatedAt, item.UpdatedAt,
)
if err != nil {
return item, err
}
return s.GetAuthProvider(item.ID)
}
func (s *Store) UpdateAuthProvider(item models.AuthProvider) (models.AuthProvider, error) {
var err error
var now int64
now = time.Now().Unix()
item.UpdatedAt = now
if item.Type == AuthProviderTypeLDAP && strings.TrimSpace(item.LDAPUserFilter) == "" {
item.LDAPUserFilter = "(uid={username})"
}
if item.Type == AuthProviderTypeOIDC && strings.TrimSpace(item.OIDCScopes) == "" {
item.OIDCScopes = "openid profile email"
}
_, err = s.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 = ?,
default_user_group_id = CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM user_groups WHERE public_id = ?) END,
updated_at = ?
WHERE public_id = ?`,
item.Name, 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.DefaultUserGroupID, item.DefaultUserGroupID,
item.UpdatedAt,
strings.TrimSpace(item.ID),
)
if err != nil {
return item, err
}
return s.GetAuthProvider(item.ID)
}
func (s *Store) DeleteAuthProvider(id string, force bool) error {
var count int
var err error
if strings.TrimSpace(id) == BuiltinDBProviderPublicID {
return errors.New("builtin provider cannot be deleted")
}
if !force {
count, err = s.CountAuthProviderUsers(id)
if err != nil {
return err
}
if count > 0 {
return errors.New("provider has associated users")
}
}
_, err = s.Exec(`DELETE FROM auth_providers WHERE public_id = ?`, strings.TrimSpace(id))
return err
}
+21 -11
View File
@@ -238,20 +238,26 @@ func (s *Store) ListPKICerts(caID string) ([]models.PKICert, error) {
var items []models.PKICert var items []models.PKICert
var item models.PKICert var item models.PKICert
if caID == "" { if caID == "" {
rows, err = s.Query(`SELECT c.public_id, COALESCE(ca.public_id, ''), COALESCE(ca.name, ''), c.created_by_kind, c.created_by_subject_id, c.created_by_subject_name, c.issuance_source, c.serial_hex, c.common_name, c.san_dns, c.san_ips, c.is_ca, c.cert_pem, c.key_pem, c.not_before, c.not_after, c.status, c.revoked_at, c.revocation_reason, c.created_at rows, err = s.Query(`SELECT c.public_id, COALESCE(ca.public_id, ''), COALESCE(ca.name, ''), c.created_by_kind, COALESCE(cbu.public_id, cbp.public_id, ''), c.created_by_subject_name, c.issuance_source, c.serial_hex, c.common_name, c.san_dns, c.san_ips, c.is_ca, c.cert_pem, c.key_pem, c.not_before, c.not_after, c.status, c.revoked_at, c.revocation_reason, c.created_at
FROM pki_certs c FROM pki_certs c
LEFT JOIN pki_cas ca ON ca.id = c.ca_id LEFT JOIN pki_cas ca ON ca.id = c.ca_id
LEFT JOIN users cbu ON cbu.id = c.created_by_user_id
LEFT JOIN service_principals cbp ON cbp.id = c.created_by_principal_id
ORDER BY c.created_at DESC`) ORDER BY c.created_at DESC`)
} else if caID == "standalone" { } else if caID == "standalone" {
rows, err = s.Query(`SELECT c.public_id, COALESCE(ca.public_id, ''), COALESCE(ca.name, ''), c.created_by_kind, c.created_by_subject_id, c.created_by_subject_name, c.issuance_source, c.serial_hex, c.common_name, c.san_dns, c.san_ips, c.is_ca, c.cert_pem, c.key_pem, c.not_before, c.not_after, c.status, c.revoked_at, c.revocation_reason, c.created_at rows, err = s.Query(`SELECT c.public_id, COALESCE(ca.public_id, ''), COALESCE(ca.name, ''), c.created_by_kind, COALESCE(cbu.public_id, cbp.public_id, ''), c.created_by_subject_name, c.issuance_source, c.serial_hex, c.common_name, c.san_dns, c.san_ips, c.is_ca, c.cert_pem, c.key_pem, c.not_before, c.not_after, c.status, c.revoked_at, c.revocation_reason, c.created_at
FROM pki_certs c FROM pki_certs c
LEFT JOIN pki_cas ca ON ca.id = c.ca_id LEFT JOIN pki_cas ca ON ca.id = c.ca_id
LEFT JOIN users cbu ON cbu.id = c.created_by_user_id
LEFT JOIN service_principals cbp ON cbp.id = c.created_by_principal_id
WHERE c.ca_id IS NULL WHERE c.ca_id IS NULL
ORDER BY c.created_at DESC`) ORDER BY c.created_at DESC`)
} else { } else {
rows, err = s.Query(`SELECT c.public_id, COALESCE(ca.public_id, ''), COALESCE(ca.name, ''), c.created_by_kind, c.created_by_subject_id, c.created_by_subject_name, c.issuance_source, c.serial_hex, c.common_name, c.san_dns, c.san_ips, c.is_ca, c.cert_pem, c.key_pem, c.not_before, c.not_after, c.status, c.revoked_at, c.revocation_reason, c.created_at rows, err = s.Query(`SELECT c.public_id, COALESCE(ca.public_id, ''), COALESCE(ca.name, ''), c.created_by_kind, COALESCE(cbu.public_id, cbp.public_id, ''), c.created_by_subject_name, c.issuance_source, c.serial_hex, c.common_name, c.san_dns, c.san_ips, c.is_ca, c.cert_pem, c.key_pem, c.not_before, c.not_after, c.status, c.revoked_at, c.revocation_reason, c.created_at
FROM pki_certs c FROM pki_certs c
LEFT JOIN pki_cas ca ON ca.id = c.ca_id LEFT JOIN pki_cas ca ON ca.id = c.ca_id
LEFT JOIN users cbu ON cbu.id = c.created_by_user_id
LEFT JOIN service_principals cbp ON cbp.id = c.created_by_principal_id
WHERE c.ca_id = (SELECT id FROM pki_cas WHERE public_id = ?) WHERE c.ca_id = (SELECT id FROM pki_cas WHERE public_id = ?)
ORDER BY c.created_at DESC`, caID) ORDER BY c.created_at DESC`, caID)
} }
@@ -277,9 +283,11 @@ func (s *Store) GetPKICert(id string) (models.PKICert, error) {
var row *sql.Row var row *sql.Row
var item models.PKICert var item models.PKICert
var err error var err error
row = s.QueryRow(`SELECT c.public_id, COALESCE(ca.public_id, ''), COALESCE(ca.name, ''), c.created_by_kind, c.created_by_subject_id, c.created_by_subject_name, c.issuance_source, c.serial_hex, c.common_name, c.san_dns, c.san_ips, c.is_ca, c.cert_pem, c.key_pem, c.not_before, c.not_after, c.status, c.revoked_at, c.revocation_reason, c.created_at row = s.QueryRow(`SELECT c.public_id, COALESCE(ca.public_id, ''), COALESCE(ca.name, ''), c.created_by_kind, COALESCE(cbu.public_id, cbp.public_id, ''), c.created_by_subject_name, c.issuance_source, c.serial_hex, c.common_name, c.san_dns, c.san_ips, c.is_ca, c.cert_pem, c.key_pem, c.not_before, c.not_after, c.status, c.revoked_at, c.revocation_reason, c.created_at
FROM pki_certs c FROM pki_certs c
LEFT JOIN pki_cas ca ON ca.id = c.ca_id LEFT JOIN pki_cas ca ON ca.id = c.ca_id
LEFT JOIN users cbu ON cbu.id = c.created_by_user_id
LEFT JOIN service_principals cbp ON cbp.id = c.created_by_principal_id
WHERE c.public_id = ?`, id) WHERE c.public_id = ?`, id)
err = row.Scan(&item.ID, &item.CAID, &item.CAName, &item.CreatedByKind, &item.CreatedBySubjectID, &item.CreatedBySubjectName, &item.IssuanceSource, &item.SerialHex, &item.CommonName, &item.SANDNS, &item.SANIPs, &item.IsCA, &item.CertPEM, &item.KeyPEM, &item.NotBefore, &item.NotAfter, &item.Status, &item.RevokedAt, &item.RevocationReason, &item.CreatedAt) err = row.Scan(&item.ID, &item.CAID, &item.CAName, &item.CreatedByKind, &item.CreatedBySubjectID, &item.CreatedBySubjectName, &item.IssuanceSource, &item.SerialHex, &item.CommonName, &item.SANDNS, &item.SANIPs, &item.IsCA, &item.CertPEM, &item.KeyPEM, &item.NotBefore, &item.NotAfter, &item.Status, &item.RevokedAt, &item.RevocationReason, &item.CreatedAt)
if err != nil { if err != nil {
@@ -305,9 +313,9 @@ func (s *Store) CreatePKICert(item models.PKICert) (models.PKICert, error) {
now = time.Now().UTC().Unix() now = time.Now().UTC().Unix()
item.CreatedAt = now item.CreatedAt = now
item.CAID = strings.TrimSpace(item.CAID) item.CAID = strings.TrimSpace(item.CAID)
_, err = s.Exec(`INSERT INTO pki_certs (public_id, ca_id, created_by_kind, created_by_subject_id, created_by_subject_name, issuance_source, serial_hex, common_name, san_dns, san_ips, is_ca, cert_pem, key_pem, not_before, not_after, status, revoked_at, revocation_reason, created_at) _, err = s.Exec(`INSERT INTO pki_certs (public_id, ca_id, created_by_kind, created_by_user_id, created_by_principal_id, created_by_subject_name, issuance_source, serial_hex, common_name, san_dns, san_ips, is_ca, cert_pem, key_pem, not_before, not_after, status, revoked_at, revocation_reason, created_at)
VALUES (?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM pki_cas WHERE public_id = ?) END, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM pki_cas WHERE public_id = ?) END, ?, (SELECT id FROM users WHERE public_id = ? AND ? = 'user'), (SELECT id FROM service_principals WHERE public_id = ? AND ? = 'service_principal'), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
item.ID, item.CAID, item.CAID, item.CreatedByKind, item.CreatedBySubjectID, item.CreatedBySubjectName, item.IssuanceSource, item.SerialHex, item.CommonName, item.SANDNS, item.SANIPs, item.IsCA, item.CertPEM, item.KeyPEM, item.NotBefore, item.NotAfter, item.Status, item.RevokedAt, item.RevocationReason, item.CreatedAt) item.ID, item.CAID, item.CAID, item.CreatedByKind, item.CreatedBySubjectID, item.CreatedByKind, item.CreatedBySubjectID, item.CreatedByKind, item.CreatedBySubjectName, item.IssuanceSource, item.SerialHex, item.CommonName, item.SANDNS, item.SANIPs, item.IsCA, item.CertPEM, item.KeyPEM, item.NotBefore, item.NotAfter, item.Status, item.RevokedAt, item.RevocationReason, item.CreatedAt)
if err != nil { if err != nil {
return item, err return item, err
} }
@@ -333,7 +341,8 @@ func (s *Store) ReplacePKICert(item models.PKICert) (models.PKICert, error) {
SET SET
ca_id = CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM pki_cas WHERE public_id = ?) END, ca_id = CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM pki_cas WHERE public_id = ?) END,
created_by_kind = ?, created_by_kind = ?,
created_by_subject_id = ?, created_by_user_id = (SELECT id FROM users WHERE public_id = ? AND ? = 'user'),
created_by_principal_id = (SELECT id FROM service_principals WHERE public_id = ? AND ? = 'service_principal'),
created_by_subject_name = ?, created_by_subject_name = ?,
issuance_source = ?, issuance_source = ?,
serial_hex = ?, serial_hex = ?,
@@ -351,7 +360,8 @@ func (s *Store) ReplacePKICert(item models.PKICert) (models.PKICert, error) {
WHERE public_id = ?`, WHERE public_id = ?`,
item.CAID, item.CAID, item.CAID, item.CAID,
item.CreatedByKind, item.CreatedByKind,
item.CreatedBySubjectID, item.CreatedBySubjectID, item.CreatedByKind,
item.CreatedBySubjectID, item.CreatedByKind,
item.CreatedBySubjectName, item.CreatedBySubjectName,
item.IssuanceSource, item.IssuanceSource,
item.SerialHex, item.SerialHex,
@@ -387,7 +397,7 @@ func (s *Store) CountTLSServerCertReferences(certID string) (int, int, error) {
if err != nil { if err != nil {
return 0, 0, err return 0, 0, err
} }
row = s.QueryRow(`SELECT COUNT(*) FROM tls_listeners WHERE tls_pki_server_cert_id = ?`, certID) row = s.QueryRow(`SELECT COUNT(*) FROM tls_listeners WHERE tls_pki_server_cert_id = (SELECT id FROM pki_certs WHERE public_id = ?)`, certID)
err = row.Scan(&listenerCount) err = row.Scan(&listenerCount)
if err != nil { if err != nil {
return 0, 0, err return 0, 0, err
@@ -405,7 +415,7 @@ func (s *Store) CountTLSClientCAReferences(caID string) (int, int, error) {
if err != nil { if err != nil {
return 0, 0, err return 0, 0, err
} }
row = s.QueryRow(`SELECT COUNT(*) FROM tls_listeners WHERE tls_pki_client_ca_id = ?`, caID) row = s.QueryRow(`SELECT COUNT(*) FROM tls_listeners WHERE tls_pki_client_ca_id = (SELECT id FROM pki_cas WHERE public_id = ?)`, caID)
err = row.Scan(&listenerCount) err = row.Scan(&listenerCount)
if err != nil { if err != nil {
return 0, 0, err return 0, 0, err
+76 -30
View File
@@ -45,9 +45,11 @@ func (s *Store) ListSSHServers() ([]models.SSHServer, error) {
var item models.SSHServer var item models.SSHServer
var tagsJSON string var tagsJSON string
rows, err = s.Query(`SELECT srv.public_id, srv.name, srv.host, srv.port, srv.description, srv.tags_json, srv.enabled, srv.host_key_policy, srv.owner_scope, COALESCE(ou.public_id, ''), srv.created_by_kind, srv.created_by_subject_id, srv.created_by_subject_name, srv.created_at, srv.updated_at rows, err = s.Query(`SELECT srv.public_id, srv.name, srv.host, srv.port, srv.description, srv.tags_json, srv.enabled, srv.host_key_policy, srv.owner_scope, COALESCE(ou.public_id, ''), srv.created_by_kind, COALESCE(srv_cbu.public_id, srv_cbp.public_id, ''), srv.created_by_subject_name, srv.created_at, srv.updated_at
FROM ssh_servers srv FROM ssh_servers srv
LEFT JOIN users ou ON ou.id = srv.owner_user_id LEFT JOIN users ou ON ou.id = srv.owner_user_id
LEFT JOIN users srv_cbu ON srv_cbu.id = srv.created_by_user_id
LEFT JOIN service_principals srv_cbp ON srv_cbp.id = srv.created_by_principal_id
ORDER BY srv.name, srv.host, srv.port`) ORDER BY srv.name, srv.host, srv.port`)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -78,9 +80,11 @@ func (s *Store) GetSSHServer(id string) (models.SSHServer, error) {
var tagsJSON string var tagsJSON string
var err error var err error
row = s.QueryRow(`SELECT srv.public_id, srv.name, srv.host, srv.port, srv.description, srv.tags_json, srv.enabled, srv.host_key_policy, srv.owner_scope, COALESCE(ou.public_id, ''), srv.created_by_kind, srv.created_by_subject_id, srv.created_by_subject_name, srv.created_at, srv.updated_at row = s.QueryRow(`SELECT srv.public_id, srv.name, srv.host, srv.port, srv.description, srv.tags_json, srv.enabled, srv.host_key_policy, srv.owner_scope, COALESCE(ou.public_id, ''), srv.created_by_kind, COALESCE(srv_cbu.public_id, srv_cbp.public_id, ''), srv.created_by_subject_name, srv.created_at, srv.updated_at
FROM ssh_servers srv FROM ssh_servers srv
LEFT JOIN users ou ON ou.id = srv.owner_user_id LEFT JOIN users ou ON ou.id = srv.owner_user_id
LEFT JOIN users srv_cbu ON srv_cbu.id = srv.created_by_user_id
LEFT JOIN service_principals srv_cbp ON srv_cbp.id = srv.created_by_principal_id
WHERE srv.public_id = ?`, strings.TrimSpace(id)) WHERE srv.public_id = ?`, strings.TrimSpace(id))
err = row.Scan(&item.ID, &item.Name, &item.Host, &item.Port, &item.Description, &tagsJSON, &item.Enabled, &item.HostKeyPolicy, &item.OwnerScope, &item.OwnerUserID, &item.CreatedByKind, &item.CreatedBySubjectID, &item.CreatedBySubjectName, &item.CreatedAt, &item.UpdatedAt) err = row.Scan(&item.ID, &item.Name, &item.Host, &item.Port, &item.Description, &tagsJSON, &item.Enabled, &item.HostKeyPolicy, &item.OwnerScope, &item.OwnerUserID, &item.CreatedByKind, &item.CreatedBySubjectID, &item.CreatedBySubjectName, &item.CreatedAt, &item.UpdatedAt)
if err != nil { return item, err } if err != nil { return item, err }
@@ -99,11 +103,13 @@ func (s *Store) ListSSHServersForUser(userID string) ([]models.SSHServer, error)
var trimmedUserID string var trimmedUserID string
trimmedUserID = strings.TrimSpace(userID) trimmedUserID = strings.TrimSpace(userID)
rows, err = s.Query(`SELECT srv.public_id, srv.name, srv.host, srv.port, srv.description, srv.tags_json, srv.enabled, srv.host_key_policy, srv.owner_scope, COALESCE(ou.public_id, ''), srv.created_by_kind, srv.created_by_subject_id, srv.created_by_subject_name, srv.created_at, srv.updated_at rows, err = s.Query(`SELECT srv.public_id, srv.name, srv.host, srv.port, srv.description, srv.tags_json, srv.enabled, srv.host_key_policy, srv.owner_scope, COALESCE(ou.public_id, ''), srv.created_by_kind, COALESCE(srv_cbu.public_id, srv_cbp.public_id, ''), srv.created_by_subject_name, srv.created_at, srv.updated_at
FROM ssh_servers srv FROM ssh_servers srv
LEFT JOIN users ou ON ou.id = srv.owner_user_id LEFT JOIN users ou ON ou.id = srv.owner_user_id
WHERE srv.owner_scope = 'user' AND ou.public_id = ? LEFT JOIN users srv_cbu ON srv_cbu.id = srv.created_by_user_id
ORDER BY srv.name, srv.host, srv.port`, trimmedUserID) LEFT JOIN service_principals srv_cbp ON srv_cbp.id = srv.created_by_principal_id
WHERE srv.owner_scope = ? AND ou.public_id = ?
ORDER BY srv.name, srv.host, srv.port`, SSHOwnerScopeUser, trimmedUserID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -203,11 +209,12 @@ func (s *Store) CreateSSHServer(item models.SSHServer) (models.SSHServer, error)
owner_scope, owner_scope,
owner_user_id, owner_user_id,
created_by_kind, created_by_kind,
created_by_subject_id, created_by_user_id,
created_by_principal_id,
created_by_subject_name, created_by_subject_name,
created_at, created_at,
updated_at updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM users WHERE public_id = ?) END, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM users WHERE public_id = ?) END, ?, (SELECT id FROM users WHERE public_id = ? AND ? = 'user'), (SELECT id FROM service_principals WHERE public_id = ? AND ? = 'service_principal'), ?, ?, ?)`,
item.ID, item.ID,
strings.TrimSpace(item.Name), strings.TrimSpace(item.Name),
strings.TrimSpace(item.Host), strings.TrimSpace(item.Host),
@@ -221,6 +228,9 @@ func (s *Store) CreateSSHServer(item models.SSHServer) (models.SSHServer, error)
strings.TrimSpace(item.OwnerUserID), strings.TrimSpace(item.OwnerUserID),
strings.TrimSpace(item.CreatedByKind), strings.TrimSpace(item.CreatedByKind),
strings.TrimSpace(item.CreatedBySubjectID), strings.TrimSpace(item.CreatedBySubjectID),
strings.TrimSpace(item.CreatedByKind),
strings.TrimSpace(item.CreatedBySubjectID),
strings.TrimSpace(item.CreatedByKind),
strings.TrimSpace(item.CreatedBySubjectName), strings.TrimSpace(item.CreatedBySubjectName),
item.CreatedAt, item.CreatedAt,
item.UpdatedAt, item.UpdatedAt,
@@ -305,7 +315,7 @@ func (s *Store) ListSSHAccessProfiles() ([]models.SSHAccessProfile, error) {
COALESCE(g.description, ''), COALESCE(g.description, ''),
COALESCE(g.enabled, 0), COALESCE(g.enabled, 0),
COALESCE(g.created_by_kind, ''), COALESCE(g.created_by_kind, ''),
COALESCE(g.created_by_subject_id, ''), COALESCE(g_cbu.public_id, g_cbp.public_id, ''),
COALESCE(g.created_by_subject_name, ''), COALESCE(g.created_by_subject_name, ''),
COALESCE(g.created_at, 0), COALESCE(g.created_at, 0),
COALESCE(g.updated_at, 0), COALESCE(g.updated_at, 0),
@@ -331,7 +341,7 @@ func (s *Store) ListSSHAccessProfiles() ([]models.SSHAccessProfile, error) {
p.valid_after, p.valid_after,
p.valid_before, p.valid_before,
p.created_by_kind, p.created_by_kind,
p.created_by_subject_id, COALESCE(p_cbu.public_id, p_cbp.public_id, ''),
p.created_by_subject_name, p.created_by_subject_name,
p.created_at, p.created_at,
p.updated_at, p.updated_at,
@@ -346,15 +356,21 @@ func (s *Store) ListSSHAccessProfiles() ([]models.SSHAccessProfile, error) {
s.owner_scope, s.owner_scope,
COALESCE(sou.public_id, ''), COALESCE(sou.public_id, ''),
s.created_by_kind, s.created_by_kind,
s.created_by_subject_id, COALESCE(s_cbu.public_id, s_cbp.public_id, ''),
s.created_by_subject_name, s.created_by_subject_name,
s.created_at, s.created_at,
s.updated_at s.updated_at
FROM ssh_access_profiles p FROM ssh_access_profiles p
JOIN ssh_servers s ON s.id = p.server_id JOIN ssh_servers s ON s.id = p.server_id
LEFT JOIN users sou ON sou.id = s.owner_user_id LEFT JOIN users sou ON sou.id = s.owner_user_id
LEFT JOIN users s_cbu ON s_cbu.id = s.created_by_user_id
LEFT JOIN service_principals s_cbp ON s_cbp.id = s.created_by_principal_id
LEFT JOIN ssh_server_groups g ON g.id = p.server_group_id LEFT JOIN ssh_server_groups g ON g.id = p.server_group_id
LEFT JOIN users g_cbu ON g_cbu.id = g.created_by_user_id
LEFT JOIN service_principals g_cbp ON g_cbp.id = g.created_by_principal_id
LEFT JOIN users pou ON pou.id = p.owner_user_id LEFT JOIN users pou ON pou.id = p.owner_user_id
LEFT JOIN users p_cbu ON p_cbu.id = p.created_by_user_id
LEFT JOIN service_principals p_cbp ON p_cbp.id = p.created_by_principal_id
LEFT JOIN ssh_secrets sec ON sec.id = p.secret_id LEFT JOIN ssh_secrets sec ON sec.id = p.secret_id
LEFT JOIN ssh_credentials c ON c.id = p.ssh_credential_id LEFT JOIN ssh_credentials c ON c.id = p.ssh_credential_id
LEFT JOIN ssh_user_cas ca ON ca.id = p.ssh_user_ca_id LEFT JOIN ssh_user_cas ca ON ca.id = p.ssh_user_ca_id
@@ -458,7 +474,7 @@ func (s *Store) GetSSHAccessProfile(id string) (models.SSHAccessProfile, error)
COALESCE(g.description, ''), COALESCE(g.description, ''),
COALESCE(g.enabled, 0), COALESCE(g.enabled, 0),
COALESCE(g.created_by_kind, ''), COALESCE(g.created_by_kind, ''),
COALESCE(g.created_by_subject_id, ''), COALESCE(g_cbu.public_id, g_cbp.public_id, ''),
COALESCE(g.created_by_subject_name, ''), COALESCE(g.created_by_subject_name, ''),
COALESCE(g.created_at, 0), COALESCE(g.created_at, 0),
COALESCE(g.updated_at, 0), COALESCE(g.updated_at, 0),
@@ -484,7 +500,7 @@ func (s *Store) GetSSHAccessProfile(id string) (models.SSHAccessProfile, error)
p.valid_after, p.valid_after,
p.valid_before, p.valid_before,
p.created_by_kind, p.created_by_kind,
p.created_by_subject_id, COALESCE(p_cbu.public_id, p_cbp.public_id, ''),
p.created_by_subject_name, p.created_by_subject_name,
p.created_at, p.created_at,
p.updated_at, p.updated_at,
@@ -499,15 +515,21 @@ func (s *Store) GetSSHAccessProfile(id string) (models.SSHAccessProfile, error)
s.owner_scope, s.owner_scope,
COALESCE(sou.public_id, ''), COALESCE(sou.public_id, ''),
s.created_by_kind, s.created_by_kind,
s.created_by_subject_id, COALESCE(s_cbu.public_id, s_cbp.public_id, ''),
s.created_by_subject_name, s.created_by_subject_name,
s.created_at, s.created_at,
s.updated_at s.updated_at
FROM ssh_access_profiles p FROM ssh_access_profiles p
JOIN ssh_servers s ON s.id = p.server_id JOIN ssh_servers s ON s.id = p.server_id
LEFT JOIN users sou ON sou.id = s.owner_user_id LEFT JOIN users sou ON sou.id = s.owner_user_id
LEFT JOIN users s_cbu ON s_cbu.id = s.created_by_user_id
LEFT JOIN service_principals s_cbp ON s_cbp.id = s.created_by_principal_id
LEFT JOIN ssh_server_groups g ON g.id = p.server_group_id LEFT JOIN ssh_server_groups g ON g.id = p.server_group_id
LEFT JOIN users g_cbu ON g_cbu.id = g.created_by_user_id
LEFT JOIN service_principals g_cbp ON g_cbp.id = g.created_by_principal_id
LEFT JOIN users pou ON pou.id = p.owner_user_id LEFT JOIN users pou ON pou.id = p.owner_user_id
LEFT JOIN users p_cbu ON p_cbu.id = p.created_by_user_id
LEFT JOIN service_principals p_cbp ON p_cbp.id = p.created_by_principal_id
LEFT JOIN ssh_secrets sec ON sec.id = p.secret_id LEFT JOIN ssh_secrets sec ON sec.id = p.secret_id
LEFT JOIN ssh_credentials c ON c.id = p.ssh_credential_id LEFT JOIN ssh_credentials c ON c.id = p.ssh_credential_id
LEFT JOIN ssh_user_cas ca ON ca.id = p.ssh_user_ca_id LEFT JOIN ssh_user_cas ca ON ca.id = p.ssh_user_ca_id
@@ -688,11 +710,12 @@ func (s *Store) CreateSSHAccessProfile(item models.SSHAccessProfile) (models.SSH
valid_after, valid_after,
valid_before, valid_before,
created_by_kind, created_by_kind,
created_by_subject_id, created_by_user_id,
created_by_principal_id,
created_by_subject_name, created_by_subject_name,
created_at, created_at,
updated_at updated_at
) VALUES (?, (SELECT id FROM ssh_servers WHERE public_id = ?), ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM ssh_server_groups WHERE public_id = ?) END, ?, ?, ?, ?, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM users WHERE public_id = ?) END, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM ssh_secrets WHERE public_id = ?) END, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM ssh_credentials WHERE public_id = ?) END, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM ssh_user_cas WHERE public_id = ?) END, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, (SELECT id FROM ssh_servers WHERE public_id = ?), ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM ssh_server_groups WHERE public_id = ?) END, ?, ?, ?, ?, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM users WHERE public_id = ?) END, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM ssh_secrets WHERE public_id = ?) END, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM ssh_credentials WHERE public_id = ?) END, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM ssh_user_cas WHERE public_id = ?) END, ?, ?, ?, ?, ?, (SELECT id FROM users WHERE public_id = ? AND ? = 'user'), (SELECT id FROM service_principals WHERE public_id = ? AND ? = 'service_principal'), ?, ?, ?)`,
item.ID, item.ID,
strings.TrimSpace(item.ServerID), strings.TrimSpace(item.ServerID),
strings.TrimSpace(item.ServerTargetType), strings.TrimSpace(item.ServerTargetType),
@@ -723,6 +746,9 @@ func (s *Store) CreateSSHAccessProfile(item models.SSHAccessProfile) (models.SSH
item.ValidBefore, item.ValidBefore,
strings.TrimSpace(item.CreatedByKind), strings.TrimSpace(item.CreatedByKind),
strings.TrimSpace(item.CreatedBySubjectID), strings.TrimSpace(item.CreatedBySubjectID),
strings.TrimSpace(item.CreatedByKind),
strings.TrimSpace(item.CreatedBySubjectID),
strings.TrimSpace(item.CreatedByKind),
strings.TrimSpace(item.CreatedBySubjectName), strings.TrimSpace(item.CreatedBySubjectName),
item.CreatedAt, item.CreatedAt,
item.UpdatedAt, item.UpdatedAt,
@@ -945,7 +971,7 @@ func (s *Store) ListSSHAccessProfilesForUser(userID string) ([]models.SSHAccessP
COALESCE(g.description, ''), COALESCE(g.description, ''),
COALESCE(g.enabled, 0), COALESCE(g.enabled, 0),
COALESCE(g.created_by_kind, ''), COALESCE(g.created_by_kind, ''),
COALESCE(g.created_by_subject_id, ''), COALESCE(g_cbu.public_id, g_cbp.public_id, ''),
COALESCE(g.created_by_subject_name, ''), COALESCE(g.created_by_subject_name, ''),
COALESCE(g.created_at, 0), COALESCE(g.created_at, 0),
COALESCE(g.updated_at, 0), COALESCE(g.updated_at, 0),
@@ -971,7 +997,7 @@ func (s *Store) ListSSHAccessProfilesForUser(userID string) ([]models.SSHAccessP
p.valid_after, p.valid_after,
p.valid_before, p.valid_before,
p.created_by_kind, p.created_by_kind,
p.created_by_subject_id, COALESCE(p_cbu.public_id, p_cbp.public_id, ''),
p.created_by_subject_name, p.created_by_subject_name,
p.created_at, p.created_at,
p.updated_at, p.updated_at,
@@ -986,15 +1012,21 @@ func (s *Store) ListSSHAccessProfilesForUser(userID string) ([]models.SSHAccessP
s.owner_scope, s.owner_scope,
COALESCE(sou.public_id, ''), COALESCE(sou.public_id, ''),
s.created_by_kind, s.created_by_kind,
s.created_by_subject_id, COALESCE(s_cbu.public_id, s_cbp.public_id, ''),
s.created_by_subject_name, s.created_by_subject_name,
s.created_at, s.created_at,
s.updated_at s.updated_at
FROM ssh_access_profiles p FROM ssh_access_profiles p
JOIN ssh_servers s ON s.id = p.server_id JOIN ssh_servers s ON s.id = p.server_id
LEFT JOIN users sou ON sou.id = s.owner_user_id LEFT JOIN users sou ON sou.id = s.owner_user_id
LEFT JOIN users s_cbu ON s_cbu.id = s.created_by_user_id
LEFT JOIN service_principals s_cbp ON s_cbp.id = s.created_by_principal_id
LEFT JOIN ssh_server_groups g ON g.id = p.server_group_id LEFT JOIN ssh_server_groups g ON g.id = p.server_group_id
LEFT JOIN users g_cbu ON g_cbu.id = g.created_by_user_id
LEFT JOIN service_principals g_cbp ON g_cbp.id = g.created_by_principal_id
LEFT JOIN users pou ON pou.id = p.owner_user_id LEFT JOIN users pou ON pou.id = p.owner_user_id
LEFT JOIN users p_cbu ON p_cbu.id = p.created_by_user_id
LEFT JOIN service_principals p_cbp ON p_cbp.id = p.created_by_principal_id
LEFT JOIN ssh_secrets sec ON sec.id = p.secret_id LEFT JOIN ssh_secrets sec ON sec.id = p.secret_id
LEFT JOIN ssh_credentials c ON c.id = p.ssh_credential_id LEFT JOIN ssh_credentials c ON c.id = p.ssh_credential_id
LEFT JOIN ssh_user_cas ca ON ca.id = p.ssh_user_ca_id LEFT JOIN ssh_user_cas ca ON ca.id = p.ssh_user_ca_id
@@ -1004,10 +1036,10 @@ func (s *Store) ListSSHAccessProfilesForUser(userID string) ([]models.SSHAccessP
LEFT JOIN users gu ON gm.user_id = gu.id LEFT JOIN users gu ON gm.user_id = gu.id
LEFT JOIN users tu ON t.target_type = 'user' AND tu.id = t.target_id LEFT JOIN users tu ON t.target_type = 'user' AND tu.id = t.target_id
WHERE ( WHERE (
(p.owner_scope = 'user' AND pou.public_id = ?) (p.owner_scope = ? AND pou.public_id = ?)
OR OR
( (
p.owner_scope = 'admin_shared' p.owner_scope = ?
AND p.enabled = 1 AND p.enabled = 1
AND s.enabled = 1 AND s.enabled = 1
AND (p.valid_after = 0 OR p.valid_after <= strftime('%s','now')) AND (p.valid_after = 0 OR p.valid_after <= strftime('%s','now'))
@@ -1019,7 +1051,7 @@ func (s *Store) ListSSHAccessProfilesForUser(userID string) ([]models.SSHAccessP
) )
) )
) )
ORDER BY s.name, p.name`, trimmedUserID, trimmedUserID, trimmedUserID) ORDER BY s.name, p.name`, SSHOwnerScopeUser, trimmedUserID, SSHOwnerScopeAdminShared, trimmedUserID, trimmedUserID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1130,7 +1162,7 @@ func (s *Store) GetSSHAccessProfileForUser(userID string, id string) (models.SSH
COALESCE(g.description, ''), COALESCE(g.description, ''),
COALESCE(g.enabled, 0), COALESCE(g.enabled, 0),
COALESCE(g.created_by_kind, ''), COALESCE(g.created_by_kind, ''),
COALESCE(g.created_by_subject_id, ''), COALESCE(g_cbu.public_id, g_cbp.public_id, ''),
COALESCE(g.created_by_subject_name, ''), COALESCE(g.created_by_subject_name, ''),
COALESCE(g.created_at, 0), COALESCE(g.created_at, 0),
COALESCE(g.updated_at, 0), COALESCE(g.updated_at, 0),
@@ -1156,7 +1188,7 @@ func (s *Store) GetSSHAccessProfileForUser(userID string, id string) (models.SSH
p.valid_after, p.valid_after,
p.valid_before, p.valid_before,
p.created_by_kind, p.created_by_kind,
p.created_by_subject_id, COALESCE(p_cbu.public_id, p_cbp.public_id, ''),
p.created_by_subject_name, p.created_by_subject_name,
p.created_at, p.created_at,
p.updated_at, p.updated_at,
@@ -1171,15 +1203,21 @@ func (s *Store) GetSSHAccessProfileForUser(userID string, id string) (models.SSH
s.owner_scope, s.owner_scope,
COALESCE(sou.public_id, ''), COALESCE(sou.public_id, ''),
s.created_by_kind, s.created_by_kind,
s.created_by_subject_id, COALESCE(s_cbu.public_id, s_cbp.public_id, ''),
s.created_by_subject_name, s.created_by_subject_name,
s.created_at, s.created_at,
s.updated_at s.updated_at
FROM ssh_access_profiles p FROM ssh_access_profiles p
JOIN ssh_servers s ON s.id = p.server_id JOIN ssh_servers s ON s.id = p.server_id
LEFT JOIN users sou ON sou.id = s.owner_user_id LEFT JOIN users sou ON sou.id = s.owner_user_id
LEFT JOIN users s_cbu ON s_cbu.id = s.created_by_user_id
LEFT JOIN service_principals s_cbp ON s_cbp.id = s.created_by_principal_id
LEFT JOIN ssh_server_groups g ON g.id = p.server_group_id LEFT JOIN ssh_server_groups g ON g.id = p.server_group_id
LEFT JOIN users g_cbu ON g_cbu.id = g.created_by_user_id
LEFT JOIN service_principals g_cbp ON g_cbp.id = g.created_by_principal_id
LEFT JOIN users pou ON pou.id = p.owner_user_id LEFT JOIN users pou ON pou.id = p.owner_user_id
LEFT JOIN users p_cbu ON p_cbu.id = p.created_by_user_id
LEFT JOIN service_principals p_cbp ON p_cbp.id = p.created_by_principal_id
LEFT JOIN ssh_secrets sec ON sec.id = p.secret_id LEFT JOIN ssh_secrets sec ON sec.id = p.secret_id
LEFT JOIN ssh_credentials c ON c.id = p.ssh_credential_id LEFT JOIN ssh_credentials c ON c.id = p.ssh_credential_id
LEFT JOIN ssh_user_cas ca ON ca.id = p.ssh_user_ca_id LEFT JOIN ssh_user_cas ca ON ca.id = p.ssh_user_ca_id
@@ -1190,10 +1228,10 @@ func (s *Store) GetSSHAccessProfileForUser(userID string, id string) (models.SSH
LEFT JOIN users tu ON t.target_type = 'user' AND tu.id = t.target_id LEFT JOIN users tu ON t.target_type = 'user' AND tu.id = t.target_id
WHERE p.public_id = ? WHERE p.public_id = ?
AND ( AND (
(p.owner_scope = 'user' AND pou.public_id = ?) (p.owner_scope = ? AND pou.public_id = ?)
OR OR
( (
p.owner_scope = 'admin_shared' p.owner_scope = ?
AND p.enabled = 1 AND p.enabled = 1
AND s.enabled = 1 AND s.enabled = 1
AND (p.valid_after = 0 OR p.valid_after <= strftime('%s','now')) AND (p.valid_after = 0 OR p.valid_after <= strftime('%s','now'))
@@ -1205,7 +1243,7 @@ func (s *Store) GetSSHAccessProfileForUser(userID string, id string) (models.SSH
) )
) )
) )
LIMIT 1`, trimmedID, trimmedUserID, trimmedUserID, trimmedUserID) LIMIT 1`, trimmedID, SSHOwnerScopeUser, trimmedUserID, SSHOwnerScopeAdminShared, trimmedUserID, trimmedUserID)
err = row.Scan( err = row.Scan(
&item.ID, &item.ID,
&item.ServerID, &item.ServerID,
@@ -1369,7 +1407,11 @@ func (s *Store) GetSSHSecret(id string) (models.SSHSecret, error) {
var item models.SSHSecret var item models.SSHSecret
var err error var err error
row = s.QueryRow(`SELECT public_id, kind, payload, password, metadata_json, created_by_kind, created_by_subject_id, created_by_subject_name, created_at, updated_at FROM ssh_secrets WHERE public_id = ?`, strings.TrimSpace(id)) row = s.QueryRow(`SELECT sec.public_id, sec.kind, sec.payload, sec.password, sec.metadata_json, sec.created_by_kind, COALESCE(sec_cbu.public_id, sec_cbp.public_id, ''), sec.created_by_subject_name, sec.created_at, sec.updated_at
FROM ssh_secrets sec
LEFT JOIN users sec_cbu ON sec_cbu.id = sec.created_by_user_id
LEFT JOIN service_principals sec_cbp ON sec_cbp.id = sec.created_by_principal_id
WHERE sec.public_id = ?`, strings.TrimSpace(id))
err = row.Scan(&item.ID, &item.Kind, &item.Payload, &item.Password, &item.MetadataJSON, &item.CreatedByKind, &item.CreatedBySubjectID, &item.CreatedBySubjectName, &item.CreatedAt, &item.UpdatedAt) err = row.Scan(&item.ID, &item.Kind, &item.Payload, &item.Password, &item.MetadataJSON, &item.CreatedByKind, &item.CreatedBySubjectID, &item.CreatedBySubjectName, &item.CreatedAt, &item.UpdatedAt)
if err != nil { if err != nil {
return item, err return item, err
@@ -1988,11 +2030,12 @@ func createSSHSecretTx(tx *sql.Tx, item models.SSHSecret) (models.SSHSecret, err
password, password,
metadata_json, metadata_json,
created_by_kind, created_by_kind,
created_by_subject_id, created_by_user_id,
created_by_principal_id,
created_by_subject_name, created_by_subject_name,
created_at, created_at,
updated_at updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, (SELECT id FROM users WHERE public_id = ? AND ? = 'user'), (SELECT id FROM service_principals WHERE public_id = ? AND ? = 'service_principal'), ?, ?, ?)`,
item.ID, item.ID,
strings.TrimSpace(item.Kind), strings.TrimSpace(item.Kind),
item.Payload, item.Payload,
@@ -2000,6 +2043,9 @@ func createSSHSecretTx(tx *sql.Tx, item models.SSHSecret) (models.SSHSecret, err
normalizeJSONText(item.MetadataJSON, "{}"), normalizeJSONText(item.MetadataJSON, "{}"),
strings.TrimSpace(item.CreatedByKind), strings.TrimSpace(item.CreatedByKind),
strings.TrimSpace(item.CreatedBySubjectID), strings.TrimSpace(item.CreatedBySubjectID),
strings.TrimSpace(item.CreatedByKind),
strings.TrimSpace(item.CreatedBySubjectID),
strings.TrimSpace(item.CreatedByKind),
strings.TrimSpace(item.CreatedBySubjectName), strings.TrimSpace(item.CreatedBySubjectName),
item.CreatedAt, item.CreatedAt,
item.UpdatedAt, item.UpdatedAt,
+21 -12
View File
@@ -31,12 +31,14 @@ func (s *Store) ListSSHCredentials() ([]models.SSHCredential, error) {
var item models.SSHCredential var item models.SSHCredential
var err error var err error
rows, err = s.Query(`SELECT c.public_id, c.name, c.description, c.type, COALESCE(sec.public_id, ''), c.public_key, c.fingerprint, c.enabled, c.owner_scope, COALESCE(u.public_id, ''), c.created_by_kind, c.created_by_subject_id, c.created_by_subject_name, c.created_at, c.updated_at rows, err = s.Query(`SELECT c.public_id, c.name, c.description, c.type, COALESCE(sec.public_id, ''), c.public_key, c.fingerprint, c.enabled, c.owner_scope, COALESCE(u.public_id, ''), c.created_by_kind, COALESCE(cbu.public_id, cbp.public_id, ''), c.created_by_subject_name, c.created_at, c.updated_at
FROM ssh_credentials c FROM ssh_credentials c
LEFT JOIN ssh_secrets sec ON sec.id = c.secret_id LEFT JOIN ssh_secrets sec ON sec.id = c.secret_id
LEFT JOIN users u ON u.id = c.owner_user_id LEFT JOIN users u ON u.id = c.owner_user_id
WHERE owner_scope = 'admin' LEFT JOIN users cbu ON cbu.id = c.created_by_user_id
ORDER BY name`) LEFT JOIN service_principals cbp ON cbp.id = c.created_by_principal_id
WHERE c.owner_scope = ?
ORDER BY c.name`, SSHOwnerScopeAdmin)
if err != nil { return nil, err } if err != nil { return nil, err }
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
@@ -55,12 +57,14 @@ func (s *Store) ListSSHCredentialsForUser(userID string) ([]models.SSHCredential
var item models.SSHCredential var item models.SSHCredential
var err error var err error
rows, err = s.Query(`SELECT c.public_id, c.name, c.description, c.type, COALESCE(sec.public_id, ''), c.public_key, c.fingerprint, c.enabled, c.owner_scope, COALESCE(u.public_id, ''), c.created_by_kind, c.created_by_subject_id, c.created_by_subject_name, c.created_at, c.updated_at rows, err = s.Query(`SELECT c.public_id, c.name, c.description, c.type, COALESCE(sec.public_id, ''), c.public_key, c.fingerprint, c.enabled, c.owner_scope, COALESCE(u.public_id, ''), c.created_by_kind, COALESCE(cbu.public_id, cbp.public_id, ''), c.created_by_subject_name, c.created_at, c.updated_at
FROM ssh_credentials c FROM ssh_credentials c
LEFT JOIN ssh_secrets sec ON sec.id = c.secret_id LEFT JOIN ssh_secrets sec ON sec.id = c.secret_id
LEFT JOIN users u ON u.id = c.owner_user_id LEFT JOIN users u ON u.id = c.owner_user_id
WHERE c.owner_scope = 'user' AND u.public_id = ? LEFT JOIN users cbu ON cbu.id = c.created_by_user_id
ORDER BY c.name`, strings.TrimSpace(userID)) LEFT JOIN service_principals cbp ON cbp.id = c.created_by_principal_id
WHERE c.owner_scope = ? AND u.public_id = ?
ORDER BY c.name`, SSHOwnerScopeUser, strings.TrimSpace(userID))
if err != nil { return nil, err } if err != nil { return nil, err }
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
@@ -78,10 +82,12 @@ func (s *Store) GetSSHCredential(id string) (models.SSHCredential, error) {
var item models.SSHCredential var item models.SSHCredential
var err error var err error
row = s.QueryRow(`SELECT c.public_id, c.name, c.description, c.type, COALESCE(sec.public_id, ''), c.public_key, c.fingerprint, c.enabled, c.owner_scope, COALESCE(u.public_id, ''), c.created_by_kind, c.created_by_subject_id, c.created_by_subject_name, c.created_at, c.updated_at row = s.QueryRow(`SELECT c.public_id, c.name, c.description, c.type, COALESCE(sec.public_id, ''), c.public_key, c.fingerprint, c.enabled, c.owner_scope, COALESCE(u.public_id, ''), c.created_by_kind, COALESCE(cbu.public_id, cbp.public_id, ''), c.created_by_subject_name, c.created_at, c.updated_at
FROM ssh_credentials c FROM ssh_credentials c
LEFT JOIN ssh_secrets sec ON sec.id = c.secret_id LEFT JOIN ssh_secrets sec ON sec.id = c.secret_id
LEFT JOIN users u ON u.id = c.owner_user_id LEFT JOIN users u ON u.id = c.owner_user_id
LEFT JOIN users cbu ON cbu.id = c.created_by_user_id
LEFT JOIN service_principals cbp ON cbp.id = c.created_by_principal_id
WHERE c.public_id = ?`, strings.TrimSpace(id)) WHERE c.public_id = ?`, strings.TrimSpace(id))
err = scanSSHCredential(row, &item) err = scanSSHCredential(row, &item)
return item, err return item, err
@@ -92,11 +98,13 @@ func (s *Store) GetSSHCredentialForUser(userID string, id string) (models.SSHCre
var item models.SSHCredential var item models.SSHCredential
var err error var err error
row = s.QueryRow(`SELECT c.public_id, c.name, c.description, c.type, COALESCE(sec.public_id, ''), c.public_key, c.fingerprint, c.enabled, c.owner_scope, COALESCE(u.public_id, ''), c.created_by_kind, c.created_by_subject_id, c.created_by_subject_name, c.created_at, c.updated_at row = s.QueryRow(`SELECT c.public_id, c.name, c.description, c.type, COALESCE(sec.public_id, ''), c.public_key, c.fingerprint, c.enabled, c.owner_scope, COALESCE(u.public_id, ''), c.created_by_kind, COALESCE(cbu.public_id, cbp.public_id, ''), c.created_by_subject_name, c.created_at, c.updated_at
FROM ssh_credentials c FROM ssh_credentials c
LEFT JOIN ssh_secrets sec ON sec.id = c.secret_id LEFT JOIN ssh_secrets sec ON sec.id = c.secret_id
LEFT JOIN users u ON u.id = c.owner_user_id LEFT JOIN users u ON u.id = c.owner_user_id
WHERE c.public_id = ? AND c.owner_scope = 'user' AND u.public_id = ?`, strings.TrimSpace(id), strings.TrimSpace(userID)) LEFT JOIN users cbu ON cbu.id = c.created_by_user_id
LEFT JOIN service_principals cbp ON cbp.id = c.created_by_principal_id
WHERE c.public_id = ? AND c.owner_scope = ? AND u.public_id = ?`, strings.TrimSpace(id), SSHOwnerScopeUser, strings.TrimSpace(userID))
err = scanSSHCredential(row, &item) err = scanSSHCredential(row, &item)
return item, err return item, err
} }
@@ -131,8 +139,8 @@ func (s *Store) CreateSSHCredential(item models.SSHCredential, secret models.SSH
item.CreatedAt = now item.CreatedAt = now
item.UpdatedAt = now item.UpdatedAt = now
item.SecretID = secret.ID item.SecretID = secret.ID
_, err = tx.Exec(`INSERT INTO ssh_credentials (public_id, name, description, type, secret_id, public_key, fingerprint, enabled, owner_scope, owner_user_id, created_by_kind, created_by_subject_id, created_by_subject_name, created_at, updated_at) _, err = tx.Exec(`INSERT INTO ssh_credentials (public_id, name, description, type, secret_id, public_key, fingerprint, enabled, owner_scope, owner_user_id, created_by_kind, created_by_user_id, created_by_principal_id, created_by_subject_name, created_at, updated_at)
VALUES (?, ?, ?, ?, (SELECT id FROM ssh_secrets WHERE public_id = ?), ?, ?, ?, ?, (SELECT id FROM users WHERE public_id = ?), ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, (SELECT id FROM ssh_secrets WHERE public_id = ?), ?, ?, ?, ?, (SELECT id FROM users WHERE public_id = ?), ?, (SELECT id FROM users WHERE public_id = ? AND ? = 'user'), (SELECT id FROM service_principals WHERE public_id = ? AND ? = 'service_principal'), ?, ?, ?)`,
item.ID, item.ID,
strings.TrimSpace(item.Name), strings.TrimSpace(item.Name),
strings.TrimSpace(item.Description), strings.TrimSpace(item.Description),
@@ -144,7 +152,8 @@ func (s *Store) CreateSSHCredential(item models.SSHCredential, secret models.SSH
strings.TrimSpace(item.OwnerScope), strings.TrimSpace(item.OwnerScope),
strings.TrimSpace(item.OwnerUserID), strings.TrimSpace(item.OwnerUserID),
strings.TrimSpace(item.CreatedByKind), strings.TrimSpace(item.CreatedByKind),
strings.TrimSpace(item.CreatedBySubjectID), strings.TrimSpace(item.CreatedBySubjectID), strings.TrimSpace(item.CreatedByKind),
strings.TrimSpace(item.CreatedBySubjectID), strings.TrimSpace(item.CreatedByKind),
strings.TrimSpace(item.CreatedBySubjectName), strings.TrimSpace(item.CreatedBySubjectName),
item.CreatedAt, item.CreatedAt,
item.UpdatedAt, item.UpdatedAt,
+14 -4
View File
@@ -47,7 +47,11 @@ func (s *Store) ListSSHServerGroups() ([]models.SSHServerGroup, error) {
var item models.SSHServerGroup var item models.SSHServerGroup
var err error var err error
rows, err = s.Query(`SELECT public_id, name, description, enabled, tags_json, created_by_kind, created_by_subject_id, created_by_subject_name, created_at, updated_at FROM ssh_server_groups ORDER BY name`) rows, err = s.Query(`SELECT g.public_id, g.name, g.description, g.enabled, g.tags_json, g.created_by_kind, COALESCE(cbu.public_id, cbp.public_id, ''), g.created_by_subject_name, g.created_at, g.updated_at
FROM ssh_server_groups g
LEFT JOIN users cbu ON cbu.id = g.created_by_user_id
LEFT JOIN service_principals cbp ON cbp.id = g.created_by_principal_id
ORDER BY g.name`)
if err != nil { return nil, err } if err != nil { return nil, err }
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
@@ -67,7 +71,11 @@ func (s *Store) GetSSHServerGroup(id string) (models.SSHServerGroup, error) {
var item models.SSHServerGroup var item models.SSHServerGroup
var err error var err error
row = s.QueryRow(`SELECT public_id, name, description, enabled, tags_json, created_by_kind, created_by_subject_id, created_by_subject_name, created_at, updated_at FROM ssh_server_groups WHERE public_id = ?`, strings.TrimSpace(id)) row = s.QueryRow(`SELECT g.public_id, g.name, g.description, g.enabled, g.tags_json, g.created_by_kind, COALESCE(cbu.public_id, cbp.public_id, ''), g.created_by_subject_name, g.created_at, g.updated_at
FROM ssh_server_groups g
LEFT JOIN users cbu ON cbu.id = g.created_by_user_id
LEFT JOIN service_principals cbp ON cbp.id = g.created_by_principal_id
WHERE g.public_id = ?`, strings.TrimSpace(id))
err = scanSSHServerGroup(row, &item) err = scanSSHServerGroup(row, &item)
if err != nil { return item, err } if err != nil { return item, err }
item.ServerIDs, err = s.listSSHServerGroupMemberIDs(item.ID) item.ServerIDs, err = s.listSSHServerGroupMemberIDs(item.ID)
@@ -82,11 +90,13 @@ func (s *Store) ListSSHServersForGroup(groupID string) ([]models.SSHServer, erro
var tagsJSON string var tagsJSON string
var err error var err error
rows, err = s.Query(`SELECT s.public_id, s.name, s.host, s.port, s.description, s.tags_json, s.enabled, s.host_key_policy, s.owner_scope, COALESCE(u.public_id, ''), s.created_by_kind, s.created_by_subject_id, s.created_by_subject_name, s.created_at, s.updated_at rows, err = s.Query(`SELECT s.public_id, s.name, s.host, s.port, s.description, s.tags_json, s.enabled, s.host_key_policy, s.owner_scope, COALESCE(u.public_id, ''), s.created_by_kind, COALESCE(sbu.public_id, sbp.public_id, ''), s.created_by_subject_name, s.created_at, s.updated_at
FROM ssh_server_group_members gm FROM ssh_server_group_members gm
JOIN ssh_servers s ON s.id = gm.server_id JOIN ssh_servers s ON s.id = gm.server_id
JOIN ssh_server_groups g ON g.id = gm.group_id JOIN ssh_server_groups g ON g.id = gm.group_id
LEFT JOIN users u ON u.id = s.owner_user_id LEFT JOIN users u ON u.id = s.owner_user_id
LEFT JOIN users sbu ON sbu.id = s.created_by_user_id
LEFT JOIN service_principals sbp ON sbp.id = s.created_by_principal_id
WHERE g.public_id = ? WHERE g.public_id = ?
ORDER BY s.name`, strings.TrimSpace(groupID)) ORDER BY s.name`, strings.TrimSpace(groupID))
if err != nil { return nil, err } if err != nil { return nil, err }
@@ -123,7 +133,7 @@ func (s *Store) CreateSSHServerGroup(item models.SSHServerGroup) (models.SSHServ
item.UpdatedAt = now item.UpdatedAt = now
tx, owned, err = s.begin() tx, owned, err = s.begin()
if err != nil { return item, err } if err != nil { return item, err }
_, err = tx.Exec(`INSERT INTO ssh_server_groups (public_id, name, description, enabled, tags_json, created_by_kind, created_by_subject_id, created_by_subject_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, item.ID, strings.TrimSpace(item.Name), strings.TrimSpace(item.Description), item.Enabled, tagsJSON, strings.TrimSpace(item.CreatedByKind), strings.TrimSpace(item.CreatedBySubjectID), strings.TrimSpace(item.CreatedBySubjectName), item.CreatedAt, item.UpdatedAt) _, err = tx.Exec(`INSERT INTO ssh_server_groups (public_id, name, description, enabled, tags_json, created_by_kind, created_by_user_id, created_by_principal_id, created_by_subject_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, (SELECT id FROM users WHERE public_id = ? AND ? = 'user'), (SELECT id FROM service_principals WHERE public_id = ? AND ? = 'service_principal'), ?, ?, ?)`, item.ID, strings.TrimSpace(item.Name), strings.TrimSpace(item.Description), item.Enabled, tagsJSON, strings.TrimSpace(item.CreatedByKind), strings.TrimSpace(item.CreatedBySubjectID), strings.TrimSpace(item.CreatedByKind), strings.TrimSpace(item.CreatedBySubjectID), strings.TrimSpace(item.CreatedByKind), strings.TrimSpace(item.CreatedBySubjectName), item.CreatedAt, item.UpdatedAt)
if err != nil { rollbackIfOwned(tx, owned); return item, err } if err != nil { rollbackIfOwned(tx, owned); return item, err }
err = commitIfOwned(tx, owned) err = commitIfOwned(tx, owned)
if err != nil { return item, err } if err != nil { return item, err }
+34 -250
View File
@@ -24,13 +24,10 @@ func (s *Store) CreateUser(user models.User, passwordHash string) (models.User,
nowUnix = now.Unix() nowUnix = now.Unix()
user.CreatedAt = nowUnix user.CreatedAt = nowUnix
user.UpdatedAt = nowUnix user.UpdatedAt = nowUnix
if user.AuthSource == "" {
user.AuthSource = "db"
}
_, err = s.Exec(` _, err = s.Exec(`
INSERT INTO users (public_id, username, display_name, email, password_hash, is_admin, disabled, auth_source, totp_required, created_at, updated_at) INSERT INTO users (public_id, username, display_name, email, password_hash, is_admin, disabled, auth_provider_id, totp_required, external_subject, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM auth_providers WHERE public_id = ?) END, ?, ?, ?, ?)
`, user.ID, user.Username, user.DisplayName, user.Email, passwordHash, user.IsAdmin, user.Disabled, user.AuthSource, user.TOTPRequired, now, now) `, user.ID, user.Username, user.DisplayName, user.Email, passwordHash, user.IsAdmin, user.Disabled, user.AuthProviderID, user.AuthProviderID, user.TOTPRequired, user.ExternalSubject, now, now)
return user, err return user, err
} }
@@ -84,8 +81,8 @@ func (s *Store) GetUserByID(id string) (models.User, error) {
var created time.Time var created time.Time
var updated time.Time var updated time.Time
var err error var err error
row = s.QueryRow(`SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, u.auth_source, COALESCE(t.enabled, 0), u.totp_required, u.created_at, u.updated_at FROM users u LEFT JOIN user_totp t ON t.user_id = u.id WHERE u.public_id = ?`, id) row = s.QueryRow(`SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, COALESCE(ap.public_id, ''), COALESCE(t.enabled, 0), u.totp_required, u.created_at, u.updated_at FROM users u LEFT JOIN auth_providers ap ON ap.id = u.auth_provider_id LEFT JOIN user_totp t ON t.user_id = u.id WHERE u.public_id = ?`, id)
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &user.TOTPEnabled, &user.TOTPRequired, &created, &updated) err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthProviderID, &user.TOTPEnabled, &user.TOTPRequired, &created, &updated)
if err != nil { if err != nil {
return user, err return user, err
} }
@@ -101,8 +98,8 @@ func (s *Store) GetUserByUsername(username string) (models.User, string, error)
var err error var err error
var created time.Time var created time.Time
var updated time.Time var updated time.Time
row = s.QueryRow(`SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, u.auth_source, u.password_hash, COALESCE(t.enabled, 0), u.totp_required, u.created_at, u.updated_at FROM users u LEFT JOIN user_totp t ON t.user_id = u.id WHERE u.username = ?`, username) row = s.QueryRow(`SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, COALESCE(ap.public_id, ''), u.password_hash, COALESCE(t.enabled, 0), u.totp_required, u.created_at, u.updated_at FROM users u LEFT JOIN auth_providers ap ON ap.id = u.auth_provider_id LEFT JOIN user_totp t ON t.user_id = u.id WHERE u.username = ?`, username)
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &passwordHash, &user.TOTPEnabled, &user.TOTPRequired, &created, &updated) err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthProviderID, &passwordHash, &user.TOTPEnabled, &user.TOTPRequired, &created, &updated)
if err != nil { if err != nil {
return user, passwordHash.String, err return user, passwordHash.String, err
} }
@@ -118,13 +115,13 @@ func (s *Store) ListUsers() ([]models.User, error) {
var u models.User var u models.User
var created time.Time var created time.Time
var updated time.Time var updated time.Time
rows, err = s.Query(`SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, u.auth_source, COALESCE(t.enabled, 0), u.totp_required, u.created_at, u.updated_at FROM users u LEFT JOIN user_totp t ON t.user_id = u.id ORDER BY u.username`) rows, err = s.Query(`SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, COALESCE(ap.public_id, ''), COALESCE(t.enabled, 0), u.totp_required, u.created_at, u.updated_at FROM users u LEFT JOIN auth_providers ap ON ap.id = u.auth_provider_id LEFT JOIN user_totp t ON t.user_id = u.id ORDER BY u.username`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
err = rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.IsAdmin, &u.Disabled, &u.AuthSource, &u.TOTPEnabled, &u.TOTPRequired, &created, &updated) err = rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.IsAdmin, &u.Disabled, &u.AuthProviderID, &u.TOTPEnabled, &u.TOTPRequired, &created, &updated)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -167,243 +164,28 @@ func (s *Store) DeleteUser(id string) error {
return err return err
} }
func (s *Store) GetAuthSettings() (models.AuthSettings, error) { func (s *Store) GetUserByProviderAndSub(providerPublicID string, sub string) (models.User, error) {
var settings models.AuthSettings var user models.User
var rows *sql.Rows var row *sql.Row
var created time.Time
var updated time.Time
var err error var err error
var key string row = s.QueryRow(`
var value string SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled,
rows, err = s.Query(`SELECT key, value FROM app_settings WHERE key IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, COALESCE(ap.public_id, ''), u.external_subject, COALESCE(t.enabled, 0), u.totp_required, u.created_at, u.updated_at
"auth.mode", FROM users u
"auth.oidc.enabled", LEFT JOIN auth_providers ap ON ap.id = u.auth_provider_id
"auth.ldap.url", LEFT JOIN user_totp t ON t.user_id = u.id
"auth.ldap.bind_dn", WHERE ap.public_id = ? AND u.external_subject = ?
"auth.ldap.bind_password", `, strings.TrimSpace(providerPublicID), strings.TrimSpace(sub))
"auth.ldap.user_base_dn", err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled,
"auth.ldap.user_filter", &user.AuthProviderID, &user.ExternalSubject, &user.TOTPEnabled, &user.TOTPRequired, &created, &updated)
"auth.ldap.tls_insecure_skip_verify",
"auth.oidc.client_id",
"auth.oidc.client_secret",
"auth.oidc.authorize_url",
"auth.oidc.token_url",
"auth.oidc.userinfo_url",
"auth.oidc.redirect_url",
"auth.oidc.scopes",
"auth.oidc.tls_insecure_skip_verify",
"auth.ldap.default_user_group_id",
"auth.oidc.default_user_group_id")
if err != nil { if err != nil {
return settings, err return user, err
} }
defer rows.Close() user.CreatedAt = created.Unix()
for rows.Next() { user.UpdatedAt = updated.Unix()
err = rows.Scan(&key, &value) return user, nil
if err != nil {
return settings, err
}
switch key {
case "auth.mode":
settings.AuthMode = value
case "auth.oidc.enabled":
settings.OIDCEnabled = value == "1"
case "auth.ldap.url":
settings.LDAPURL = value
case "auth.ldap.bind_dn":
settings.LDAPBindDN = value
case "auth.ldap.bind_password":
settings.LDAPBindPassword = value
case "auth.ldap.user_base_dn":
settings.LDAPUserBaseDN = value
case "auth.ldap.user_filter":
settings.LDAPUserFilter = value
case "auth.ldap.tls_insecure_skip_verify":
settings.LDAPTLSInsecureSkipVerify = value == "1"
case "auth.oidc.client_id":
settings.OIDCClientID = value
case "auth.oidc.client_secret":
settings.OIDCClientSecret = value
case "auth.oidc.authorize_url":
settings.OIDCAuthorizeURL = value
case "auth.oidc.token_url":
settings.OIDCTokenURL = value
case "auth.oidc.userinfo_url":
settings.OIDCUserInfoURL = value
case "auth.oidc.redirect_url":
settings.OIDCRedirectURL = value
case "auth.oidc.scopes":
settings.OIDCScopes = value
case "auth.oidc.tls_insecure_skip_verify":
settings.OIDCTLSInsecureSkipVerify = value == "1"
case "auth.ldap.default_user_group_id":
settings.LDAPDefaultUserGroupID = value
case "auth.oidc.default_user_group_id":
settings.OIDCDefaultUserGroupID = value
}
}
err = rows.Err()
if err != nil {
return settings, err
}
return settings, nil
}
func (s *Store) SetAuthSettings(settings models.AuthSettings) error {
var tx *sql.Tx
var owned bool
var err error
var now int64
var tlsInsecure string
tx, owned, err = s.begin()
if err != nil {
return err
}
now = time.Now().UTC().Unix()
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
"auth.mode", settings.AuthMode, now)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
if settings.OIDCEnabled {
tlsInsecure = "1"
} else {
tlsInsecure = "0"
}
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
"auth.oidc.enabled", tlsInsecure, now)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
"auth.ldap.url", settings.LDAPURL, now)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
"auth.ldap.bind_dn", settings.LDAPBindDN, now)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
"auth.ldap.bind_password", settings.LDAPBindPassword, now)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
"auth.ldap.user_base_dn", settings.LDAPUserBaseDN, now)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
"auth.ldap.user_filter", settings.LDAPUserFilter, now)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
if settings.LDAPTLSInsecureSkipVerify {
tlsInsecure = "1"
} else {
tlsInsecure = "0"
}
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
"auth.ldap.tls_insecure_skip_verify", tlsInsecure, now)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
"auth.oidc.client_id", settings.OIDCClientID, now)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
"auth.oidc.client_secret", settings.OIDCClientSecret, now)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
"auth.oidc.authorize_url", settings.OIDCAuthorizeURL, now)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
"auth.oidc.token_url", settings.OIDCTokenURL, now)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
"auth.oidc.userinfo_url", settings.OIDCUserInfoURL, now)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
"auth.oidc.redirect_url", settings.OIDCRedirectURL, now)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
"auth.oidc.scopes", settings.OIDCScopes, now)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
if settings.OIDCTLSInsecureSkipVerify {
tlsInsecure = "1"
} else {
tlsInsecure = "0"
}
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
"auth.oidc.tls_insecure_skip_verify", tlsInsecure, now)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
"auth.ldap.default_user_group_id", settings.LDAPDefaultUserGroupID, now)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
"auth.oidc.default_user_group_id", settings.OIDCDefaultUserGroupID, now)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
err = commitIfOwned(tx, owned)
if err != nil {
return err
}
return nil
} }
func (s *Store) GetTLSSettings() (models.TLSSettings, error) { func (s *Store) GetTLSSettings() (models.TLSSettings, error) {
@@ -836,16 +618,17 @@ func (s *Store) GetUserByAPIKeyHash(tokenHash string) (models.User, error) {
now = time.Now().UTC() now = time.Now().UTC()
currentUnix = now.Unix() currentUnix = now.Unix()
row = tx.QueryRow(` row = tx.QueryRow(`
SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, u.auth_source, u.created_at, u.updated_at, k.id SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, COALESCE(ap.public_id, ''), u.created_at, u.updated_at, k.id
FROM api_keys k FROM api_keys k
JOIN users u ON u.id = k.user_id JOIN users u ON u.id = k.user_id
LEFT JOIN auth_providers ap ON ap.id = u.auth_provider_id
WHERE k.token_hash = ? WHERE k.token_hash = ?
AND u.disabled = 0 AND u.disabled = 0
AND k.disabled = 0 AND k.disabled = 0
AND (k.expires_at = 0 OR k.expires_at > ?) AND (k.expires_at = 0 OR k.expires_at > ?)
LIMIT 1 LIMIT 1
`, tokenHash, currentUnix) `, tokenHash, currentUnix)
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &created, &updated, &keyID) err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthProviderID, &created, &updated, &keyID)
if err != nil { if err != nil {
rollbackIfOwned(tx, owned) rollbackIfOwned(tx, owned)
return user, err return user, err
@@ -901,7 +684,7 @@ func (s *Store) GetSessionUser(token string) (models.User, time.Time, bool, erro
var created time.Time var created time.Time
var updated time.Time var updated time.Time
row = s.QueryRow(` row = s.QueryRow(`
SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, u.auth_source, COALESCE(t.enabled, 0), u.totp_required, SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, COALESCE(ap.public_id, ''), COALESCE(t.enabled, 0), u.totp_required,
(u.totp_required OR EXISTS ( (u.totp_required OR EXISTS (
SELECT 1 FROM user_group_members m JOIN user_groups g ON g.id = m.group_id SELECT 1 FROM user_group_members m JOIN user_groups g ON g.id = m.group_id
WHERE m.user_id = u.id AND g.disabled = 0 AND g.totp_required = 1 WHERE m.user_id = u.id AND g.disabled = 0 AND g.totp_required = 1
@@ -912,9 +695,10 @@ func (s *Store) GetSessionUser(token string) (models.User, time.Time, bool, erro
s.totp_verified, u.created_at, u.updated_at, s.expires_at s.totp_verified, u.created_at, u.updated_at, s.expires_at
FROM sessions s JOIN users u ON u.id = s.user_id FROM sessions s JOIN users u ON u.id = s.user_id
LEFT JOIN user_totp t ON t.user_id = u.id LEFT JOIN user_totp t ON t.user_id = u.id
LEFT JOIN auth_providers ap ON ap.id = u.auth_provider_id
WHERE s.token = ? AND u.disabled = 0 WHERE s.token = ? AND u.disabled = 0
`, token) `, token)
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &user.TOTPEnabled, &user.TOTPRequired, &user.TOTPEffectiveRequired, &totpVerified, &created, &updated, &expires) err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthProviderID, &user.TOTPEnabled, &user.TOTPRequired, &user.TOTPEffectiveRequired, &totpVerified, &created, &updated, &expires)
if err != nil { if err != nil {
return user, time.Time{}, false, err return user, time.Time{}, false, err
} }
+42 -6
View File
@@ -171,7 +171,7 @@ func (s *Store) ListTLSAuthPolicies() ([]models.TLSAuthPolicy, error) {
var allowedFingerprints string var allowedFingerprints string
var requiredPermissions string var requiredPermissions string
rows, err = s.Query(`SELECT public_id, name, description, read_mode, write_mode, require_principal_for_write, allowed_pki_client_cert_ids, allowed_cert_fingerprints, required_permissions, permission_match, required_scope, created_at, updated_at FROM tls_auth_policies ORDER BY name`) rows, err = s.Query(`SELECT p.public_id, p.name, p.description, p.read_mode, p.write_mode, p.require_principal_for_write, COALESCE((SELECT GROUP_CONCAT(c.public_id, ',') FROM tls_auth_policy_allowed_pki_certs apc JOIN pki_certs c ON c.id = apc.cert_id WHERE apc.policy_id = p.id), '') AS allowed_pki_client_cert_ids, p.allowed_cert_fingerprints, p.required_permissions, p.permission_match, p.required_scope, p.created_at, p.updated_at FROM tls_auth_policies p ORDER BY p.name`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -201,7 +201,7 @@ func (s *Store) GetTLSAuthPolicy(id string) (models.TLSAuthPolicy, error) {
var allowedFingerprints string var allowedFingerprints string
var requiredPermissions string var requiredPermissions string
row = s.QueryRow(`SELECT public_id, name, description, read_mode, write_mode, require_principal_for_write, allowed_pki_client_cert_ids, allowed_cert_fingerprints, required_permissions, permission_match, required_scope, created_at, updated_at FROM tls_auth_policies WHERE public_id = ?`, id) row = s.QueryRow(`SELECT p.public_id, p.name, p.description, p.read_mode, p.write_mode, p.require_principal_for_write, COALESCE((SELECT GROUP_CONCAT(c.public_id, ',') FROM tls_auth_policy_allowed_pki_certs apc JOIN pki_certs c ON c.id = apc.cert_id WHERE apc.policy_id = p.id), '') AS allowed_pki_client_cert_ids, p.allowed_cert_fingerprints, p.required_permissions, p.permission_match, p.required_scope, p.created_at, p.updated_at FROM tls_auth_policies p WHERE p.public_id = ?`, id)
err = row.Scan(&item.ID, &item.Name, &item.Description, &item.ReadMode, &item.WriteMode, &item.RequirePrincipalForWrite, &allowedPKICertIDs, &allowedFingerprints, &requiredPermissions, &item.PermissionMatch, &item.RequiredScope, &item.CreatedAt, &item.UpdatedAt) err = row.Scan(&item.ID, &item.Name, &item.Description, &item.ReadMode, &item.WriteMode, &item.RequirePrincipalForWrite, &allowedPKICertIDs, &allowedFingerprints, &requiredPermissions, &item.PermissionMatch, &item.RequiredScope, &item.CreatedAt, &item.UpdatedAt)
if err != nil { if err != nil {
return item, err return item, err
@@ -216,6 +216,10 @@ func (s *Store) CreateTLSAuthPolicy(item models.TLSAuthPolicy) (models.TLSAuthPo
var id string var id string
var now int64 var now int64
var err error var err error
var result sql.Result
var internalID int64
var i int
var certPublicID string
if item.ID == "" { if item.ID == "" {
id, err = util.NewID() id, err = util.NewID()
@@ -227,24 +231,56 @@ func (s *Store) CreateTLSAuthPolicy(item models.TLSAuthPolicy) (models.TLSAuthPo
now = time.Now().UTC().Unix() now = time.Now().UTC().Unix()
item.CreatedAt = now item.CreatedAt = now
item.UpdatedAt = now item.UpdatedAt = now
_, err = s.Exec(`INSERT INTO tls_auth_policies (public_id, name, description, read_mode, write_mode, require_principal_for_write, allowed_pki_client_cert_ids, allowed_cert_fingerprints, required_permissions, permission_match, required_scope, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, result, err = s.Exec(`INSERT INTO tls_auth_policies (public_id, name, description, read_mode, write_mode, require_principal_for_write, allowed_cert_fingerprints, required_permissions, permission_match, required_scope, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
item.ID, item.Name, item.Description, item.ReadMode, item.WriteMode, item.RequirePrincipalForWrite, strings.Join(item.AllowedPKIClientCertIDs, ","), strings.Join(item.AllowedCertFingerprints, ","), strings.Join(item.RequiredPermissions, ","), item.PermissionMatch, item.RequiredScope, item.CreatedAt, item.UpdatedAt) item.ID, item.Name, item.Description, item.ReadMode, item.WriteMode, item.RequirePrincipalForWrite, strings.Join(item.AllowedCertFingerprints, ","), strings.Join(item.RequiredPermissions, ","), item.PermissionMatch, item.RequiredScope, item.CreatedAt, item.UpdatedAt)
if err != nil { if err != nil {
return item, err return item, err
} }
internalID, err = result.LastInsertId()
if err != nil {
return item, err
}
for i = 0; i < len(item.AllowedPKIClientCertIDs); i++ {
certPublicID = item.AllowedPKIClientCertIDs[i]
_, err = s.Exec(`INSERT OR IGNORE INTO tls_auth_policy_allowed_pki_certs (policy_id, cert_id) SELECT ?, id FROM pki_certs WHERE public_id = ?`, internalID, certPublicID)
if err != nil {
return item, err
}
}
return item, nil return item, nil
} }
func (s *Store) UpdateTLSAuthPolicy(item models.TLSAuthPolicy) error { func (s *Store) UpdateTLSAuthPolicy(item models.TLSAuthPolicy) error {
var now int64 var now int64
var err error var err error
var row *sql.Row
var internalID int64
var i int
row = s.QueryRow(`SELECT id FROM tls_auth_policies WHERE public_id = ?`, item.ID)
err = row.Scan(&internalID)
if err != nil {
return err
}
now = time.Now().UTC().Unix() now = time.Now().UTC().Unix()
item.UpdatedAt = now item.UpdatedAt = now
_, err = s.Exec(`UPDATE tls_auth_policies SET name = ?, description = ?, read_mode = ?, write_mode = ?, require_principal_for_write = ?, allowed_pki_client_cert_ids = ?, allowed_cert_fingerprints = ?, required_permissions = ?, permission_match = ?, required_scope = ?, updated_at = ? WHERE public_id = ?`, _, err = s.Exec(`UPDATE tls_auth_policies SET name = ?, description = ?, read_mode = ?, write_mode = ?, require_principal_for_write = ?, allowed_cert_fingerprints = ?, required_permissions = ?, permission_match = ?, required_scope = ?, updated_at = ? WHERE public_id = ?`,
item.Name, item.Description, item.ReadMode, item.WriteMode, item.RequirePrincipalForWrite, strings.Join(item.AllowedPKIClientCertIDs, ","), strings.Join(item.AllowedCertFingerprints, ","), strings.Join(item.RequiredPermissions, ","), item.PermissionMatch, item.RequiredScope, item.UpdatedAt, item.ID) item.Name, item.Description, item.ReadMode, item.WriteMode, item.RequirePrincipalForWrite, strings.Join(item.AllowedCertFingerprints, ","), strings.Join(item.RequiredPermissions, ","), item.PermissionMatch, item.RequiredScope, item.UpdatedAt, item.ID)
if err != nil {
return err return err
} }
_, err = s.Exec(`DELETE FROM tls_auth_policy_allowed_pki_certs WHERE policy_id = ?`, internalID)
if err != nil {
return err
}
for i = 0; i < len(item.AllowedPKIClientCertIDs); i++ {
_, err = s.Exec(`INSERT OR IGNORE INTO tls_auth_policy_allowed_pki_certs (policy_id, cert_id) SELECT ?, id FROM pki_certs WHERE public_id = ?`, internalID, item.AllowedPKIClientCertIDs[i])
if err != nil {
return err
}
}
return nil
}
func (s *Store) DeleteTLSAuthPolicy(id string) error { func (s *Store) DeleteTLSAuthPolicy(id string) error {
var err error var err error
+4 -4
View File
@@ -15,7 +15,7 @@ func (s *Store) ListTLSListeners() ([]models.TLSListener, error) {
var httpAddrs string var httpAddrs string
var httpsAddrs string var httpsAddrs string
var endpointPoliciesJSON string var endpointPoliciesJSON string
rows, err = s.Query(`SELECT l.public_id, l.name, l.enabled, l.http_addrs, l.https_addrs, l.endpoint_policies_json, l.tls_server_cert_source, l.tls_cert_file, l.tls_key_file, l.tls_pki_server_cert_id, l.tls_client_auth, l.tls_client_ca_file, l.tls_pki_client_ca_id, l.tls_min_version, l.created_at, l.updated_at FROM tls_listeners l ORDER BY l.name`) rows, err = s.Query(`SELECT l.public_id, l.name, l.enabled, l.http_addrs, l.https_addrs, l.endpoint_policies_json, l.tls_server_cert_source, l.tls_cert_file, l.tls_key_file, COALESCE(sc.public_id, ''), l.tls_client_auth, l.tls_client_ca_file, COALESCE(cca.public_id, ''), l.tls_min_version, l.created_at, l.updated_at FROM tls_listeners l LEFT JOIN pki_certs sc ON sc.id = l.tls_pki_server_cert_id LEFT JOIN pki_cas cca ON cca.id = l.tls_pki_client_ca_id ORDER BY l.name`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -44,7 +44,7 @@ func (s *Store) GetTLSListener(id string) (models.TLSListener, error) {
var httpsAddrs string var httpsAddrs string
var endpointPoliciesJSON string var endpointPoliciesJSON string
var err error var err error
row = s.QueryRow(`SELECT l.public_id, l.name, l.enabled, l.http_addrs, l.https_addrs, l.endpoint_policies_json, l.tls_server_cert_source, l.tls_cert_file, l.tls_key_file, l.tls_pki_server_cert_id, l.tls_client_auth, l.tls_client_ca_file, l.tls_pki_client_ca_id, l.tls_min_version, l.created_at, l.updated_at FROM tls_listeners l WHERE l.public_id = ?`, id) row = s.QueryRow(`SELECT l.public_id, l.name, l.enabled, l.http_addrs, l.https_addrs, l.endpoint_policies_json, l.tls_server_cert_source, l.tls_cert_file, l.tls_key_file, COALESCE(sc.public_id, ''), l.tls_client_auth, l.tls_client_ca_file, COALESCE(cca.public_id, ''), l.tls_min_version, l.created_at, l.updated_at FROM tls_listeners l LEFT JOIN pki_certs sc ON sc.id = l.tls_pki_server_cert_id LEFT JOIN pki_cas cca ON cca.id = l.tls_pki_client_ca_id WHERE l.public_id = ?`, id)
err = row.Scan(&item.ID, &item.Name, &item.Enabled, &httpAddrs, &httpsAddrs, &endpointPoliciesJSON, &item.TLSServerCertSource, &item.TLSCertFile, &item.TLSKeyFile, &item.TLSPKIServerCertID, &item.TLSClientAuth, &item.TLSClientCAFile, &item.TLSPKIClientCAID, &item.TLSMinVersion, &item.CreatedAt, &item.UpdatedAt) err = row.Scan(&item.ID, &item.Name, &item.Enabled, &httpAddrs, &httpsAddrs, &endpointPoliciesJSON, &item.TLSServerCertSource, &item.TLSCertFile, &item.TLSKeyFile, &item.TLSPKIServerCertID, &item.TLSClientAuth, &item.TLSClientCAFile, &item.TLSPKIClientCAID, &item.TLSMinVersion, &item.CreatedAt, &item.UpdatedAt)
if err != nil { if err != nil {
return item, err return item, err
@@ -74,7 +74,7 @@ func (s *Store) CreateTLSListener(item models.TLSListener) (models.TLSListener,
if err != nil { if err != nil {
return item, err return item, err
} }
_, err = s.Exec(`INSERT INTO tls_listeners (public_id, name, enabled, http_addrs, https_addrs, endpoint_policies_json, tls_server_cert_source, tls_cert_file, tls_key_file, tls_pki_server_cert_id, tls_client_auth, tls_client_ca_file, tls_pki_client_ca_id, tls_min_version, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, _, err = s.Exec(`INSERT INTO tls_listeners (public_id, name, enabled, http_addrs, https_addrs, endpoint_policies_json, tls_server_cert_source, tls_cert_file, tls_key_file, tls_pki_server_cert_id, tls_client_auth, tls_client_ca_file, tls_pki_client_ca_id, tls_min_version, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT id FROM pki_certs WHERE public_id = ?), ?, ?, (SELECT id FROM pki_cas WHERE public_id = ?), ?, ?, ?)`,
item.ID, item.Name, item.Enabled, strings.Join(item.HTTPAddrs, ","), strings.Join(item.HTTPSAddrs, ","), endpointPoliciesJSON, item.TLSServerCertSource, item.TLSCertFile, item.TLSKeyFile, item.TLSPKIServerCertID, item.TLSClientAuth, item.TLSClientCAFile, item.TLSPKIClientCAID, item.TLSMinVersion, item.CreatedAt, item.UpdatedAt) item.ID, item.Name, item.Enabled, strings.Join(item.HTTPAddrs, ","), strings.Join(item.HTTPSAddrs, ","), endpointPoliciesJSON, item.TLSServerCertSource, item.TLSCertFile, item.TLSKeyFile, item.TLSPKIServerCertID, item.TLSClientAuth, item.TLSClientCAFile, item.TLSPKIClientCAID, item.TLSMinVersion, item.CreatedAt, item.UpdatedAt)
if err != nil { if err != nil {
return item, err return item, err
@@ -92,7 +92,7 @@ func (s *Store) UpdateTLSListener(item models.TLSListener) error {
if err != nil { if err != nil {
return err return err
} }
_, err = s.Exec(`UPDATE tls_listeners SET name = ?, enabled = ?, http_addrs = ?, https_addrs = ?, endpoint_policies_json = ?, tls_server_cert_source = ?, tls_cert_file = ?, tls_key_file = ?, tls_pki_server_cert_id = ?, tls_client_auth = ?, tls_client_ca_file = ?, tls_pki_client_ca_id = ?, tls_min_version = ?, updated_at = ? WHERE public_id = ?`, _, err = s.Exec(`UPDATE tls_listeners SET name = ?, enabled = ?, http_addrs = ?, https_addrs = ?, endpoint_policies_json = ?, tls_server_cert_source = ?, tls_cert_file = ?, tls_key_file = ?, tls_pki_server_cert_id = (SELECT id FROM pki_certs WHERE public_id = ?), tls_client_auth = ?, tls_client_ca_file = ?, tls_pki_client_ca_id = (SELECT id FROM pki_cas WHERE public_id = ?), tls_min_version = ?, updated_at = ? WHERE public_id = ?`,
item.Name, item.Enabled, strings.Join(item.HTTPAddrs, ","), strings.Join(item.HTTPSAddrs, ","), endpointPoliciesJSON, item.TLSServerCertSource, item.TLSCertFile, item.TLSKeyFile, item.TLSPKIServerCertID, item.TLSClientAuth, item.TLSClientCAFile, item.TLSPKIClientCAID, item.TLSMinVersion, item.UpdatedAt, item.ID) item.Name, item.Enabled, strings.Join(item.HTTPAddrs, ","), strings.Join(item.HTTPSAddrs, ","), endpointPoliciesJSON, item.TLSServerCertSource, item.TLSCertFile, item.TLSKeyFile, item.TLSPKIServerCertID, item.TLSClientAuth, item.TLSClientCAFile, item.TLSPKIClientCAID, item.TLSMinVersion, item.UpdatedAt, item.ID)
return err return err
} }
+76 -274
View File
@@ -161,27 +161,7 @@ type updateMeRequest struct {
Password string `json:"password"` Password string `json:"password"`
} }
type updateAuthSettingsRequest struct {
AuthMode string `json:"auth_mode"`
OIDCEnabled bool `json:"oidc_enabled"`
LDAPURL string `json:"ldap_url"`
LDAPBindDN string `json:"ldap_bind_dn"`
LDAPBindPassword string `json:"ldap_bind_password"`
LDAPUserBaseDN string `json:"ldap_user_base_dn"`
LDAPUserFilter string `json:"ldap_user_filter"`
LDAPTLSInsecureSkipVerify bool `json:"ldap_tls_insecure_skip_verify"`
OIDCClientID string `json:"oidc_client_id"`
OIDCClientSecret string `json:"oidc_client_secret"`
OIDCAuthorizeURL string `json:"oidc_authorize_url"`
OIDCTokenURL string `json:"oidc_token_url"`
OIDCUserInfoURL string `json:"oidc_userinfo_url"`
OIDCRedirectURL string `json:"oidc_redirect_url"`
OIDCScopes string `json:"oidc_scopes"`
OIDCTLSInsecureSkipVerify bool `json:"oidc_tls_insecure_skip_verify"`
}
type testLDAPSettingsRequest struct { type testLDAPSettingsRequest struct {
AuthMode string `json:"auth_mode"`
LDAPURL string `json:"ldap_url"` LDAPURL string `json:"ldap_url"`
LDAPBindDN string `json:"ldap_bind_dn"` LDAPBindDN string `json:"ldap_bind_dn"`
LDAPBindPassword string `json:"ldap_bind_password"` LDAPBindPassword string `json:"ldap_bind_password"`
@@ -458,7 +438,14 @@ func (api *API) Login(w http.ResponseWriter, r *http.Request, _ map[string]strin
var ldapUser auth.LDAPUser var ldapUser auth.LDAPUser
var newUser models.User var newUser models.User
var created models.User var created models.User
var authCfg config.Config var dbProvider models.AuthProvider
var providers []models.AuthProvider
var p models.AuthProvider
var cfg config.Config
// this usually handles login by builtin mechanism.
// an external mechanism is handled by a relevant callback.
// for example, OIDCCallbackWithProvider for oidc
err = DecodeJSON(r, &req) err = DecodeJSON(r, &req)
if err != nil { if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json") WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json")
@@ -469,49 +456,81 @@ func (api *API) Login(w http.ResponseWriter, r *http.Request, _ map[string]strin
return return
} }
// we need to find the user in the database
user, storedHash, err = api.store(r).GetUserByUsername(req.Username) user, storedHash, err = api.store(r).GetUserByUsername(req.Username)
if err == nil && user.Disabled { if err == nil && user.Disabled {
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "user disabled") WriteJSONWithErrorReason(w, r, http.StatusForbidden, "user disabled")
return return
} }
if err == nil && storedHash != "" && auth.ComparePassword(storedHash, req.Password) == nil {
// if db authentication method is enabled, compare the password against the password loaded from the database
// if the stored password is not blank and the given password is equal to it, it calls finishLogin()
// to issue a session or require an otp code depending on configuration.
dbProvider, _ = api.store(r).GetAuthProvider(db.BuiltinDBProviderPublicID)
if dbProvider.Enabled && err == nil && user.AuthProviderID == db.BuiltinDBProviderPublicID && storedHash != "" && auth.ComparePassword(storedHash, req.Password) == nil {
api.finishLogin(w, r, user, req.OTPCode) api.finishLogin(w, r, user, req.OTPCode)
return return
} }
authCfg, _ = api.effectiveAuthConfig(r.Context()) // try ldap authentication
if authCfg.AuthMode == "ldap" || authCfg.AuthMode == "hybrid" { providers, err = api.store(r).ListEnabledAuthProviders() // gets the enabled providers only
api.Logger.Write(logIDAuth, codit_logger.LOG_INFO, "ldap login attempt username=%s mode=%s", req.Username, authCfg.AuthMode) if err != nil {
ldapUser, err = auth.LDAPAuthenticateContext(r.Context(), authCfg, req.Username, req.Password) WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, fmt.Sprintf("failed to list enabled auth providers - %s", err.Error()))
if err == nil {
api.Logger.Write(logIDAuth, codit_logger.LOG_INFO, "ldap login success username=%s", req.Username)
if user.ID != "" && user.Disabled {
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "user disabled")
return return
} }
if user.ID == "" { for _, p = range providers {
if p.Type != db.AuthProviderTypeLDAP { continue }
api.Logger.Write(logIDAuth, codit_logger.LOG_INFO, "ldap login attempt username=%s provider=%s", req.Username, p.Name)
cfg = api.Cfg
cfg.LDAPURL = p.LDAPUrl
cfg.LDAPBindDN = p.LDAPBindDN
cfg.LDAPBindPassword = p.LDAPBindPassword
cfg.LDAPUserBaseDN = p.LDAPUserBaseDN
cfg.LDAPUserFilter = p.LDAPUserFilter
cfg.LDAPTLSInsecureSkipVerify = p.LDAPTLSInsecureSkipVerify
ldapUser, err = auth.LDAPAuthenticateContext(r.Context(), cfg, req.Username, req.Password)
if err != nil {
api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "ldap login failed username=%s provider=%s err=%v", req.Username, p.Name, err)
continue
}
api.Logger.Write(logIDAuth, codit_logger.LOG_INFO, "ldap login success username=%s provider=%s", req.Username, p.Name)
if user.ID != "" {
if user.AuthProviderID != p.ID {
// the user entry exists in the database.
// check if the user entry has been created of this ldap server.
api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "ldap login rejected username=%s provider=%s reason=provider_mismatch user_provider=%s", req.Username, p.Name, user.AuthProviderID)
continue
}
} else {
// user not found. create a new user from this ldap auth source.
// in this case, the user info from the first successful ldap server wins.
// it can be confusing to users if there are duplicate user IDs across
// multiple enabled ldap servers and thier passwords are not the same
// on those ldap servres.
newUser = models.User{ newUser = models.User{
Username: ldapUser.Username, Username: ldapUser.Username,
DisplayName: ldapUser.DisplayName, DisplayName: ldapUser.DisplayName,
Email: ldapUser.Email, Email: ldapUser.Email,
AuthSource: "ldap", AuthProviderID: p.ID,
}
if newUser.Email == "" {
newUser.Email = ldapUser.Username + "@local"
} }
if newUser.Email == "" { newUser.Email = ldapUser.Username + "@ldap" }
created, err = api.store(r).CreateUser(newUser, "") created, err = api.store(r).CreateUser(newUser, "")
if err == nil { if err == nil {
user = created user = created
if authCfg.LDAPDefaultUserGroupID != "" { if p.DefaultUserGroupID != "" {
_ = api.store(r).AddUserGroupMember(authCfg.LDAPDefaultUserGroupID, user.ID) _ = api.store(r).AddUserGroupMember(p.DefaultUserGroupID, user.ID)
} }
} else {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
} }
} }
api.finishLogin(w, r, user, req.OTPCode) api.finishLogin(w, r, user, req.OTPCode)
return return
} }
api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "ldap login failed username=%s err=%v", req.Username, err)
}
WriteJSONWithErrorReason(w, r, http.StatusUnauthorized, "invalid credentials") WriteJSONWithErrorReason(w, r, http.StatusUnauthorized, "invalid credentials")
} }
@@ -521,10 +540,10 @@ func (api *API) issueSession(w http.ResponseWriter, r *http.Request, user models
var err error var err error
var expires time.Time var expires time.Time
var cookie *http.Cookie var cookie *http.Cookie
token, err = auth.NewSessionToken() token, err = auth.NewSessionToken()
if err != nil { if err != nil { return }
return
}
expires = auth.SessionExpiry(api.Cfg) expires = auth.SessionExpiry(api.Cfg)
_ = api.store(r).DeleteExpiredSessions(time.Now().UTC()) _ = api.store(r).DeleteExpiredSessions(time.Now().UTC())
_ = api.store(r).CreateSession(user.ID, token, expires, totpVerified) _ = api.store(r).CreateSession(user.ID, token, expires, totpVerified)
@@ -611,7 +630,7 @@ func (api *API) UpdateMe(w http.ResponseWriter, r *http.Request, _ map[string]st
user.Email = req.Email user.Email = req.Email
} }
newPassword = strings.TrimSpace(req.Password) != "" newPassword = strings.TrimSpace(req.Password) != ""
if newPassword && user.AuthSource != "db" { if newPassword && user.AuthProviderID != "" {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "password update not supported for non-db users") WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "password update not supported for non-db users")
return return
} }
@@ -679,7 +698,7 @@ func (api *API) CreateUser(w http.ResponseWriter, r *http.Request, _ map[string]
Email: req.Email, Email: req.Email,
IsAdmin: req.IsAdmin, IsAdmin: req.IsAdmin,
TOTPRequired: req.TOTPRequired, TOTPRequired: req.TOTPRequired,
AuthSource: "db", AuthProviderID: db.BuiltinDBProviderPublicID,
} }
created, err = api.store(r).CreateUser(user, hash) created, err = api.store(r).CreateUser(user, hash)
if err != nil { if err != nil {
@@ -922,87 +941,11 @@ func (api *API) RemoveUserGroupMember(w http.ResponseWriter, r *http.Request, pa
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
func (api *API) GetAuthSettings(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var settings models.AuthSettings
var err error
var user models.User
var ok bool
if !api.requireAdmin(w, r) {
return
}
user, ok = middleware.UserFromContext(r.Context())
if ok {
api.Logger.Write(logIDAuth, codit_logger.LOG_INFO, "auth settings fetch requested by user=%s", user.Username)
}
settings, err = api.getMergedAuthSettings(r.Context())
if err != nil {
api.Logger.Write(logIDAuth, codit_logger.LOG_ERROR, "auth settings fetch failed err=%v", err)
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
api.Logger.Write(logIDAuth, codit_logger.LOG_INFO, "auth settings fetch success mode=%s ldap_url=%s oidc_authorize_url=%s", settings.AuthMode, settings.LDAPURL, settings.OIDCAuthorizeURL)
WriteJSON(w, http.StatusOK, settings)
}
func (api *API) UpdateAuthSettings(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var req updateAuthSettingsRequest
var err error
var settings models.AuthSettings
var user models.User
var ok bool
if !api.requireAdmin(w, r) {
return
}
user, ok = middleware.UserFromContext(r.Context())
err = DecodeJSON(r, &req)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json")
return
}
settings = models.AuthSettings{
AuthMode: normalizeAuthMode(req.AuthMode),
OIDCEnabled: req.OIDCEnabled,
LDAPURL: strings.TrimSpace(req.LDAPURL),
LDAPBindDN: strings.TrimSpace(req.LDAPBindDN),
LDAPBindPassword: req.LDAPBindPassword,
LDAPUserBaseDN: strings.TrimSpace(req.LDAPUserBaseDN),
LDAPUserFilter: strings.TrimSpace(req.LDAPUserFilter),
LDAPTLSInsecureSkipVerify: req.LDAPTLSInsecureSkipVerify,
OIDCClientID: strings.TrimSpace(req.OIDCClientID),
OIDCClientSecret: req.OIDCClientSecret,
OIDCAuthorizeURL: strings.TrimSpace(req.OIDCAuthorizeURL),
OIDCTokenURL: strings.TrimSpace(req.OIDCTokenURL),
OIDCUserInfoURL: strings.TrimSpace(req.OIDCUserInfoURL),
OIDCRedirectURL: strings.TrimSpace(req.OIDCRedirectURL),
OIDCScopes: strings.TrimSpace(req.OIDCScopes),
OIDCTLSInsecureSkipVerify: req.OIDCTLSInsecureSkipVerify,
}
if settings.LDAPUserFilter == "" {
settings.LDAPUserFilter = "(uid={username})"
}
if settings.OIDCScopes == "" {
settings.OIDCScopes = "openid profile email"
}
if ok {
api.Logger.Write(logIDAuth, codit_logger.LOG_INFO, "auth settings update requested by user=%s mode=%s oidc_enabled=%t ldap_url=%s oidc_authorize_url=%s",
user.Username, settings.AuthMode, settings.OIDCEnabled, settings.LDAPURL, settings.OIDCAuthorizeURL)
}
err = api.store(r).SetAuthSettings(settings)
if err != nil {
api.Logger.Write(logIDAuth, codit_logger.LOG_ERROR, "auth settings update failed err=%v", err)
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
api.Logger.Write(logIDAuth, codit_logger.LOG_INFO, "auth settings update success mode=%s oidc_enabled=%t ldap_url=%s oidc_authorize_url=%s", settings.AuthMode, settings.OIDCEnabled, settings.LDAPURL, settings.OIDCAuthorizeURL)
WriteJSON(w, http.StatusOK, settings)
}
func (api *API) TestLDAPSettings(w http.ResponseWriter, r *http.Request, _ map[string]string) { func (api *API) TestLDAPSettings(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var req testLDAPSettingsRequest var req testLDAPSettingsRequest
var err error var err error
var merged models.AuthSettings
var cfg config.Config var cfg config.Config
var user auth.LDAPUser var ldapUser auth.LDAPUser
if !api.requireAdmin(w, r) { if !api.requireAdmin(w, r) {
return return
} }
@@ -1011,42 +954,18 @@ func (api *API) TestLDAPSettings(w http.ResponseWriter, r *http.Request, _ map[s
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json") WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json")
return return
} }
merged, err = api.getMergedAuthSettings(r.Context()) cfg = api.Cfg
if err != nil { cfg.LDAPURL = strings.TrimSpace(req.LDAPURL)
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error()) cfg.LDAPBindDN = strings.TrimSpace(req.LDAPBindDN)
return cfg.LDAPBindPassword = req.LDAPBindPassword
} cfg.LDAPUserBaseDN = strings.TrimSpace(req.LDAPUserBaseDN)
if strings.TrimSpace(req.AuthMode) != "" { cfg.LDAPUserFilter = strings.TrimSpace(req.LDAPUserFilter)
merged.AuthMode = normalizeAuthMode(req.AuthMode) if cfg.LDAPUserFilter == "" {
} cfg.LDAPUserFilter = "(uid={username})"
if strings.TrimSpace(req.LDAPURL) != "" {
merged.LDAPURL = strings.TrimSpace(req.LDAPURL)
}
if strings.TrimSpace(req.LDAPBindDN) != "" {
merged.LDAPBindDN = strings.TrimSpace(req.LDAPBindDN)
}
if req.LDAPBindPassword != "" {
merged.LDAPBindPassword = req.LDAPBindPassword
}
if strings.TrimSpace(req.LDAPUserBaseDN) != "" {
merged.LDAPUserBaseDN = strings.TrimSpace(req.LDAPUserBaseDN)
}
if strings.TrimSpace(req.LDAPUserFilter) != "" {
merged.LDAPUserFilter = strings.TrimSpace(req.LDAPUserFilter)
} }
if req.LDAPTLSInsecureSkipVerify != nil { if req.LDAPTLSInsecureSkipVerify != nil {
merged.LDAPTLSInsecureSkipVerify = *req.LDAPTLSInsecureSkipVerify cfg.LDAPTLSInsecureSkipVerify = *req.LDAPTLSInsecureSkipVerify
} }
if merged.LDAPUserFilter == "" {
merged.LDAPUserFilter = "(uid={username})"
}
cfg = api.Cfg
cfg.AuthMode = merged.AuthMode
cfg.LDAPURL = merged.LDAPURL
cfg.LDAPBindDN = merged.LDAPBindDN
cfg.LDAPBindPassword = merged.LDAPBindPassword
cfg.LDAPUserBaseDN = merged.LDAPUserBaseDN
cfg.LDAPUserFilter = merged.LDAPUserFilter
err = auth.LDAPTestConnectionContext(r.Context(), cfg) err = auth.LDAPTestConnectionContext(r.Context(), cfg)
if err != nil { if err != nil {
api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "ldap test connection failed url=%s err=%v", cfg.LDAPURL, err) api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "ldap test connection failed url=%s err=%v", cfg.LDAPURL, err)
@@ -1055,14 +974,14 @@ func (api *API) TestLDAPSettings(w http.ResponseWriter, r *http.Request, _ map[s
} }
api.Logger.Write(logIDAuth, codit_logger.LOG_INFO, "ldap test connection success url=%s", cfg.LDAPURL) api.Logger.Write(logIDAuth, codit_logger.LOG_INFO, "ldap test connection success url=%s", cfg.LDAPURL)
if strings.TrimSpace(req.Username) != "" && strings.TrimSpace(req.Password) != "" { if strings.TrimSpace(req.Username) != "" && strings.TrimSpace(req.Password) != "" {
user, err = auth.LDAPAuthenticateContext(r.Context(), cfg, strings.TrimSpace(req.Username), req.Password) ldapUser, err = auth.LDAPAuthenticateContext(r.Context(), cfg, strings.TrimSpace(req.Username), req.Password)
if err != nil { if err != nil {
api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "ldap test bind failed username=%s err=%v", strings.TrimSpace(req.Username), err) api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "ldap test bind failed username=%s err=%v", strings.TrimSpace(req.Username), err)
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error()) WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return return
} }
api.Logger.Write(logIDAuth, codit_logger.LOG_INFO, "ldap test bind success username=%s", user.Username) api.Logger.Write(logIDAuth, codit_logger.LOG_INFO, "ldap test bind success username=%s", ldapUser.Username)
WriteJSON(w, http.StatusOK, map[string]string{"status": "ok", "user": user.Username}) WriteJSON(w, http.StatusOK, map[string]string{"status": "ok", "user": ldapUser.Username})
return return
} }
WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"}) WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"})
@@ -5532,123 +5451,6 @@ func normalizeProjectHomePage(value string) string {
return "info" return "info"
} }
func normalizeAuthMode(value string) string {
var v string
v = strings.ToLower(strings.TrimSpace(value))
if v == "ldap" || v == "hybrid" || v == "db" {
return v
}
return "db"
}
func (api *API) getMergedAuthSettings(ctx context.Context) (models.AuthSettings, error) {
var settings models.AuthSettings
var saved models.AuthSettings
var err error
settings = models.AuthSettings{
AuthMode: normalizeAuthMode(api.Cfg.AuthMode),
LDAPURL: api.Cfg.LDAPURL,
LDAPBindDN: api.Cfg.LDAPBindDN,
LDAPBindPassword: api.Cfg.LDAPBindPassword,
LDAPUserBaseDN: api.Cfg.LDAPUserBaseDN,
LDAPUserFilter: api.Cfg.LDAPUserFilter,
LDAPTLSInsecureSkipVerify: api.Cfg.LDAPTLSInsecureSkipVerify,
OIDCClientID: api.Cfg.OIDCClientID,
OIDCClientSecret: api.Cfg.OIDCClientSecret,
OIDCAuthorizeURL: api.Cfg.OIDCAuthorizeURL,
OIDCTokenURL: api.Cfg.OIDCTokenURL,
OIDCUserInfoURL: api.Cfg.OIDCUserInfoURL,
OIDCRedirectURL: api.Cfg.OIDCRedirectURL,
OIDCScopes: api.Cfg.OIDCScopes,
OIDCEnabled: api.Cfg.OIDCEnabled,
OIDCTLSInsecureSkipVerify: api.Cfg.OIDCTLSInsecureSkipVerify,
}
saved, err = api.storeContext(ctx).GetAuthSettings()
if err != nil {
return settings, err
}
if strings.TrimSpace(saved.AuthMode) != "" {
settings.AuthMode = normalizeAuthMode(saved.AuthMode)
}
settings.OIDCEnabled = saved.OIDCEnabled
if strings.ToLower(strings.TrimSpace(saved.AuthMode)) == "oidc" {
settings.OIDCEnabled = true
}
if strings.TrimSpace(saved.LDAPURL) != "" {
settings.LDAPURL = saved.LDAPURL
}
if strings.TrimSpace(saved.LDAPBindDN) != "" {
settings.LDAPBindDN = saved.LDAPBindDN
}
if saved.LDAPBindPassword != "" {
settings.LDAPBindPassword = saved.LDAPBindPassword
}
if strings.TrimSpace(saved.LDAPUserBaseDN) != "" {
settings.LDAPUserBaseDN = saved.LDAPUserBaseDN
}
if strings.TrimSpace(saved.LDAPUserFilter) != "" {
settings.LDAPUserFilter = saved.LDAPUserFilter
}
if settings.LDAPUserFilter == "" {
settings.LDAPUserFilter = "(uid={username})"
}
if strings.TrimSpace(saved.OIDCClientID) != "" {
settings.OIDCClientID = saved.OIDCClientID
}
if strings.TrimSpace(saved.OIDCClientSecret) != "" {
settings.OIDCClientSecret = saved.OIDCClientSecret
}
if strings.TrimSpace(saved.OIDCAuthorizeURL) != "" {
settings.OIDCAuthorizeURL = saved.OIDCAuthorizeURL
}
if strings.TrimSpace(saved.OIDCTokenURL) != "" {
settings.OIDCTokenURL = saved.OIDCTokenURL
}
if strings.TrimSpace(saved.OIDCUserInfoURL) != "" {
settings.OIDCUserInfoURL = saved.OIDCUserInfoURL
}
if strings.TrimSpace(saved.OIDCRedirectURL) != "" {
settings.OIDCRedirectURL = saved.OIDCRedirectURL
}
if strings.TrimSpace(saved.OIDCScopes) != "" {
settings.OIDCScopes = saved.OIDCScopes
}
settings.OIDCTLSInsecureSkipVerify = saved.OIDCTLSInsecureSkipVerify
if strings.TrimSpace(settings.OIDCScopes) == "" {
settings.OIDCScopes = "openid profile email"
}
return settings, nil
}
func (api *API) effectiveAuthConfig(ctx context.Context) (config.Config, error) {
var cfg config.Config
var settings models.AuthSettings
var err error
cfg = api.Cfg
settings, err = api.getMergedAuthSettings(ctx)
if err != nil {
return cfg, err
}
cfg.AuthMode = settings.AuthMode
cfg.LDAPURL = settings.LDAPURL
cfg.LDAPBindDN = settings.LDAPBindDN
cfg.LDAPBindPassword = settings.LDAPBindPassword
cfg.LDAPUserBaseDN = settings.LDAPUserBaseDN
cfg.LDAPUserFilter = settings.LDAPUserFilter
cfg.LDAPTLSInsecureSkipVerify = settings.LDAPTLSInsecureSkipVerify
cfg.OIDCClientID = settings.OIDCClientID
cfg.OIDCClientSecret = settings.OIDCClientSecret
cfg.OIDCAuthorizeURL = settings.OIDCAuthorizeURL
cfg.OIDCTokenURL = settings.OIDCTokenURL
cfg.OIDCUserInfoURL = settings.OIDCUserInfoURL
cfg.OIDCRedirectURL = settings.OIDCRedirectURL
cfg.OIDCScopes = settings.OIDCScopes
cfg.OIDCEnabled = settings.OIDCEnabled
cfg.OIDCTLSInsecureSkipVerify = settings.OIDCTLSInsecureSkipVerify
cfg.LDAPDefaultUserGroupID = settings.LDAPDefaultUserGroupID
cfg.OIDCDefaultUserGroupID = settings.OIDCDefaultUserGroupID
return cfg, nil
}
func (api *API) getMergedTLSSettings(ctx context.Context) (models.TLSSettings, error) { func (api *API) getMergedTLSSettings(ctx context.Context) (models.TLSSettings, error) {
var settings models.TLSSettings var settings models.TLSSettings
+207
View File
@@ -0,0 +1,207 @@
package handlers
import "net/http"
import "strings"
import "codit/internal/db"
import "codit/internal/auth"
import "codit/config"
import "codit/internal/models"
func (api *API) ListAuthProviders(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var providers []models.AuthProvider
var err error
if !api.requireAdmin(w, r) {
return
}
providers, err = api.store(r).ListAuthProviders()
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
WriteJSON(w, http.StatusOK, providers)
}
func (api *API) GetAuthProvider(w http.ResponseWriter, r *http.Request, params map[string]string) {
var provider models.AuthProvider
var err error
if !api.requireAdmin(w, r) {
return
}
provider, err = api.store(r).GetAuthProvider(params["id"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "auth provider not found")
return
}
WriteJSON(w, http.StatusOK, provider)
}
func (api *API) CreateAuthProvider(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var req models.AuthProvider
var created models.AuthProvider
var err error
if !api.requireAdmin(w, r) {
return
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json")
return
}
if strings.TrimSpace(req.Name) == "" {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "name is required")
return
}
if req.Type != db.AuthProviderTypeLDAP && req.Type != db.AuthProviderTypeOIDC {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "type must be ldap or oidc")
return
}
created, err = api.store(r).CreateAuthProvider(req)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
WriteJSON(w, http.StatusCreated, created)
}
func (api *API) UpdateAuthProvider(w http.ResponseWriter, r *http.Request, params map[string]string) {
var req models.AuthProvider
var updated models.AuthProvider
var existing models.AuthProvider
var err error
if !api.requireAdmin(w, r) {
return
}
existing, err = api.store(r).GetAuthProvider(params["id"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "auth provider not found")
return
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json")
return
}
req.ID = existing.ID
req.Type = existing.Type
if existing.ID == db.BuiltinDBProviderPublicID {
existing.Enabled = req.Enabled
if strings.TrimSpace(req.Name) != "" {
existing.Name = strings.TrimSpace(req.Name)
}
updated, err = api.store(r).UpdateAuthProvider(existing)
} else {
if strings.TrimSpace(req.Name) == "" {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "name is required")
return
}
updated, err = api.store(r).UpdateAuthProvider(req)
}
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
WriteJSON(w, http.StatusOK, updated)
}
func (api *API) DeleteAuthProvider(w http.ResponseWriter, r *http.Request, params map[string]string) {
var err error
var force bool
var count int
if !api.requireAdmin(w, r) {
return
}
if params["id"] == db.BuiltinDBProviderPublicID {
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "builtin provider cannot be deleted")
return
}
force = strings.EqualFold(r.URL.Query().Get("force"), "true")
if !force {
count, err = api.store(r).CountAuthProviderUsers(params["id"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if count > 0 {
WriteJSON(w, http.StatusConflict, map[string]any{
"error": "provider has associated users",
"user_count": count,
})
return
}
}
err = api.store(r).DeleteAuthProvider(params["id"], force)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (api *API) TestAuthProvider(w http.ResponseWriter, r *http.Request, params map[string]string) {
var provider models.AuthProvider
var req testLDAPSettingsRequest
var cfg config.Config
var ldapUser auth.LDAPUser
var err error
if !api.requireAdmin(w, r) {
return
}
provider, err = api.store(r).GetAuthProvider(params["id"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "auth provider not found")
return
}
if provider.Type != db.AuthProviderTypeLDAP {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "test is only supported for ldap providers")
return
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json")
return
}
cfg = api.Cfg
cfg.LDAPURL = provider.LDAPUrl
cfg.LDAPBindDN = provider.LDAPBindDN
cfg.LDAPBindPassword = provider.LDAPBindPassword
cfg.LDAPUserBaseDN = provider.LDAPUserBaseDN
cfg.LDAPUserFilter = provider.LDAPUserFilter
if cfg.LDAPUserFilter == "" {
cfg.LDAPUserFilter = "(uid={username})"
}
cfg.LDAPTLSInsecureSkipVerify = provider.LDAPTLSInsecureSkipVerify
if strings.TrimSpace(req.LDAPURL) != "" {
cfg.LDAPURL = strings.TrimSpace(req.LDAPURL)
}
if strings.TrimSpace(req.LDAPBindDN) != "" {
cfg.LDAPBindDN = strings.TrimSpace(req.LDAPBindDN)
}
if req.LDAPBindPassword != "" {
cfg.LDAPBindPassword = req.LDAPBindPassword
}
if strings.TrimSpace(req.LDAPUserBaseDN) != "" {
cfg.LDAPUserBaseDN = strings.TrimSpace(req.LDAPUserBaseDN)
}
if strings.TrimSpace(req.LDAPUserFilter) != "" {
cfg.LDAPUserFilter = strings.TrimSpace(req.LDAPUserFilter)
}
if req.LDAPTLSInsecureSkipVerify != nil {
cfg.LDAPTLSInsecureSkipVerify = *req.LDAPTLSInsecureSkipVerify
}
err = auth.LDAPTestConnectionContext(r.Context(), cfg)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
if strings.TrimSpace(req.Username) != "" && strings.TrimSpace(req.Password) != "" {
ldapUser, err = auth.LDAPAuthenticateContext(r.Context(), cfg, strings.TrimSpace(req.Username), req.Password)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
WriteJSON(w, http.StatusOK, map[string]string{"status": "ok", "user": ldapUser.Username})
return
}
WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
+109 -118
View File
@@ -3,7 +3,7 @@ package handlers
import codit_logger "codit/logger" import codit_logger "codit/logger"
import "context" import "context"
import "crypto/rand" import "crypto/rand"
import "crypto/sha1" import "crypto/sha256"
import "crypto/tls" import "crypto/tls"
import "database/sql" import "database/sql"
import "encoding/base64" import "encoding/base64"
@@ -17,7 +17,7 @@ import "net/url"
import "strings" import "strings"
import "time" import "time"
import "codit/config" import "codit/internal/db"
import "codit/internal/models" import "codit/internal/models"
type oidcTokenResponse struct { type oidcTokenResponse struct {
@@ -37,44 +37,46 @@ type oidcErrorResponse struct {
ErrorDescription string `json:"error_description"` ErrorDescription string `json:"error_description"`
} }
func (api *API) OIDCEnabled(w http.ResponseWriter, r *http.Request, _ map[string]string) { func (api *API) ListPublicAuthProviders(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var settings models.AuthSettings var providers []models.AuthProvider
var err error var err error
var configured bool var result []map[string]string
settings, err = api.getMergedAuthSettings(r.Context()) var p models.AuthProvider
providers, err = api.store(r).ListEnabledAuthProviders()
if err != nil { if err != nil {
WriteJSON(w, http.StatusOK, map[string]any{"enabled": false, "configured": false}) WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return return
} }
configured = api.oidcConfiguredFromSettings(settings) result = make([]map[string]string, 0, len(providers))
WriteJSON(w, http.StatusOK, map[string]any{ for _, p = range providers {
"enabled": settings.OIDCEnabled, if p.Type == db.AuthProviderTypeDB {
"configured": configured, continue
}
result = append(result, map[string]string{
"id": p.ID,
"name": p.Name,
"type": p.Type,
}) })
} }
WriteJSON(w, http.StatusOK, result)
}
func (api *API) OIDCLogin(w http.ResponseWriter, r *http.Request, _ map[string]string) { func (api *API) OIDCLoginWithProvider(w http.ResponseWriter, r *http.Request, params map[string]string) {
var settings models.AuthSettings var provider models.AuthProvider
var cfg config.Config
var state string var state string
var err error var err error
var authURL string var authURL string
settings, err = api.getMergedAuthSettings(r.Context()) provider, err = api.store(r).GetAuthProvider(params["id"])
if err != nil { if err != nil || provider.Type != db.AuthProviderTypeOIDC {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, "failed to load auth settings") WriteJSONWithErrorReason(w, r, http.StatusNotFound, "oidc provider not found")
return return
} }
cfg, err = api.effectiveAuthConfig(r.Context()) if !provider.Enabled {
if err != nil { WriteJSONWithErrorReason(w, r, http.StatusServiceUnavailable, "oidc provider is disabled")
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, "failed to load auth settings")
return return
} }
if !settings.OIDCEnabled { if !oidcProviderConfigured(provider) {
WriteJSONWithErrorReason(w, r, http.StatusServiceUnavailable, "oidc login is disabled") WriteJSONWithErrorReason(w, r, http.StatusServiceUnavailable, "oidc provider is not configured")
return
}
if !api.oidcConfiguredFromSettings(settings) {
WriteJSONWithErrorReason(w, r, http.StatusServiceUnavailable, "oidc is not configured")
return return
} }
state, err = newOIDCState() state, err = newOIDCState()
@@ -83,21 +85,20 @@ func (api *API) OIDCLogin(w http.ResponseWriter, r *http.Request, _ map[string]s
return return
} }
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: api.oidcStateCookieName(), Name: oidcStateCookieNameForProvider(api, provider.ID),
Value: state, Value: state,
HttpOnly: true, HttpOnly: true,
Path: "/api/auth/oidc", Path: "/api/auth/oidc",
Expires: time.Now().UTC().Add(10 * time.Minute), Expires: time.Now().UTC().Add(10 * time.Minute),
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
}) })
authURL = api.buildOIDCAuthorizeURL(cfg, state) authURL = buildOIDCAuthorizeURLFromProvider(provider, state)
api.Logger.Write(logIDAuth, codit_logger.LOG_INFO, "oidc login redirect authorize_url=%s", cfg.OIDCAuthorizeURL) api.Logger.Write(logIDAuth, codit_logger.LOG_INFO, "oidc login redirect provider=%s authorize_url=%s", provider.Name, provider.OIDCAuthorizeURL)
http.Redirect(w, r, authURL, http.StatusFound) http.Redirect(w, r, authURL, http.StatusFound)
} }
func (api *API) OIDCCallback(w http.ResponseWriter, r *http.Request, _ map[string]string) { func (api *API) OIDCCallbackWithProvider(w http.ResponseWriter, r *http.Request, params map[string]string) {
var settings models.AuthSettings var provider models.AuthProvider
var cfg config.Config
var state string var state string
var code string var code string
var cookie *http.Cookie var cookie *http.Cookie
@@ -107,28 +108,23 @@ func (api *API) OIDCCallback(w http.ResponseWriter, r *http.Request, _ map[strin
var user models.User var user models.User
var required bool var required bool
var sessionVerified bool var sessionVerified bool
settings, err = api.getMergedAuthSettings(r.Context()) provider, err = api.store(r).GetAuthProvider(params["id"])
if err != nil { if err != nil || provider.Type != db.AuthProviderTypeOIDC {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, "failed to load auth settings") WriteJSONWithErrorReason(w, r, http.StatusNotFound, "oidc provider not found")
return return
} }
cfg, err = api.effectiveAuthConfig(r.Context()) if !provider.Enabled {
if err != nil { WriteJSONWithErrorReason(w, r, http.StatusServiceUnavailable, "oidc provider is disabled")
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, "failed to load auth settings")
return return
} }
if !settings.OIDCEnabled { if !oidcProviderConfigured(provider) {
WriteJSONWithErrorReason(w, r, http.StatusServiceUnavailable, "oidc login is disabled") WriteJSONWithErrorReason(w, r, http.StatusServiceUnavailable, "oidc provider is not configured")
return
}
if !api.oidcConfiguredFromSettings(settings) {
WriteJSONWithErrorReason(w, r, http.StatusServiceUnavailable, "oidc is not configured")
return return
} }
state = strings.TrimSpace(r.URL.Query().Get("state")) state = strings.TrimSpace(r.URL.Query().Get("state"))
code = strings.TrimSpace(r.URL.Query().Get("code")) code = strings.TrimSpace(r.URL.Query().Get("code"))
cookie, err = r.Cookie(api.oidcStateCookieName()) cookie, err = r.Cookie(oidcStateCookieNameForProvider(api, provider.ID))
api.clearOIDCStateCookie(w) clearOIDCStateCookieForProvider(w, api, provider.ID)
if err != nil || cookie.Value == "" || state == "" || state != cookie.Value { if err != nil || cookie.Value == "" || state == "" || state != cookie.Value {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid oidc state") WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid oidc state")
return return
@@ -137,21 +133,21 @@ func (api *API) OIDCCallback(w http.ResponseWriter, r *http.Request, _ map[strin
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "missing authorization code") WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "missing authorization code")
return return
} }
token, err = api.oidcExchangeCode(r.Context(), cfg, code) token, err = api.oidcExchangeCodeFromProvider(r.Context(), provider, code)
if err != nil { if err != nil {
api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "oidc token exchange failed err=%v", err) api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "oidc token exchange failed provider=%s err=%v", provider.Name, err)
WriteJSONWithErrorReason(w, r, http.StatusUnauthorized, err.Error()) WriteJSONWithErrorReason(w, r, http.StatusUnauthorized, err.Error())
return return
} }
claims, err = api.oidcResolveClaims(r.Context(), cfg, token) claims, err = api.oidcResolveClaimsFromProvider(r.Context(), provider, token)
if err != nil { if err != nil {
api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "oidc claims fetch failed err=%v", err) api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "oidc claims fetch failed provider=%s err=%v", provider.Name, err)
WriteJSONWithErrorReason(w, r, http.StatusUnauthorized, "oidc claims fetch failed") WriteJSONWithErrorReason(w, r, http.StatusUnauthorized, "oidc claims fetch failed")
return return
} }
user, err = api.oidcGetOrCreateUser(r.Context(), claims) user, err = api.oidcGetOrCreateUser(r.Context(), claims, provider)
if err != nil { if err != nil {
api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "oidc user mapping failed err=%v", err) api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "oidc user mapping failed provider=%s err=%v", provider.Name, err)
WriteJSONWithErrorReason(w, r, http.StatusUnauthorized, "oidc user mapping failed") WriteJSONWithErrorReason(w, r, http.StatusUnauthorized, "oidc user mapping failed")
return return
} }
@@ -169,59 +165,74 @@ func (api *API) OIDCCallback(w http.ResponseWriter, r *http.Request, _ map[strin
user.TOTPSetupRequired = required && !user.TOTPEnabled user.TOTPSetupRequired = required && !user.TOTPEnabled
sessionVerified = !required sessionVerified = !required
api.issueSession(w, r, user, sessionVerified) api.issueSession(w, r, user, sessionVerified)
api.Logger.Write(logIDAuth, codit_logger.LOG_INFO, "oidc login success username=%s", user.Username) api.Logger.Write(logIDAuth, codit_logger.LOG_INFO, "oidc login success username=%s provider=%s", user.Username, provider.Name)
http.Redirect(w, r, "/", http.StatusFound) http.Redirect(w, r, "/", http.StatusFound)
} }
func (api *API) oidcConfiguredFromSettings(settings models.AuthSettings) bool { func oidcProviderConfigured(p models.AuthProvider) bool {
if strings.TrimSpace(settings.OIDCClientID) == "" { if strings.TrimSpace(p.OIDCClientID) == "" {
return false return false
} }
if strings.TrimSpace(settings.OIDCClientSecret) == "" { if strings.TrimSpace(p.OIDCClientSecret) == "" {
return false return false
} }
if strings.TrimSpace(settings.OIDCAuthorizeURL) == "" { if strings.TrimSpace(p.OIDCAuthorizeURL) == "" {
return false return false
} }
if strings.TrimSpace(settings.OIDCTokenURL) == "" { if strings.TrimSpace(p.OIDCTokenURL) == "" {
return false return false
} }
if strings.TrimSpace(settings.OIDCRedirectURL) == "" { if strings.TrimSpace(p.OIDCRedirectURL) == "" {
return false return false
} }
return true return true
} }
func (api *API) buildOIDCAuthorizeURL(cfg config.Config, state string) string { func oidcStateCookieNameForProvider(api *API, providerID string) string {
return api.oidcStateCookieName() + "_" + providerID
}
func clearOIDCStateCookieForProvider(w http.ResponseWriter, api *API, providerID string) {
http.SetCookie(w, &http.Cookie{
Name: oidcStateCookieNameForProvider(api, providerID),
Value: "",
Path: "/api/auth/oidc",
Expires: time.Unix(0, 0),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
func buildOIDCAuthorizeURLFromProvider(p models.AuthProvider, state string) string {
var values url.Values var values url.Values
var scopes string var scopes string
var endpoint string var endpoint string
values = url.Values{} values = url.Values{}
values.Set("response_type", "code") values.Set("response_type", "code")
values.Set("client_id", cfg.OIDCClientID) values.Set("client_id", p.OIDCClientID)
values.Set("redirect_uri", cfg.OIDCRedirectURL) values.Set("redirect_uri", p.OIDCRedirectURL)
scopes = strings.TrimSpace(cfg.OIDCScopes) scopes = strings.TrimSpace(p.OIDCScopes)
if scopes == "" { if scopes == "" {
scopes = "openid profile email" scopes = "openid profile email"
} }
values.Set("scope", scopes) values.Set("scope", scopes)
values.Set("state", state) values.Set("state", state)
endpoint = cfg.OIDCAuthorizeURL endpoint = p.OIDCAuthorizeURL
if strings.Contains(endpoint, "?") { if strings.Contains(endpoint, "?") {
return endpoint + "&" + values.Encode() return endpoint + "&" + values.Encode()
} }
return endpoint + "?" + values.Encode() return endpoint + "?" + values.Encode()
} }
func (api *API) oidcHTTPClient(cfg config.Config) *http.Client { func oidcHTTPClientFromProvider(p models.AuthProvider) *http.Client {
var transport *http.Transport var transport *http.Transport
transport = &http.Transport{ transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.OIDCTLSInsecureSkipVerify}, TLSClientConfig: &tls.Config{InsecureSkipVerify: p.OIDCTLSInsecureSkipVerify},
} }
return &http.Client{Transport: transport, Timeout: 15 * time.Second} return &http.Client{Transport: transport, Timeout: 15 * time.Second}
} }
func (api *API) oidcExchangeCode(ctx context.Context, cfg config.Config, code string) (oidcTokenResponse, error) { func (api *API) oidcExchangeCodeFromProvider(ctx context.Context, p models.AuthProvider, code string) (oidcTokenResponse, error) {
var client *http.Client var client *http.Client
var form url.Values var form url.Values
var req *http.Request var req *http.Request
@@ -229,14 +240,14 @@ func (api *API) oidcExchangeCode(ctx context.Context, cfg config.Config, code st
var body []byte var body []byte
var token oidcTokenResponse var token oidcTokenResponse
var err error var err error
client = api.oidcHTTPClient(cfg) client = oidcHTTPClientFromProvider(p)
form = url.Values{} form = url.Values{}
form.Set("grant_type", "authorization_code") form.Set("grant_type", "authorization_code")
form.Set("code", code) form.Set("code", code)
form.Set("redirect_uri", cfg.OIDCRedirectURL) form.Set("redirect_uri", p.OIDCRedirectURL)
form.Set("client_id", cfg.OIDCClientID) form.Set("client_id", p.OIDCClientID)
form.Set("client_secret", cfg.OIDCClientSecret) form.Set("client_secret", p.OIDCClientSecret)
req, err = http.NewRequestWithContext(ctx, http.MethodPost, cfg.OIDCTokenURL, strings.NewReader(form.Encode())) req, err = http.NewRequestWithContext(ctx, http.MethodPost, p.OIDCTokenURL, strings.NewReader(form.Encode()))
if err != nil { if err != nil {
return token, err return token, err
} }
@@ -287,11 +298,11 @@ func oidcErrorDetail(body []byte) string {
return text return text
} }
func (api *API) oidcResolveClaims(ctx context.Context, cfg config.Config, token oidcTokenResponse) (oidcUserClaims, error) { func (api *API) oidcResolveClaimsFromProvider(ctx context.Context, p models.AuthProvider, token oidcTokenResponse) (oidcUserClaims, error) {
var claims oidcUserClaims var claims oidcUserClaims
var err error var err error
if strings.TrimSpace(cfg.OIDCUserInfoURL) != "" && strings.TrimSpace(token.AccessToken) != "" { if strings.TrimSpace(p.OIDCUserInfoURL) != "" && strings.TrimSpace(token.AccessToken) != "" {
claims, err = api.oidcUserInfo(ctx, cfg, token.AccessToken) claims, err = api.oidcUserInfoFromProvider(ctx, p, token.AccessToken)
if err == nil { if err == nil {
return claims, nil return claims, nil
} }
@@ -306,15 +317,15 @@ func (api *API) oidcResolveClaims(ctx context.Context, cfg config.Config, token
return claims, nil return claims, nil
} }
func (api *API) oidcUserInfo(ctx context.Context, cfg config.Config, accessToken string) (oidcUserClaims, error) { func (api *API) oidcUserInfoFromProvider(ctx context.Context, p models.AuthProvider, accessToken string) (oidcUserClaims, error) {
var client *http.Client var client *http.Client
var req *http.Request var req *http.Request
var res *http.Response var res *http.Response
var body []byte var body []byte
var claims oidcUserClaims var claims oidcUserClaims
var err error var err error
client = api.oidcHTTPClient(cfg) client = oidcHTTPClientFromProvider(p)
req, err = http.NewRequestWithContext(ctx, http.MethodGet, cfg.OIDCUserInfoURL, nil) req, err = http.NewRequestWithContext(ctx, http.MethodGet, p.OIDCUserInfoURL, nil)
if err != nil { if err != nil {
return claims, err return claims, err
} }
@@ -342,52 +353,42 @@ func (api *API) oidcUserInfo(ctx context.Context, cfg config.Config, accessToken
return claims, nil return claims, nil
} }
func (api *API) oidcGetOrCreateUser(ctx context.Context, claims oidcUserClaims) (models.User, error) { func (api *API) oidcGetOrCreateUser(ctx context.Context, claims oidcUserClaims, provider models.AuthProvider) (models.User, error) {
var username string var username string
var displayName string var displayName string
var email string var email string
var user models.User var user models.User
var hash string
var err error var err error
var created models.User var created models.User
var authCfg models.AuthSettings
username = oidcUsernameFromSub(claims.Sub) user, err = api.storeContext(ctx).GetUserByProviderAndSub(provider.ID, claims.Sub)
user, hash, err = api.storeContext(ctx).GetUserByUsername(username)
_ = hash
if err == nil { if err == nil {
if user.Disabled { if user.Disabled {
return user, errors.New("user disabled") return user, errors.New("user disabled")
} }
return user, nil return user, nil
} }
if !errors.Is(err, sql.ErrNoRows) { if !errors.Is(err, sql.ErrNoRows) { return user, err }
return user, err
} username = oidcUsernameFromProviderAndSub(provider.ID, claims.Sub)
displayName = strings.TrimSpace(claims.Name) displayName = strings.TrimSpace(claims.Name)
if displayName == "" { if displayName == "" { displayName = strings.TrimSpace(claims.PreferredUsername) }
displayName = strings.TrimSpace(claims.PreferredUsername) if displayName == "" { displayName = username }
}
if displayName == "" {
displayName = username
}
email = strings.TrimSpace(claims.Email) email = strings.TrimSpace(claims.Email)
if email == "" { if email == "" { email = username + "@oidc.local" }
email = username + "@oidc.local"
}
user = models.User{ user = models.User{
Username: username, Username: username,
DisplayName: displayName, DisplayName: displayName,
Email: email, Email: email,
AuthSource: "oidc", AuthProviderID: provider.ID,
ExternalSubject: strings.TrimSpace(claims.Sub),
} }
created, err = api.storeContext(ctx).CreateUser(user, "") created, err = api.storeContext(ctx).CreateUser(user, "")
if err != nil { if err != nil { return created, err }
return created, err
} if provider.DefaultUserGroupID != "" {
authCfg, err = api.storeContext(ctx).GetAuthSettings() _ = api.storeContext(ctx).AddUserGroupMember(provider.DefaultUserGroupID, created.ID)
if err == nil && authCfg.OIDCDefaultUserGroupID != "" {
_ = api.storeContext(ctx).AddUserGroupMember(authCfg.OIDCDefaultUserGroupID, created.ID)
} }
return created, nil return created, nil
} }
@@ -415,10 +416,11 @@ func oidcClaimsFromIDToken(idToken string) (oidcUserClaims, error) {
return claims, nil return claims, nil
} }
func oidcUsernameFromSub(sub string) string { // Username is a display artifact only; OIDC identity is looked up by (auth_provider_id, external_subject).
var sum [20]byte func oidcUsernameFromProviderAndSub(providerID string, sub string) string {
sum = sha1.Sum([]byte(strings.TrimSpace(sub))) var sum [32]byte
return "oidc-" + hex.EncodeToString(sum[:6]) sum = sha256.Sum256([]byte(providerID + ":" + strings.TrimSpace(sub)))
return "oidc-" + hex.EncodeToString(sum[:8])
} }
func newOIDCState() (string, error) { func newOIDCState() (string, error) {
@@ -431,14 +433,3 @@ func newOIDCState() (string, error) {
} }
return hex.EncodeToString(buf), nil return hex.EncodeToString(buf), nil
} }
func (api *API) clearOIDCStateCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: api.oidcStateCookieName(),
Value: "",
Path: "/api/auth/oidc",
Expires: time.Unix(0, 0),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
+11
View File
@@ -466,7 +466,9 @@ func (api *API) CreateSSHServerGroupAdmin(w http.ResponseWriter, r *http.Request
var ok bool var ok bool
var item models.SSHServerGroup var item models.SSHServerGroup
var err error var err error
if !api.requireAdmin(w, r) { return } if !api.requireAdmin(w, r) { return }
err = DecodeJSON(r, &req) err = DecodeJSON(r, &req)
if err != nil { if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json") WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json")
@@ -477,6 +479,7 @@ func (api *API) CreateSSHServerGroupAdmin(w http.ResponseWriter, r *http.Request
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
return return
} }
item = models.SSHServerGroup{Name: strings.TrimSpace(req.Name), Description: strings.TrimSpace(req.Description), Enabled: req.Enabled, Tags: req.Tags, CreatedByKind: "user", CreatedBySubjectID: user.ID, CreatedBySubjectName: user.Username} item = models.SSHServerGroup{Name: strings.TrimSpace(req.Name), Description: strings.TrimSpace(req.Description), Enabled: req.Enabled, Tags: req.Tags, CreatedByKind: "user", CreatedBySubjectID: user.ID, CreatedBySubjectName: user.Username}
item, err = api.store(r).CreateSSHServerGroup(item) item, err = api.store(r).CreateSSHServerGroup(item)
if err != nil { if err != nil {
@@ -1783,12 +1786,20 @@ func (api *API) CreateSSHSessionForSelf(w http.ResponseWriter, r *http.Request,
} }
middleware.RegisterAfterCommit(r.Context(), func() { middleware.RegisterAfterCommit(r.Context(), func() {
if api.SSHPromptedAuthStore != nil { if api.SSHPromptedAuthStore != nil {
// arrange to store auth info after successful commit.
// the info is to be used by runSSHSessionStream().
// it is to preserve the password and otp code passsed
// by the api POST /api/ssh/access-profiles/:id/connect.
// the function that establishes the ssh connection takes
// the info from the in-memory auth store.
api.SSHPromptedAuthStore.Put(item.ID, SSHPromptedAuthInput{ api.SSHPromptedAuthStore.Put(item.ID, SSHPromptedAuthInput{
Password: req.Password, Password: req.Password,
OTPCode: req.OTPCode, OTPCode: req.OTPCode,
}) })
} }
if api.SSHPreparedSessionStore != nil { if api.SSHPreparedSessionStore != nil {
// arrange to store session info after successful commit.
// the info is to be used by runSSHSessionStream().
api.SSHPreparedSessionStore.Put(item.ID, prepared) api.SSHPreparedSessionStore.Put(item.ID, prepared)
} }
}) })
@@ -19,7 +19,7 @@ type meResponse struct {
Email string `json:"email"` Email string `json:"email"`
IsAdmin bool `json:"is_admin"` IsAdmin bool `json:"is_admin"`
Disabled bool `json:"disabled"` Disabled bool `json:"disabled"`
AuthSource string `json:"auth_source"` AuthProviderID string `json:"auth_provider_id"`
TOTPEnabled bool `json:"totp_enabled"` TOTPEnabled bool `json:"totp_enabled"`
TOTPRequired bool `json:"totp_required"` TOTPRequired bool `json:"totp_required"`
TOTPEffectiveRequired bool `json:"totp_effective_required"` TOTPEffectiveRequired bool `json:"totp_effective_required"`
@@ -179,7 +179,7 @@ func buildMeResponse(user models.User, permissions []string) meResponse {
Email: user.Email, Email: user.Email,
IsAdmin: user.IsAdmin, IsAdmin: user.IsAdmin,
Disabled: user.Disabled, Disabled: user.Disabled,
AuthSource: user.AuthSource, AuthProviderID: user.AuthProviderID,
TOTPEnabled: user.TOTPEnabled, TOTPEnabled: user.TOTPEnabled,
TOTPRequired: user.TOTPRequired, TOTPRequired: user.TOTPRequired,
TOTPEffectiveRequired: user.TOTPEffectiveRequired, TOTPEffectiveRequired: user.TOTPEffectiveRequired,
+27 -22
View File
@@ -7,7 +7,8 @@ type User struct {
Email string `json:"email"` Email string `json:"email"`
IsAdmin bool `json:"is_admin"` IsAdmin bool `json:"is_admin"`
Disabled bool `json:"disabled"` Disabled bool `json:"disabled"`
AuthSource string `json:"auth_source"` AuthProviderID string `json:"auth_provider_id"`
ExternalSubject string `json:"-"`
TOTPEnabled bool `json:"totp_enabled"` TOTPEnabled bool `json:"totp_enabled"`
TOTPRequired bool `json:"totp_required"` TOTPRequired bool `json:"totp_required"`
TOTPEffectiveRequired bool `json:"totp_effective_required"` TOTPEffectiveRequired bool `json:"totp_effective_required"`
@@ -16,6 +17,31 @@ type User struct {
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
} }
type AuthProvider struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
LDAPUrl string `json:"ldap_url"`
LDAPBindDN string `json:"ldap_bind_dn"`
LDAPBindPassword string `json:"ldap_bind_password"`
LDAPUserBaseDN string `json:"ldap_user_base_dn"`
LDAPUserFilter string `json:"ldap_user_filter"`
LDAPTLSInsecureSkipVerify bool `json:"ldap_tls_insecure_skip_verify"`
OIDCClientID string `json:"oidc_client_id"`
OIDCClientSecret string `json:"oidc_client_secret"`
OIDCAuthorizeURL string `json:"oidc_authorize_url"`
OIDCTokenURL string `json:"oidc_token_url"`
OIDCUserInfoURL string `json:"oidc_userinfo_url"`
OIDCRedirectURL string `json:"oidc_redirect_url"`
OIDCScopes string `json:"oidc_scopes"`
OIDCTLSInsecureSkipVerify bool `json:"oidc_tls_insecure_skip_verify"`
DefaultUserGroupID string `json:"default_user_group_id"`
UserCount int `json:"user_count,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type Project struct { type Project struct {
ID string `json:"id"` ID string `json:"id"`
Slug string `json:"slug"` Slug string `json:"slug"`
@@ -247,27 +273,6 @@ type PrincipalAPIKey struct {
Disabled bool `json:"disabled"` Disabled bool `json:"disabled"`
} }
type AuthSettings struct {
AuthMode string `json:"auth_mode"`
OIDCEnabled bool `json:"oidc_enabled"`
LDAPURL string `json:"ldap_url"`
LDAPBindDN string `json:"ldap_bind_dn"`
LDAPBindPassword string `json:"ldap_bind_password"`
LDAPUserBaseDN string `json:"ldap_user_base_dn"`
LDAPUserFilter string `json:"ldap_user_filter"`
LDAPTLSInsecureSkipVerify bool `json:"ldap_tls_insecure_skip_verify"`
OIDCClientID string `json:"oidc_client_id"`
OIDCClientSecret string `json:"oidc_client_secret"`
OIDCAuthorizeURL string `json:"oidc_authorize_url"`
OIDCTokenURL string `json:"oidc_token_url"`
OIDCUserInfoURL string `json:"oidc_userinfo_url"`
OIDCRedirectURL string `json:"oidc_redirect_url"`
OIDCScopes string `json:"oidc_scopes"`
OIDCTLSInsecureSkipVerify bool `json:"oidc_tls_insecure_skip_verify"`
LDAPDefaultUserGroupID string `json:"ldap_default_user_group_id"`
OIDCDefaultUserGroupID string `json:"oidc_default_user_group_id"`
}
type TLSSettings struct { type TLSSettings struct {
HTTPAddrs []string `json:"http_addrs"` HTTPAddrs []string `json:"http_addrs"`
HTTPSAddrs []string `json:"https_addrs"` HTTPSAddrs []string `json:"https_addrs"`
+86
View File
@@ -0,0 +1,86 @@
-- Fix 1: tls_listeners - change TEXT PKI ID columns to INTEGER FKs
ALTER TABLE tls_listeners ADD COLUMN tls_pki_server_cert_id_new INTEGER REFERENCES pki_certs(id) ON DELETE SET NULL;
UPDATE tls_listeners SET tls_pki_server_cert_id_new = (SELECT id FROM pki_certs WHERE public_id = tls_pki_server_cert_id) WHERE tls_pki_server_cert_id != '';
ALTER TABLE tls_listeners DROP COLUMN tls_pki_server_cert_id;
ALTER TABLE tls_listeners RENAME COLUMN tls_pki_server_cert_id_new TO tls_pki_server_cert_id;
ALTER TABLE tls_listeners ADD COLUMN tls_pki_client_ca_id_new INTEGER REFERENCES pki_cas(id) ON DELETE SET NULL;
UPDATE tls_listeners SET tls_pki_client_ca_id_new = (SELECT id FROM pki_cas WHERE public_id = tls_pki_client_ca_id) WHERE tls_pki_client_ca_id != '';
ALTER TABLE tls_listeners DROP COLUMN tls_pki_client_ca_id;
ALTER TABLE tls_listeners RENAME COLUMN tls_pki_client_ca_id_new TO tls_pki_client_ca_id;
-- Fix 2: tls_auth_policies.allowed_pki_client_cert_ids - migrate CSV TEXT to a proper junction table
CREATE TABLE tls_auth_policy_allowed_pki_certs (
policy_id INTEGER NOT NULL,
cert_id INTEGER NOT NULL,
PRIMARY KEY (policy_id, cert_id),
FOREIGN KEY (policy_id) REFERENCES tls_auth_policies(id) ON DELETE CASCADE,
FOREIGN KEY (cert_id) REFERENCES pki_certs(id) ON DELETE CASCADE
);
WITH RECURSIVE
policy_csvs(policy_id, csv) AS (
SELECT id, allowed_pki_client_cert_ids || ','
FROM tls_auth_policies
WHERE allowed_pki_client_cert_ids != ''
),
tokens(policy_id, token, rest) AS (
SELECT policy_id,
substr(csv, 1, instr(csv, ',') - 1),
substr(csv, instr(csv, ',') + 1)
FROM policy_csvs
UNION ALL
SELECT policy_id,
substr(rest, 1, instr(rest, ',') - 1),
substr(rest, instr(rest, ',') + 1)
FROM tokens
WHERE rest != ''
)
INSERT OR IGNORE INTO tls_auth_policy_allowed_pki_certs (policy_id, cert_id)
SELECT t.policy_id, pc.id
FROM tokens t
JOIN pki_certs pc ON pc.public_id = t.token
WHERE t.token != '';
ALTER TABLE tls_auth_policies DROP COLUMN allowed_pki_client_cert_ids;
-- Fix 3: created_by_subject_id TEXT -> created_by_user_id + created_by_principal_id INTEGER FKs
-- Pattern: add two nullable FK columns, populate from existing TEXT value, drop old TEXT column.
ALTER TABLE ssh_servers ADD COLUMN created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE ssh_servers ADD COLUMN created_by_principal_id INTEGER REFERENCES service_principals(id) ON DELETE SET NULL;
UPDATE ssh_servers SET created_by_user_id = (SELECT id FROM users WHERE public_id = created_by_subject_id) WHERE created_by_kind = 'user';
UPDATE ssh_servers SET created_by_principal_id = (SELECT id FROM service_principals WHERE public_id = created_by_subject_id) WHERE created_by_kind = 'service_principal';
ALTER TABLE ssh_servers DROP COLUMN created_by_subject_id;
ALTER TABLE ssh_server_groups ADD COLUMN created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE ssh_server_groups ADD COLUMN created_by_principal_id INTEGER REFERENCES service_principals(id) ON DELETE SET NULL;
UPDATE ssh_server_groups SET created_by_user_id = (SELECT id FROM users WHERE public_id = created_by_subject_id) WHERE created_by_kind = 'user';
UPDATE ssh_server_groups SET created_by_principal_id = (SELECT id FROM service_principals WHERE public_id = created_by_subject_id) WHERE created_by_kind = 'service_principal';
ALTER TABLE ssh_server_groups DROP COLUMN created_by_subject_id;
ALTER TABLE ssh_credentials ADD COLUMN created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE ssh_credentials ADD COLUMN created_by_principal_id INTEGER REFERENCES service_principals(id) ON DELETE SET NULL;
UPDATE ssh_credentials SET created_by_user_id = (SELECT id FROM users WHERE public_id = created_by_subject_id) WHERE created_by_kind = 'user';
UPDATE ssh_credentials SET created_by_principal_id = (SELECT id FROM service_principals WHERE public_id = created_by_subject_id) WHERE created_by_kind = 'service_principal';
ALTER TABLE ssh_credentials DROP COLUMN created_by_subject_id;
ALTER TABLE ssh_secrets ADD COLUMN created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE ssh_secrets ADD COLUMN created_by_principal_id INTEGER REFERENCES service_principals(id) ON DELETE SET NULL;
UPDATE ssh_secrets SET created_by_user_id = (SELECT id FROM users WHERE public_id = created_by_subject_id) WHERE created_by_kind = 'user';
UPDATE ssh_secrets SET created_by_principal_id = (SELECT id FROM service_principals WHERE public_id = created_by_subject_id) WHERE created_by_kind = 'service_principal';
ALTER TABLE ssh_secrets DROP COLUMN created_by_subject_id;
ALTER TABLE ssh_access_profiles ADD COLUMN created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE ssh_access_profiles ADD COLUMN created_by_principal_id INTEGER REFERENCES service_principals(id) ON DELETE SET NULL;
UPDATE ssh_access_profiles SET created_by_user_id = (SELECT id FROM users WHERE public_id = created_by_subject_id) WHERE created_by_kind = 'user';
UPDATE ssh_access_profiles SET created_by_principal_id = (SELECT id FROM service_principals WHERE public_id = created_by_subject_id) WHERE created_by_kind = 'service_principal';
ALTER TABLE ssh_access_profiles DROP COLUMN created_by_subject_id;
ALTER TABLE pki_certs ADD COLUMN created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE pki_certs ADD COLUMN created_by_principal_id INTEGER REFERENCES service_principals(id) ON DELETE SET NULL;
UPDATE pki_certs SET created_by_user_id = (SELECT id FROM users WHERE public_id = created_by_subject_id) WHERE created_by_kind = 'user';
UPDATE pki_certs SET created_by_principal_id = (SELECT id FROM service_principals WHERE public_id = created_by_subject_id) WHERE created_by_kind = 'service_principal';
ALTER TABLE pki_certs DROP COLUMN created_by_subject_id;
+92
View File
@@ -0,0 +1,92 @@
-- Create auth_providers table
CREATE TABLE IF NOT EXISTS auth_providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
ldap_url TEXT NOT NULL DEFAULT '',
ldap_bind_dn TEXT NOT NULL DEFAULT '',
ldap_bind_password TEXT NOT NULL DEFAULT '',
ldap_user_base_dn TEXT NOT NULL DEFAULT '',
ldap_user_filter TEXT NOT NULL DEFAULT '(uid={username})',
ldap_tls_insecure_skip_verify INTEGER NOT NULL DEFAULT 0,
oidc_client_id TEXT NOT NULL DEFAULT '',
oidc_client_secret TEXT NOT NULL DEFAULT '',
oidc_authorize_url TEXT NOT NULL DEFAULT '',
oidc_token_url TEXT NOT NULL DEFAULT '',
oidc_userinfo_url TEXT NOT NULL DEFAULT '',
oidc_redirect_url TEXT NOT NULL DEFAULT '',
oidc_scopes TEXT NOT NULL DEFAULT 'openid profile email',
oidc_tls_insecure_skip_verify INTEGER NOT NULL DEFAULT 0,
default_user_group_id INTEGER REFERENCES user_groups(id) ON DELETE SET NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-- Migrate existing LDAP config from app_settings into auth_providers
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, default_user_group_id, created_at, updated_at
)
SELECT
'ldap-default', 'Default LDAP', 'ldap', 1,
COALESCE((SELECT value FROM app_settings WHERE key = 'auth.ldap.url'), ''),
COALESCE((SELECT value FROM app_settings WHERE key = 'auth.ldap.bind_dn'), ''),
COALESCE((SELECT value FROM app_settings WHERE key = 'auth.ldap.bind_password'), ''),
COALESCE((SELECT value FROM app_settings WHERE key = 'auth.ldap.user_base_dn'), ''),
COALESCE(NULLIF((SELECT value FROM app_settings WHERE key = 'auth.ldap.user_filter'), ''), '(uid={username})'),
COALESCE((SELECT CASE WHEN value = '1' THEN 1 ELSE 0 END FROM app_settings WHERE key = 'auth.ldap.tls_insecure_skip_verify'), 0),
(SELECT ug.id FROM user_groups ug WHERE ug.public_id = (SELECT value FROM app_settings WHERE key = 'auth.ldap.default_user_group_id') LIMIT 1),
strftime('%s', 'now'), strftime('%s', 'now')
WHERE EXISTS (SELECT 1 FROM app_settings WHERE key = 'auth.ldap.url' AND value != '');
-- Migrate existing OIDC config from app_settings into auth_providers
INSERT INTO auth_providers (
public_id, name, type, enabled,
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, default_user_group_id,
created_at, updated_at
)
SELECT
'oidc-default', 'Default OIDC', 'oidc',
COALESCE((SELECT CASE WHEN value = '1' THEN 1 ELSE 0 END FROM app_settings WHERE key = 'auth.oidc.enabled'), 0),
COALESCE((SELECT value FROM app_settings WHERE key = 'auth.oidc.client_id'), ''),
COALESCE((SELECT value FROM app_settings WHERE key = 'auth.oidc.client_secret'), ''),
COALESCE((SELECT value FROM app_settings WHERE key = 'auth.oidc.authorize_url'), ''),
COALESCE((SELECT value FROM app_settings WHERE key = 'auth.oidc.token_url'), ''),
COALESCE((SELECT value FROM app_settings WHERE key = 'auth.oidc.userinfo_url'), ''),
COALESCE((SELECT value FROM app_settings WHERE key = 'auth.oidc.redirect_url'), ''),
COALESCE(NULLIF((SELECT value FROM app_settings WHERE key = 'auth.oidc.scopes'), ''), 'openid profile email'),
COALESCE((SELECT CASE WHEN value = '1' THEN 1 ELSE 0 END FROM app_settings WHERE key = 'auth.oidc.tls_insecure_skip_verify'), 0),
(SELECT ug.id FROM user_groups ug WHERE ug.public_id = (SELECT value FROM app_settings WHERE key = 'auth.oidc.default_user_group_id') LIMIT 1),
strftime('%s', 'now'), strftime('%s', 'now')
WHERE EXISTS (SELECT 1 FROM app_settings WHERE key = 'auth.oidc.client_id' AND value != '');
-- Add auth.db.enabled setting derived from old auth.mode
INSERT INTO app_settings (key, value, updated_at) VALUES ('auth.db.enabled', '1', strftime('%s', 'now'));
UPDATE app_settings SET value = '0' WHERE key = 'auth.db.enabled'
AND EXISTS (SELECT 1 FROM app_settings WHERE key = 'auth.mode' AND value = 'ldap');
-- If mode was 'db', disable any newly created LDAP providers
UPDATE auth_providers SET enabled = 0 WHERE type = 'ldap'
AND EXISTS (SELECT 1 FROM app_settings WHERE key = 'auth.mode' AND value = 'db');
-- Add auth_provider_id to users and populate from auth_source
ALTER TABLE users ADD COLUMN auth_provider_id INTEGER REFERENCES auth_providers(id) ON DELETE SET NULL;
UPDATE users SET auth_provider_id = (SELECT id FROM auth_providers WHERE type = 'ldap' LIMIT 1) WHERE auth_source = 'ldap';
UPDATE users SET auth_provider_id = (SELECT id FROM auth_providers WHERE type = 'oidc' LIMIT 1) WHERE auth_source = 'oidc';
ALTER TABLE users DROP COLUMN auth_source;
-- Remove migrated auth settings from app_settings
DELETE FROM app_settings WHERE key IN (
'auth.mode',
'auth.ldap.url', 'auth.ldap.bind_dn', 'auth.ldap.bind_password',
'auth.ldap.user_base_dn', 'auth.ldap.user_filter', 'auth.ldap.tls_insecure_skip_verify',
'auth.ldap.default_user_group_id',
'auth.oidc.enabled', 'auth.oidc.client_id', 'auth.oidc.client_secret',
'auth.oidc.authorize_url', 'auth.oidc.token_url', 'auth.oidc.userinfo_url',
'auth.oidc.redirect_url', 'auth.oidc.scopes', 'auth.oidc.tls_insecure_skip_verify',
'auth.oidc.default_user_group_id'
);
@@ -0,0 +1,23 @@
-- Insert the builtin DB auth provider at reserved id=0.
-- enabled is seeded from the existing auth.db.enabled setting (default true).
INSERT INTO auth_providers (id, public_id, name, type, enabled, created_at, updated_at)
VALUES (
0, 'db', 'Database', 'db',
COALESCE((SELECT CASE WHEN value = '1' THEN 1 ELSE 0 END FROM app_settings WHERE key = 'auth.db.enabled'), 1),
strftime('%s', 'now'), strftime('%s', 'now')
);
-- Backfill existing DB users: NULL auth_provider_id → 0 (builtin).
UPDATE users SET auth_provider_id = 0 WHERE auth_provider_id IS NULL;
-- Add external_subject column for stable OIDC identity anchoring.
ALTER TABLE users ADD COLUMN external_subject TEXT NOT NULL DEFAULT '';
-- Remove the now-redundant app_settings row.
DELETE FROM app_settings WHERE key = 'auth.db.enabled';
-- Enforce uniqueness of (provider, subject) for OIDC users.
-- The partial condition excludes DB users (external_subject='') and unlinked rows (auth_provider_id IS NULL).
CREATE UNIQUE INDEX users_auth_provider_subject_uq
ON users(auth_provider_id, external_subject)
WHERE auth_provider_id IS NOT NULL AND external_subject != '';
+14 -17
View File
@@ -629,9 +629,9 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
router.Handle("GET", "/api/app-info", api.AppInfo) router.Handle("GET", "/api/app-info", api.AppInfo)
router.Handle("POST", "/api/login", api.Login) router.Handle("POST", "/api/login", api.Login)
router.Handle("POST", "/api/logout", api.Logout) router.Handle("POST", "/api/logout", api.Logout)
router.Handle("GET", "/api/auth/oidc/enabled", api.OIDCEnabled) router.Handle("GET", "/api/auth/providers", api.ListPublicAuthProviders)
router.Handle("GET", "/api/auth/oidc/login", api.OIDCLogin) router.Handle("GET", "/api/auth/oidc/:id/login", api.OIDCLoginWithProvider)
router.Handle("GET", "/api/auth/oidc/callback", api.OIDCCallback) router.Handle("GET", "/api/auth/oidc/:id/callback", api.OIDCCallbackWithProvider)
router.Handle("GET", "/api/me", api.Me) router.Handle("GET", "/api/me", api.Me)
router.Handle("PATCH", "/api/me", api.UpdateMe) router.Handle("PATCH", "/api/me", api.UpdateMe)
router.Handle("GET", "/api/me/totp", api.GetMyTOTP) router.Handle("GET", "/api/me/totp", api.GetMyTOTP)
@@ -657,9 +657,12 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
router.Handle("GET", "/api/admin/permissions", api.ListSubjectPermissions) router.Handle("GET", "/api/admin/permissions", api.ListSubjectPermissions)
router.Handle("POST", "/api/admin/permissions", api.CreateSubjectPermissions) router.Handle("POST", "/api/admin/permissions", api.CreateSubjectPermissions)
router.Handle("DELETE", "/api/admin/permissions/:permission/:subjectType/:subjectID", api.DeleteSubjectPermission) router.Handle("DELETE", "/api/admin/permissions/:permission/:subjectType/:subjectID", api.DeleteSubjectPermission)
router.Handle("GET", "/api/admin/auth", api.GetAuthSettings) router.Handle("GET", "/api/admin/auth-providers", api.ListAuthProviders)
router.Handle("PATCH", "/api/admin/auth", api.UpdateAuthSettings) router.Handle("POST", "/api/admin/auth-providers", api.CreateAuthProvider)
router.Handle("POST", "/api/admin/auth/test", api.TestLDAPSettings) router.Handle("GET", "/api/admin/auth-providers/:id", api.GetAuthProvider)
router.Handle("PUT", "/api/admin/auth-providers/:id", api.UpdateAuthProvider)
router.Handle("DELETE", "/api/admin/auth-providers/:id", api.DeleteAuthProvider)
router.Handle("POST", "/api/admin/auth-providers/:id/test", api.TestAuthProvider)
router.Handle("GET", "/api/admin/tls", api.GetTLSSettings) router.Handle("GET", "/api/admin/tls", api.GetTLSSettings)
router.Handle("PATCH", "/api/admin/tls", api.UpdateTLSSettings) router.Handle("PATCH", "/api/admin/tls", api.UpdateTLSSettings)
router.Handle("GET", "/api/admin/tls/auth-policies", api.ListTLSAuthPolicies) router.Handle("GET", "/api/admin/tls/auth-policies", api.ListTLSAuthPolicies)
@@ -686,9 +689,7 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
router.Handle("GET", "/api/admin/cert-principal-bindings", api.ListCertPrincipalBindings) router.Handle("GET", "/api/admin/cert-principal-bindings", api.ListCertPrincipalBindings)
router.Handle("POST", "/api/admin/cert-principal-bindings", api.UpsertCertPrincipalBinding) router.Handle("POST", "/api/admin/cert-principal-bindings", api.UpsertCertPrincipalBinding)
router.Handle("DELETE", "/api/admin/cert-principal-bindings/:fingerprint", api.DeleteCertPrincipalBinding) router.Handle("DELETE", "/api/admin/cert-principal-bindings/:fingerprint", api.DeleteCertPrincipalBinding)
router.Handle("GET", "/api/admin/auth/ldap", api.GetAuthSettings)
router.Handle("PATCH", "/api/admin/auth/ldap", api.UpdateAuthSettings)
router.Handle("POST", "/api/admin/auth/ldap/test", api.TestLDAPSettings)
router.Handle("GET", "/api/admin/pki/cas", api.ListPKICAs) router.Handle("GET", "/api/admin/pki/cas", api.ListPKICAs)
router.Handle("GET", "/api/admin/pki/cas/:id", api.GetPKICA) router.Handle("GET", "/api/admin/pki/cas/:id", api.GetPKICA)
router.Handle("PATCH", "/api/admin/pki/cas/:id", api.UpdatePKICA) router.Handle("PATCH", "/api/admin/pki/cas/:id", api.UpdatePKICA)
@@ -967,10 +968,7 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
mux.Handle("/api/", apiHandler) mux.Handle("/api/", apiHandler)
mux.Handle("/api/health", middleware.AccessLog(logger, router)) mux.Handle("/api/health", middleware.AccessLog(logger, router))
// apiPublicHandler = middleware.WithRequestStore(store, // apiPublicHandler bypasses the authentication layer. WithAPIAuth is not called.
// middleware.AccessLog(logger,
// middleware.WithStoreTransaction(store,
// middleware.WithUser(store, router))))
apiPublicHandler = middleware.WithUserCookie(store, sessionCookieNameFromServerId(app.serverId), router) apiPublicHandler = middleware.WithUserCookie(store, sessionCookieNameFromServerId(app.serverId), router)
apiPublicHandler = middleware.WithStoreTransaction(store, apiPublicHandler) apiPublicHandler = middleware.WithStoreTransaction(store, apiPublicHandler)
apiPublicHandler = middleware.AccessLog(logger, apiPublicHandler) apiPublicHandler = middleware.AccessLog(logger, apiPublicHandler)
@@ -978,9 +976,8 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
apiPublicHandler = middleware.WithCors(apiPublicHandler) apiPublicHandler = middleware.WithCors(apiPublicHandler)
mux.Handle("/api/login", apiPublicHandler) mux.Handle("/api/login", apiPublicHandler)
mux.Handle("/api/logout", apiPublicHandler) mux.Handle("/api/logout", apiPublicHandler)
mux.Handle("/api/auth/oidc/enabled", apiPublicHandler) mux.Handle("/api/auth/providers", apiPublicHandler)
mux.Handle("/api/auth/oidc/login", apiPublicHandler) mux.Handle("/api/auth/oidc/", apiPublicHandler) // for /api/auth/oidc/:id/login and /api/auth/oidc/:id/callback without authentication
mux.Handle("/api/auth/oidc/callback", apiPublicHandler)
mux.Handle("/api/app-info", apiPublicHandler) mux.Handle("/api/app-info", apiPublicHandler)
mux.Handle("/", middleware.WithUserCookie(store, sessionCookieNameFromServerId(app.serverId), spaHandler(cfg.FrontendDir, logger, app.serverId, TitleFromServerId(app.serverId), app.siteName))) mux.Handle("/", middleware.WithUserCookie(store, sessionCookieNameFromServerId(app.serverId), spaHandler(cfg.FrontendDir, logger, app.serverId, TitleFromServerId(app.serverId), app.siteName)))
@@ -2461,7 +2458,7 @@ func bootstrapAdmin(store *db.Store, envPrefix string) error {
DisplayName: username, DisplayName: username,
Email: username + "@local", Email: username + "@local",
IsAdmin: true, IsAdmin: true,
AuthSource: "db", AuthProviderID: db.BuiltinDBProviderPublicID,
}, hash) }, hash)
if err != nil { if err != nil {
_ = txStore.Rollback() _ = txStore.Rollback()
+27 -19
View File
@@ -4,7 +4,7 @@ export interface User {
display_name: string display_name: string
email: string email: string
is_admin: boolean is_admin: boolean
auth_source?: string auth_provider_id?: string
totp_enabled?: boolean totp_enabled?: boolean
totp_required?: boolean totp_required?: boolean
totp_effective_required?: boolean totp_effective_required?: boolean
@@ -434,9 +434,11 @@ export interface PrincipalProjectRole {
created_at: number created_at: number
} }
export interface AuthSettings { export interface AuthProvider {
auth_mode: 'db' | 'ldap' | 'hybrid' id: string
oidc_enabled: boolean name: string
type: 'ldap' | 'oidc' | 'db'
enabled: boolean
ldap_url: string ldap_url: string
ldap_bind_dn: string ldap_bind_dn: string
ldap_bind_password: string ldap_bind_password: string
@@ -451,8 +453,16 @@ export interface AuthSettings {
oidc_redirect_url: string oidc_redirect_url: string
oidc_scopes: string oidc_scopes: string
oidc_tls_insecure_skip_verify: boolean oidc_tls_insecure_skip_verify: boolean
ldap_default_user_group_id: string default_user_group_id: string
oidc_default_user_group_id: string user_count?: number
created_at: number
updated_at: number
}
export type PublicAuthProvider = {
id: string
name: string
type: 'ldap' | 'oidc'
} }
export interface TLSSettings { export interface TLSSettings {
@@ -509,10 +519,6 @@ export interface TLSListener {
updated_at: number updated_at: number
} }
export interface OIDCStatus {
enabled: boolean
configured?: boolean
}
export interface PKICA { export interface PKICA {
id: string id: string
@@ -1348,7 +1354,7 @@ async function requestText(path: string, options: RequestInit = {}): Promise<str
} }
export const api = { export const api = {
oidcStatus: () => request<OIDCStatus>('/api/auth/oidc/enabled'), listPublicAuthProviders: () => request<PublicAuthProvider[]>('/api/auth/providers'),
login: (username: string, password: string, otpCode?: string) => login: (username: string, password: string, otpCode?: string) =>
request<User>('/api/login', { request<User>('/api/login', {
method: 'POST', method: 'POST',
@@ -1418,14 +1424,16 @@ export const api = {
request<CertPrincipalBinding>('/api/admin/cert-principal-bindings', { method: 'POST', body: JSON.stringify(payload) }), request<CertPrincipalBinding>('/api/admin/cert-principal-bindings', { method: 'POST', body: JSON.stringify(payload) }),
deleteCertPrincipalBinding: (fingerprint: string) => deleteCertPrincipalBinding: (fingerprint: string) =>
request<void>(`/api/admin/cert-principal-bindings/${encodeURIComponent(fingerprint)}`, { method: 'DELETE' }), request<void>(`/api/admin/cert-principal-bindings/${encodeURIComponent(fingerprint)}`, { method: 'DELETE' }),
getAuthSettings: () => request<AuthSettings>('/api/admin/auth'), listAuthProviders: () => request<AuthProvider[]>('/api/admin/auth-providers'),
updateAuthSettings: (payload: AuthSettings) => getAuthProvider: (id: string) => request<AuthProvider>(`/api/admin/auth-providers/${id}`),
request<AuthSettings>('/api/admin/auth', { method: 'PATCH', body: JSON.stringify(payload) }), createAuthProvider: (payload: Omit<AuthProvider, 'id' | 'user_count' | 'created_at' | 'updated_at'>) =>
testAuthSettings: ( request<AuthProvider>('/api/admin/auth-providers', { method: 'POST', body: JSON.stringify(payload) }),
payload: Partial<AuthSettings> & { username?: string; password?: string }, updateAuthProvider: (id: string, payload: Omit<AuthProvider, 'id' | 'user_count' | 'created_at' | 'updated_at'>) =>
signal?: AbortSignal request<AuthProvider>(`/api/admin/auth-providers/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
) => deleteAuthProvider: (id: string, force?: boolean) =>
request<{ status: string; user?: string }>('/api/admin/auth/test', { request<void>(`/api/admin/auth-providers/${id}${force ? '?force=true' : ''}`, { method: 'DELETE' }),
testAuthProvider: (id: string, payload: { username?: string; password?: string }, signal?: AbortSignal) =>
request<{ status: string; user?: string }>(`/api/admin/auth-providers/${id}/test`, {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
signal signal
+1 -2
View File
@@ -111,8 +111,7 @@ export const routes: RouteObject[] = [
{ path: 'admin/principals', element: <AdminServicePrincipalsPage /> }, { path: 'admin/principals', element: <AdminServicePrincipalsPage /> },
{ path: 'admin/auth', element: <AdminSiteAuthPage /> }, { path: 'admin/auth', element: <AdminSiteAuthPage /> },
{ path: 'admin/tls/auth-policies', element: <AdminTLSAuthPoliciesPage /> }, { path: 'admin/tls/auth-policies', element: <AdminTLSAuthPoliciesPage /> },
{ path: 'admin/tls', element: <AdminTLSSettingsPage /> }, { path: 'admin/tls', element: <AdminTLSSettingsPage /> }
{ path: 'admin/auth/ldap', element: <AdminSiteAuthPage /> }
] ]
}, },
{ path: '*', element: <NotFoundPage /> } { path: '*', element: <NotFoundPage /> }
@@ -30,7 +30,7 @@ export default function UserDetailsDialog(props: UserDetailsDialogProps) {
<TextField label="Email" value={item?.email || '-'} InputProps={{ readOnly: true }} /> <TextField label="Email" value={item?.email || '-'} InputProps={{ readOnly: true }} />
<TextField label="Admin" value={yesNo(item?.is_admin)} InputProps={{ readOnly: true }} /> <TextField label="Admin" value={yesNo(item?.is_admin)} InputProps={{ readOnly: true }} />
<TextField label="Disabled" value={yesNo(item?.disabled)} InputProps={{ readOnly: true }} /> <TextField label="Disabled" value={yesNo(item?.disabled)} InputProps={{ readOnly: true }} />
<TextField label="Auth Source" value={item?.auth_source || 'db'} InputProps={{ readOnly: true }} /> <TextField label="Auth Provider" value={item?.auth_provider_id || 'db'} InputProps={{ readOnly: true }} />
<TextField label="TOTP Enabled" value={yesNo(item?.totp_enabled)} InputProps={{ readOnly: true }} /> <TextField label="TOTP Enabled" value={yesNo(item?.totp_enabled)} InputProps={{ readOnly: true }} />
<TextField label="TOTP Required" value={yesNo(item?.totp_required)} InputProps={{ readOnly: true }} /> <TextField label="TOTP Required" value={yesNo(item?.totp_required)} InputProps={{ readOnly: true }} />
<TextField label="Effective TOTP Required" value={yesNo(item?.totp_effective_required)} InputProps={{ readOnly: true }} /> <TextField label="Effective TOTP Required" value={yesNo(item?.totp_effective_required)} InputProps={{ readOnly: true }} />
+2 -2
View File
@@ -179,7 +179,7 @@ export default function AccountPage() {
type="password" type="password"
value={password} value={password}
onChange={(event) => setPassword(event.target.value)} onChange={(event) => setPassword(event.target.value)}
helperText={user?.auth_source === 'db' ? '' : 'Password updates are available for db users only.'} helperText={user?.auth_provider_id === 'db' ? '' : 'Password updates are available for db users only.'}
/> />
<TextField <TextField
label="Confirm New Password" label="Confirm New Password"
@@ -250,7 +250,7 @@ export default function AccountPage() {
type="password" type="password"
value={totpPassword} value={totpPassword}
onChange={(event) => setTOTPPassword(event.target.value)} onChange={(event) => setTOTPPassword(event.target.value)}
helperText={user?.auth_source === 'db' ? 'Required for db users.' : 'Non-db users can leave this empty.'} helperText={user?.auth_provider_id === 'db' ? 'Required for db users.' : 'Non-db users can leave this empty.'}
/> />
<TextField <TextField
label="Authenticator Code" label="Authenticator Code"
@@ -422,7 +422,7 @@ export default function AdminSSHAccessProfilesPage() {
<SectionCard <SectionCard
title={ title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0, flexWrap: 'wrap' }}>
<Typography variant="h6">SSH Access Profiles</Typography> <Typography variant="h6">SSH Access Profiles ({filteredItems.length})</Typography>
<TextField <TextField
size="small" size="small"
label="Search" label="Search"
+34 -3
View File
@@ -12,7 +12,7 @@ import List from '@mui/material/List'
import ListItem from '@mui/material/ListItem' import ListItem from '@mui/material/ListItem'
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 { useEffect, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog' import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import FormDialogContent from '../components/FormDialogContent' import FormDialogContent from '../components/FormDialogContent'
import HeaderActionButton from '../components/HeaderActionButton' import HeaderActionButton from '../components/HeaderActionButton'
@@ -56,6 +56,22 @@ export default function AdminSSHCredentialsPage() {
const [viewItem, setViewItem] = useState<SSHCredential | null>(null) const [viewItem, setViewItem] = useState<SSHCredential | null>(null)
const [deleteItem, setDeleteItem] = useState<SSHCredential | null>(null) const [deleteItem, setDeleteItem] = useState<SSHCredential | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<string>('') const [deleteConfirm, setDeleteConfirm] = useState<string>('')
const [search, setSearch] = useState<string>('')
const filteredItems = useMemo(() => {
const normalized: string = search.trim().toLowerCase()
if (!normalized) {
return items
}
return items.filter((item: SSHCredential) => {
return item.id.toLowerCase().includes(normalized)
|| item.name.toLowerCase().includes(normalized)
|| (item.description || '').toLowerCase().includes(normalized)
|| (item.fingerprint || '').toLowerCase().includes(normalized)
|| (item.type || '').toLowerCase().includes(normalized)
})
}, [items, search])
const load = async () => { const load = async () => {
let list: SSHCredential[] let list: SSHCredential[]
@@ -135,7 +151,19 @@ export default function AdminSSHCredentialsPage() {
<Box> <Box>
<Typography variant="h5" sx={{ mb: 2 }}>Admin: SSH Credentials</Typography> <Typography variant="h5" sx={{ mb: 2 }}>Admin: SSH Credentials</Typography>
<SectionCard <SectionCard
title="Imported Private Keys" title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0, flexWrap: 'wrap' }}>
<Typography variant="h6">Imported Private Keys ({filteredItems.length})</Typography>
<TextField
size="small"
label="Search"
placeholder="name, fingerprint, or type"
value={search}
onChange={(event) => setSearch(event.target.value)}
sx={{ minWidth: 240, maxWidth: 320 }}
/>
</Box>
}
actions={ actions={
<HeaderActionButton variant="outlined" startIcon={<AddIcon />} onClick={openCreate}> <HeaderActionButton variant="outlined" startIcon={<AddIcon />} onClick={openCreate}>
Import Key Import Key
@@ -144,7 +172,7 @@ export default function AdminSSHCredentialsPage() {
> >
<PageAlert message={error} onClose={() => setError(null)} sx={{ mb: 1 }} /> <PageAlert message={error} onClose={() => setError(null)} sx={{ mb: 1 }} />
<List> <List>
{items.map((item) => ( {filteredItems.map((item: SSHCredential) => (
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}> <ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
<CompactListItemText <CompactListItemText
primary={ primary={
@@ -174,6 +202,9 @@ export default function AdminSSHCredentialsPage() {
</ListRowActions> </ListRowActions>
</ListItem> </ListItem>
))} ))}
{filteredItems.length === 0 ? (
<Typography variant="body2" color="text.secondary">{items.length ? 'No matching SSH credentials.' : 'No SSH credentials found.'}</Typography>
) : null}
</List> </List>
</SectionCard> </SectionCard>
@@ -14,7 +14,7 @@ import {
TextField, TextField,
Typography Typography
} from '@mui/material' } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog' import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import HeaderActionButton from '../components/HeaderActionButton' import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions' import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
@@ -22,10 +22,16 @@ import SectionCard from '../components/SectionCard'
import SSHPrincipalGrantDetailsDialog from '../components/SSHPrincipalGrantDetailsDialog' import SSHPrincipalGrantDetailsDialog from '../components/SSHPrincipalGrantDetailsDialog'
import SSHPrincipalGrantFormDialog, { SSHPrincipalGrantFormState } from '../components/SSHPrincipalGrantFormDialog' import SSHPrincipalGrantFormDialog, { SSHPrincipalGrantFormState } from '../components/SSHPrincipalGrantFormDialog'
import { api, SSHPrincipalGrant, SSHPrincipalGrantUpsertPayload, User, UserGroup } from '../api' import { api, SSHPrincipalGrant, SSHPrincipalGrantUpsertPayload, User, UserGroup } from '../api'
import Autocomplete from '../components/Autocomplete'
import SelectField from '../components/SelectField' import SelectField from '../components/SelectField'
import CompactListItemText from '../components/CompactListItemText' import CompactListItemText from '../components/CompactListItemText'
import PageAlert from '../components/PageAlert' import PageAlert from '../components/PageAlert'
type TargetOption = {
id: string
label: string
}
function fmt(value: number): string { function fmt(value: number): string {
if (!value || value <= 0) { if (!value || value <= 0) {
return '-' return '-'
@@ -79,6 +85,7 @@ export default function AdminSSHPrincipalGrantsPage() {
const [filterTargetType, setFilterTargetType] = useState<'user' | 'group' | ''>('') const [filterTargetType, setFilterTargetType] = useState<'user' | 'group' | ''>('')
const [filterTargetID, setFilterTargetID] = useState('') const [filterTargetID, setFilterTargetID] = useState('')
const [search, setSearch] = useState('')
const [viewItem, setViewItem] = useState<SSHPrincipalGrant | null>(null) const [viewItem, setViewItem] = useState<SSHPrincipalGrant | null>(null)
const [editOpen, setEditOpen] = useState(false) const [editOpen, setEditOpen] = useState(false)
@@ -232,23 +239,47 @@ export default function AdminSSHPrincipalGrantsPage() {
} }
} }
const userOptions = users.map((item) => ({ const userOptions: TargetOption[] = users.map((item: User) => ({
id: item.id, id: item.id,
label: item.display_name ? `${item.display_name} (${item.username})` : item.username label: item.display_name ? `${item.display_name} (${item.username})` : item.username
})) }))
const groupOptions = groups.map((item) => ({ const groupOptions: TargetOption[] = groups.map((item: UserGroup) => ({
id: item.id, id: item.id,
label: `${item.name}${item.disabled ? ' (disabled)' : ''}` label: `${item.name}${item.disabled ? ' (disabled)' : ''}`
})) }))
const filteredItems = useMemo(() => {
const normalized: string = search.trim().toLowerCase()
if (!normalized) {
return items
}
return items.filter((item: SSHPrincipalGrant) => {
return item.id.toLowerCase().includes(normalized)
|| (item.name || '').toLowerCase().includes(normalized)
|| (item.principal || '').toLowerCase().includes(normalized)
|| (item.principals || []).join(' ').toLowerCase().includes(normalized)
|| (item.reason || '').toLowerCase().includes(normalized)
|| (item.targets || []).map((target) => `${target.target_type} ${target.target_id} ${target.target_name || ''}`).join(' ').toLowerCase().includes(normalized)
})
}, [items, search])
return ( return (
<Box> <Box>
<Typography variant="h5" sx={{ mb: 2 }}>Admin: SSH Principal Grants</Typography> <Typography variant="h5" sx={{ mb: 2 }}>Admin: SSH Principal Grants</Typography>
<SectionCard <SectionCard
title={ title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', minWidth: 0 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', minWidth: 0 }}>
<Typography variant="h6">SSH Principal Grants</Typography> <Typography variant="h6">SSH Principal Grants ({filteredItems.length})</Typography>
<TextField
size="small"
label="Search"
placeholder="grant, principal, target, or reason"
value={search}
onChange={(event) => setSearch(event.target.value)}
sx={{ minWidth: 240, maxWidth: 320 }}
/>
<SelectField <SelectField
select select
size="small" size="small"
@@ -265,34 +296,30 @@ export default function AdminSSHPrincipalGrantsPage() {
<MenuItem value="group">Group</MenuItem> <MenuItem value="group">Group</MenuItem>
</SelectField> </SelectField>
{filterTargetType === 'user' ? ( {filterTargetType === 'user' ? (
<SelectField <Autocomplete<TargetOption, false, false, false>
select
size="small" size="small"
label="User" options={userOptions}
value={filterTargetID} value={userOptions.find((item: TargetOption) => item.id === filterTargetID) || null}
onChange={(event) => setFilterTargetID(event.target.value)} onChange={(_event, value: TargetOption | null) => setFilterTargetID(value?.id || '')}
getOptionLabel={(item: TargetOption) => item.label}
isOptionEqualToValue={(option: TargetOption, value: TargetOption) => option.id === value.id}
noOptionsText="No users"
renderInput={(params) => <TextField {...params} label="User" placeholder="(all users)" />}
sx={{ minWidth: 240 }} sx={{ minWidth: 240 }}
> />
<MenuItem value="">(all users)</MenuItem>
{users.map((item) => (
<MenuItem key={item.id} value={item.id}>{item.display_name ? `${item.display_name} (${item.username})` : item.username}</MenuItem>
))}
</SelectField>
) : null} ) : null}
{filterTargetType === 'group' ? ( {filterTargetType === 'group' ? (
<SelectField <Autocomplete<TargetOption, false, false, false>
select
size="small" size="small"
label="Group" options={groupOptions}
value={filterTargetID} value={groupOptions.find((item: TargetOption) => item.id === filterTargetID) || null}
onChange={(event) => setFilterTargetID(event.target.value)} onChange={(_event, value: TargetOption | null) => setFilterTargetID(value?.id || '')}
getOptionLabel={(item: TargetOption) => item.label}
isOptionEqualToValue={(option: TargetOption, value: TargetOption) => option.id === value.id}
noOptionsText="No groups"
renderInput={(params) => <TextField {...params} label="Group" placeholder="(all groups)" />}
sx={{ minWidth: 220 }} sx={{ minWidth: 220 }}
> />
<MenuItem value="">(all groups)</MenuItem>
{groups.map((item) => (
<MenuItem key={item.id} value={item.id}>{item.name}</MenuItem>
))}
</SelectField>
) : null} ) : null}
</Box> </Box>
} }
@@ -304,10 +331,10 @@ export default function AdminSSHPrincipalGrantsPage() {
> >
<PageAlert message={error} onClose={() => setError(null)} sx={{ mb: 1 }} /> <PageAlert message={error} onClose={() => setError(null)} sx={{ mb: 1 }} />
{loading ? <Typography variant="body2" color="text.secondary">Loading principal grants...</Typography> : null} {loading ? <Typography variant="body2" color="text.secondary">Loading principal grants...</Typography> : null}
{!loading && items.length === 0 ? <Typography variant="body2" color="text.secondary">No principal grants configured.</Typography> : null} {!loading && filteredItems.length === 0 ? <Typography variant="body2" color="text.secondary">{items.length ? 'No matching principal grants.' : 'No principal grants configured.'}</Typography> : null}
{!loading ? ( {!loading ? (
<List dense> <List dense>
{items.map((item) => ( {filteredItems.map((item: SSHPrincipalGrant) => (
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}> <ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
<CompactListItemText <CompactListItemText
@@ -178,9 +178,10 @@ export default function AdminSSHSignHistoryPage() {
<Box> <Box>
<Typography variant="h5" sx={{ mb: 2 }}>Admin: SSH Signing History</Typography> <Typography variant="h5" sx={{ mb: 2 }}>Admin: SSH Signing History</Typography>
<PageAlert message={error} onClose={() => setError(null)} sx={{ mb: 1 }} /> <PageAlert message={error} onClose={() => setError(null)} sx={{ mb: 1 }} />
<SectionCard
<SectionCard title="SSH Signing History"> title={
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="h6">SSH Signing History</Typography>
<Autocomplete<SSHUserCA, false, false, false> <Autocomplete<SSHUserCA, false, false, false>
size="small" size="small"
options={cas} options={cas}
@@ -196,6 +197,10 @@ export default function AdminSSHSignHistoryPage() {
renderInput={(params) => <TextField {...params} label="CA" placeholder="(all)" />} renderInput={(params) => <TextField {...params} label="CA" placeholder="(all)" />}
sx={{ minWidth: 400 }} sx={{ minWidth: 400 }}
/> />
</Box>
}
actions={
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<TextField <TextField
size="small" size="small"
label="Limit" label="Limit"
@@ -207,6 +212,8 @@ export default function AdminSSHSignHistoryPage() {
{loading ? 'Loading...' : 'Refresh'} {loading ? 'Loading...' : 'Refresh'}
</Button> </Button>
</Box> </Box>
}
>
{!loading && items.length === 0 ? <Typography variant="body2" color="text.secondary">No signing history found.</Typography> : null} {!loading && items.length === 0 ? <Typography variant="body2" color="text.secondary">No signing history found.</Typography> : null}
<List> <List>
{items.map((item: SSHUserCAIssuance) => ( {items.map((item: SSHUserCAIssuance) => (
@@ -792,7 +792,8 @@ export default function AdminServicePrincipalsPage() {
<ListItem key={item.fingerprint} divider sx={{ alignItems: 'flex-start' }}> <ListItem key={item.fingerprint} divider sx={{ alignItems: 'flex-start' }}>
<CompactListItemText <CompactListItemText
primary={ primary={
pkiCertByFingerprint(item.fingerprint) ? ( <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0, flexWrap: 'wrap' }}>
{pkiCertByFingerprint(item.fingerprint) ? (
<Link <Link
underline="hover" underline="hover"
onClick={() => void openCertView(pkiCertByFingerprint(item.fingerprint)?.id || '')} onClick={() => void openCertView(pkiCertByFingerprint(item.fingerprint)?.id || '')}
@@ -802,7 +803,9 @@ export default function AdminServicePrincipalsPage() {
</Link> </Link>
) : ( ) : (
<Typography>{item.fingerprint}</Typography> <Typography>{item.fingerprint}</Typography>
) )}
{item.enabled ? null : <Chip size="small" color="error" label="Disabled" />}
</Box>
} }
secondary={ secondary={
<Box sx={{ display: 'grid' }}> <Box sx={{ display: 'grid' }}>
+473 -198
View File
@@ -1,17 +1,56 @@
import { Box, Button, Checkbox, FormControlLabel, MenuItem, TextField, Typography } from '@mui/material' import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox'
import Chip from '@mui/material/Chip'
import Dialog from '../components/ModalDialog'
import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel'
import List from '@mui/material/List'
import ListItem from '@mui/material/ListItem'
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 CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline' import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
import { useEffect, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import PageAlert from '../components/PageAlert' import CompactListItemText from '../components/CompactListItemText'
import { api, AuthSettings, UserGroup } from '../api' import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import FormDialogContent from '../components/FormDialogContent'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard' import SectionCard from '../components/SectionCard'
import SelectField from '../components/SelectField' import SelectField from '../components/SelectField'
import PageAlert from '../components/PageAlert'
import Autocomplete from '../components/Autocomplete' import Autocomplete from '../components/Autocomplete'
import { api, AuthProvider, UserGroup } from '../api'
export default function AdminSiteAuthPage() { type ProviderFormState = {
const [settings, setSettings] = useState<AuthSettings>({ name: string
auth_mode: 'db', type: 'ldap' | 'oidc' | 'db'
oidc_enabled: false, enabled: boolean
ldap_url: string
ldap_bind_dn: string
ldap_bind_password: string
ldap_user_base_dn: string
ldap_user_filter: string
ldap_tls_insecure_skip_verify: boolean
oidc_client_id: string
oidc_client_secret: string
oidc_authorize_url: string
oidc_token_url: string
oidc_userinfo_url: string
oidc_redirect_url: string
oidc_scopes: string
oidc_tls_insecure_skip_verify: boolean
default_user_group_id: string
}
function emptyProviderForm(): ProviderFormState {
return {
name: '',
type: 'ldap',
enabled: true,
ldap_url: '', ldap_url: '',
ldap_bind_dn: '', ldap_bind_dn: '',
ldap_bind_password: '', ldap_bind_password: '',
@@ -26,71 +65,164 @@ export default function AdminSiteAuthPage() {
oidc_redirect_url: '', oidc_redirect_url: '',
oidc_scopes: 'openid profile email', oidc_scopes: 'openid profile email',
oidc_tls_insecure_skip_verify: false, oidc_tls_insecure_skip_verify: false,
ldap_default_user_group_id: '', default_user_group_id: ''
oidc_default_user_group_id: '' }
}) }
export default function AdminSiteAuthPage() {
// Providers list
const [providers, setProviders] = useState<AuthProvider[]>([])
const [userGroups, setUserGroups] = useState<UserGroup[]>([]) const [userGroups, setUserGroups] = useState<UserGroup[]>([])
const [loading, setLoading] = useState(false) const [providersLoading, setProvidersLoading] = useState(false)
const [providersError, setProvidersError] = useState<string | null>(null)
const [providerSearch, setProviderSearch] = useState('')
// Provider dialog
const [dialogOpen, setDialogOpen] = useState(false)
const [editingProvider, setEditingProvider] = useState<AuthProvider | null>(null)
const [form, setForm] = useState<ProviderFormState>(emptyProviderForm())
const [dialogError, setDialogError] = useState<string | null>(null)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
// Delete dialog
const [deleteItem, setDeleteItem] = useState<AuthProvider | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState('')
const [forceDelete, setForceDelete] = useState(false)
const [deleteError, setDeleteError] = useState<string | null>(null)
const [deleting, setDeleting] = useState(false)
// LDAP test
const [testing, setTesting] = useState(false) const [testing, setTesting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
const [testError, setTestError] = useState<string | null>(null) const [testError, setTestError] = useState<string | null>(null)
const [testResult, setTestResult] = useState<string | null>(null) const [testResult, setTestResult] = useState<string | null>(null)
const [testUsername, setTestUsername] = useState('') const [testUsername, setTestUsername] = useState('')
const [testPassword, setTestPassword] = useState('') const [testPassword, setTestPassword] = useState('')
const testControllerRef = useRef<AbortController | null>(null) const testControllerRef = useRef<AbortController | null>(null)
const filteredProviders = useMemo(() => {
const needle = providerSearch.trim().toLowerCase()
if (!needle) return providers
return providers.filter((p) =>
[p.name, p.type, p.id].join(' ').toLowerCase().includes(needle)
)
}, [providers, providerSearch])
const loadProviders = async () => {
setProvidersLoading(true)
try {
const [list, groups] = await Promise.all([api.listAuthProviders(), api.listUserGroups()])
setProviders(Array.isArray(list) ? list : [])
setUserGroups(Array.isArray(groups) ? groups.filter((g) => g.scope === 'explicit') : [])
} catch (err) {
setProvidersError(err instanceof Error ? err.message : 'Failed to load auth providers')
} finally {
setProvidersLoading(false)
}
}
useEffect(() => { useEffect(() => {
setLoading(true) loadProviders()
Promise.all([api.getAuthSettings(), api.listUserGroups()])
.then(([authData, groups]) => {
setSettings(authData)
setUserGroups(groups.filter((g) => g.scope === 'explicit'))
})
.catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load authentication settings'
setError(message)
})
.finally(() => setLoading(false))
}, []) }, [])
const handleSave = async () => { useEffect(() => {
return () => {
if (testControllerRef.current) testControllerRef.current.abort()
}
}, [])
const openCreate = () => {
setEditingProvider(null)
setForm(emptyProviderForm())
setDialogError(null)
setTestError(null)
setTestResult(null)
setTestUsername('')
setTestPassword('')
setDialogOpen(true)
}
const openEdit = (item: AuthProvider) => {
setEditingProvider(item)
setForm({
name: item.name,
type: item.type,
enabled: item.enabled,
ldap_url: item.ldap_url,
ldap_bind_dn: item.ldap_bind_dn,
ldap_bind_password: item.ldap_bind_password,
ldap_user_base_dn: item.ldap_user_base_dn,
ldap_user_filter: item.ldap_user_filter || '(uid={username})',
ldap_tls_insecure_skip_verify: item.ldap_tls_insecure_skip_verify,
oidc_client_id: item.oidc_client_id,
oidc_client_secret: item.oidc_client_secret,
oidc_authorize_url: item.oidc_authorize_url,
oidc_token_url: item.oidc_token_url,
oidc_userinfo_url: item.oidc_userinfo_url,
oidc_redirect_url: item.oidc_redirect_url,
oidc_scopes: item.oidc_scopes || 'openid profile email',
oidc_tls_insecure_skip_verify: item.oidc_tls_insecure_skip_verify,
default_user_group_id: item.default_user_group_id
})
setDialogError(null)
setTestError(null)
setTestResult(null)
setTestUsername('')
setTestPassword('')
setDialogOpen(true)
}
const handleSaveProvider = async () => {
if (!form.name.trim()) {
setDialogError('Provider name is required')
return
}
setSaving(true) setSaving(true)
setError(null) setDialogError(null)
setSaved(false)
try { try {
const updated = await api.updateAuthSettings(settings) if (editingProvider) {
setSettings(updated) await api.updateAuthProvider(editingProvider.id, form)
setSaved(true) } else {
await api.createAuthProvider(form)
}
setDialogOpen(false)
await loadProviders()
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save authentication settings' setDialogError(err instanceof Error ? err.message : 'Failed to save auth provider')
setError(message)
} finally { } finally {
setSaving(false) setSaving(false)
} }
} }
const handleTest = async () => { const handleDelete = async () => {
let controller: AbortController if (!deleteItem) return
if (testing) { setDeleting(true)
if (testControllerRef.current) { setDeleteError(null)
testControllerRef.current.abort() try {
await api.deleteAuthProvider(deleteItem.id, forceDelete)
setDeleteItem(null)
await loadProviders()
} catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to delete auth provider')
} finally {
setDeleting(false)
} }
}
const handleTest = async () => {
if (!editingProvider) return
if (testing) {
if (testControllerRef.current) testControllerRef.current.abort()
return return
} }
controller = new AbortController() const controller = new AbortController()
testControllerRef.current = controller testControllerRef.current = controller
setTesting(true) setTesting(true)
setTestError(null) setTestError(null)
setTestResult(null) setTestResult(null)
try { try {
const result = await api.testAuthSettings( const result = await api.testAuthProvider(
{ editingProvider.id,
...settings, { username: testUsername.trim() || undefined, password: testPassword || undefined },
username: testUsername.trim() || undefined,
password: testPassword || undefined
},
controller.signal controller.signal
) )
setTestResult(result.user ? `Connection ok. User test ok: ${result.user}` : 'Connection ok.') setTestResult(result.user ? `Connection ok. User test ok: ${result.user}` : 'Connection ok.')
@@ -99,180 +231,323 @@ export default function AdminSiteAuthPage() {
setTestResult('LDAP test canceled.') setTestResult('LDAP test canceled.')
return return
} }
const message = err instanceof Error ? err.message : 'LDAP test failed' setTestError(err instanceof Error ? err.message : 'LDAP test failed')
setTestError(message)
} finally { } finally {
setTesting(false) setTesting(false)
testControllerRef.current = null testControllerRef.current = null
} }
} }
useEffect(() => { const isLDAP = form.type === 'ldap'
return () => { const isOIDC = form.type === 'oidc'
if (testControllerRef.current) {
testControllerRef.current.abort()
}
}
}, [])
const testSubtitle = testError ? ( return (
<Box sx={{ display: 'grid', gap: 1, minWidth: 0 }}>
<Typography variant="h5" sx={{ mb: 1 }}>Admin: Site Authentication</Typography>
{/* Auth providers card */}
<SectionCard
collapsible
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0, flexWrap: 'wrap' }}>
<Typography variant="h6">Providers ({filteredProviders.length})</Typography>
<TextField
size="small"
label="Search"
value={providerSearch}
onChange={(event) => setProviderSearch(event.target.value)}
sx={{ minWidth: 240 }}
/>
</Box>
}
actions={
<Button variant="outlined" onClick={openCreate}>
New Provider
</Button>
}
>
<PageAlert message={providersError} onClose={() => setProvidersError(null)} />
{providersLoading ? (
<Typography variant="body2" color="text.secondary">Loading...</Typography>
) : null}
{filteredProviders.length === 0 && !providersLoading ? (
<Typography variant="body2" color="text.secondary">
No auth providers configured.
</Typography>
) : null}
<List>
{filteredProviders.map((item) => (
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
<CompactListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography sx={{ wordBreak: 'break-word' }}>{item.name}</Typography>
<Chip label={item.type.toUpperCase()} size="small" variant="outlined" />
{item.enabled ? (
<Chip label="enabled" size="small" color="success" />
) : (
<Chip label="disabled" size="small" />
)}
</Box>
}
secondary={
<Box sx={{ display: 'grid', gap: 0.5 }}>
<Typography variant="caption" color="text.secondary">
{(item.user_count ?? 0) > 0 ? `${item.user_count} user${item.user_count === 1 ? '' : 's'} · ` : ''}{item.id}
</Typography>
</Box>
}
disableTypography
/>
<ListRowActions>
<ListRowActionButton onClick={() => openEdit(item)}>Edit</ListRowActionButton>
<ListRowActionButton color="error" disabled={item.id === 'db'} onClick={() => {
setDeleteError(null)
setForceDelete(false)
setDeleteConfirm('')
setDeleteItem(item)
}}>
Delete
</ListRowActionButton>
</ListRowActions>
</ListItem>
))}
</List>
</SectionCard>
{/* Create / Edit dialog */}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">
{editingProvider ? `Edit Provider: ${editingProvider.name}` : 'New Provider'}
</Typography>
<PageAlert message={dialogError} onClose={() => setDialogError(null)} />
</Box>
</DialogTitle>
<FormDialogContent>
<TextField
label="Name"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
/>
{!editingProvider ? (
<SelectField
select
label="Type"
value={form.type}
onChange={(e) => setForm((prev) => ({ ...prev, type: e.target.value as 'ldap' | 'oidc' }))}
>
<MenuItem value="ldap">LDAP</MenuItem>
<MenuItem value="oidc">OIDC</MenuItem>
</SelectField>
) : null}
<FormControlLabel
control={
<Checkbox
checked={form.enabled}
onChange={(e) => setForm((prev) => ({ ...prev, enabled: e.target.checked }))}
/>
}
label="Enabled"
/>
{editingProvider?.type !== 'db' ? (
<>
<Autocomplete<UserGroup, false, false, false>
options={userGroups}
value={userGroups.find((g) => g.id === form.default_user_group_id) ?? null}
onChange={(_event, value) => setForm((prev) => ({ ...prev, default_user_group_id: value?.id ?? '' }))}
getOptionLabel={(g) => g.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={form.ldap_url}
onChange={(e) => setForm((prev) => ({ ...prev, ldap_url: e.target.value }))}
helperText="Example: ldap://ldap.example.com:389"
/>
<TextField
label="Bind DN (optional)"
value={form.ldap_bind_dn}
onChange={(e) => setForm((prev) => ({ ...prev, ldap_bind_dn: e.target.value }))}
/>
<TextField
label="Bind Password"
type="password"
value={form.ldap_bind_password}
onChange={(e) => setForm((prev) => ({ ...prev, ldap_bind_password: e.target.value }))}
/>
<TextField
label="User Base DN"
value={form.ldap_user_base_dn}
onChange={(e) => setForm((prev) => ({ ...prev, ldap_user_base_dn: e.target.value }))}
/>
<TextField
label="User Filter"
value={form.ldap_user_filter}
onChange={(e) => setForm((prev) => ({ ...prev, ldap_user_filter: e.target.value }))}
helperText="Use {username} as placeholder. Example: (uid={username})"
/>
<FormControlLabel
control={
<Checkbox
checked={form.ldap_tls_insecure_skip_verify}
onChange={(e) => setForm((prev) => ({ ...prev, ldap_tls_insecure_skip_verify: e.target.checked }))}
/>
}
label="TLS insecure skip verify (testing/self-signed only)"
/>
</>
) : null}
{isOIDC ? (
<>
<TextField
label="Client ID"
value={form.oidc_client_id}
onChange={(e) => setForm((prev) => ({ ...prev, oidc_client_id: e.target.value }))}
/>
<TextField
label="Client Secret"
type="password"
value={form.oidc_client_secret}
onChange={(e) => setForm((prev) => ({ ...prev, oidc_client_secret: e.target.value }))}
/>
<TextField
label="Authorize URL"
value={form.oidc_authorize_url}
onChange={(e) => setForm((prev) => ({ ...prev, oidc_authorize_url: e.target.value }))}
/>
<TextField
label="Token URL"
value={form.oidc_token_url}
onChange={(e) => setForm((prev) => ({ ...prev, oidc_token_url: e.target.value }))}
/>
<TextField
label="UserInfo URL (optional)"
value={form.oidc_userinfo_url}
onChange={(e) => setForm((prev) => ({ ...prev, oidc_userinfo_url: e.target.value }))}
/>
<TextField
label="Redirect URL"
value={form.oidc_redirect_url}
onChange={(e) => setForm((prev) => ({ ...prev, oidc_redirect_url: e.target.value }))}
helperText={
editingProvider
? `Callback URL: /api/auth/oidc/${editingProvider.id}/callback`
: 'You can set this after creating the provider once you have its ID.'
}
/>
<TextField
label="Scopes"
value={form.oidc_scopes}
onChange={(e) => setForm((prev) => ({ ...prev, oidc_scopes: e.target.value }))}
helperText="Space-separated. Example: openid profile email"
/>
<FormControlLabel
control={
<Checkbox
checked={form.oidc_tls_insecure_skip_verify}
onChange={(e) => setForm((prev) => ({ ...prev, oidc_tls_insecure_skip_verify: e.target.checked }))}
/>
}
label="OIDC TLS insecure skip verify (testing/self-signed only)"
/>
</>
) : null}
{editingProvider && editingProvider.type === 'ldap' ? (
<Box sx={{ borderTop: 1, borderColor: 'divider', pt: 1.5, mt: 0.5, display: 'grid', gap: 1 }}>
<Typography variant="subtitle2">Test LDAP Connection</Typography>
{testError ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: 'error.main' }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: 'error.main' }}>
<ErrorOutlineIcon fontSize="small" /> <ErrorOutlineIcon fontSize="small" />
<Typography variant="body2" color="inherit"> <Typography variant="body2" color="inherit">{testError}</Typography>
{testError}
</Typography>
</Box> </Box>
) : testResult ? ( ) : testResult ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: 'success.main' }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: 'success.main' }}>
<CheckCircleOutlineIcon fontSize="small" /> <CheckCircleOutlineIcon fontSize="small" />
<Typography variant="body2" color="inherit"> <Typography variant="body2" color="inherit">{testResult}</Typography>
{testResult}
</Typography>
</Box> </Box>
) : 'Optional user bind'
return (
<Box sx={{ display: 'grid', gap: 1, minWidth: 0 }}>
<Typography variant="h5">
Admin: Site Authentication
</Typography>
{loading ? (
<Typography variant="body2" color="text.secondary">
Loading...
</Typography>
) : null} ) : null}
<PageAlert message={error} onClose={() => setError(null)} />
<PageAlert severity="success" message={saved ? 'Saved.' : null} onClose={() => setSaved(false)} />
<Box sx={{ display: 'grid', gap: 1, width: '100%', minWidth: 0 }}>
<SectionCard
title="General"
actions={
<Button variant="contained" onClick={handleSave} disabled={saving || loading}>
{saving ? 'Saving...' : 'Save'}
</Button>
}
>
<SelectField
select
label="Auth Mode"
value={settings.auth_mode}
onChange={(event) => setSettings((prev) => ({ ...prev, auth_mode: event.target.value as 'db' | 'ldap' | 'hybrid' }))}
>
<MenuItem value="db">db</MenuItem>
<MenuItem value="ldap">ldap</MenuItem>
<MenuItem value="hybrid">hybrid</MenuItem>
</SelectField>
</SectionCard>
<Box
sx={{
display: 'grid',
gap: 1,
width: '100%',
minWidth: 0,
gridTemplateColumns: { xs: '1fr', lg: 'minmax(0, 1fr) minmax(0, 1fr)' },
alignItems: 'start'
}}
>
<SectionCard title="OIDC">
<FormControlLabel
control={<Checkbox checked={settings.oidc_enabled} onChange={(event) => setSettings((prev) => ({ ...prev, oidc_enabled: event.target.checked }))} />}
label="Enable OIDC login"
/>
<TextField label="Client ID" value={settings.oidc_client_id} onChange={(event) => setSettings((prev) => ({ ...prev, oidc_client_id: event.target.value }))} />
<TextField <TextField
label="Client Secret" label="Test Username (optional)"
size="small"
value={testUsername}
onChange={(e) => setTestUsername(e.target.value)}
/>
<TextField
label="Test Password (optional)"
type="password" type="password"
value={settings.oidc_client_secret} size="small"
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_client_secret: event.target.value }))} value={testPassword}
onChange={(e) => setTestPassword(e.target.value)}
/> />
<TextField <Box>
label="Authorize URL" <Button
value={settings.oidc_authorize_url} variant="outlined"
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_authorize_url: event.target.value }))} size="small"
/> onClick={handleTest}
<TextField color={testing ? 'warning' : 'primary'}
label="Token URL" >
value={settings.oidc_token_url}
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_token_url: event.target.value }))}
/>
<TextField
label="UserInfo URL (optional)"
value={settings.oidc_userinfo_url}
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_userinfo_url: event.target.value }))}
/>
<TextField
label="Redirect URL"
value={settings.oidc_redirect_url}
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_redirect_url: event.target.value }))}
/>
<TextField
label="Scopes"
value={settings.oidc_scopes}
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_scopes: event.target.value }))}
helperText="Example: openid profile email"
/>
<FormControlLabel
control={<Checkbox checked={settings.oidc_tls_insecure_skip_verify} onChange={(event) => setSettings((prev) => ({ ...prev, oidc_tls_insecure_skip_verify: event.target.checked }))} />}
label="OIDC TLS insecure skip verify (testing/self-signed only)"
/>
<Autocomplete<UserGroup, false, false, false>
options={userGroups}
value={userGroups.find((g) => g.id === settings.oidc_default_user_group_id) || null}
onChange={(_event, value) => setSettings((prev) => ({ ...prev, oidc_default_user_group_id: value?.id || '' }))}
getOptionLabel={(g) => g.name}
isOptionEqualToValue={(option, value) => option.id === value.id}
noOptionsText="No user groups"
renderInput={(params) => <TextField {...params} label="Default User Group for new OIDC users" helperText="Automatically added to this group on first login." />}
/>
</SectionCard>
<SectionCard title="LDAP">
<TextField label="LDAP URL" value={settings.ldap_url} onChange={(event) => setSettings((prev) => ({ ...prev, ldap_url: event.target.value }))} />
<TextField label="Bind DN" value={settings.ldap_bind_dn} onChange={(event) => setSettings((prev) => ({ ...prev, ldap_bind_dn: event.target.value }))} />
<TextField
label="Bind Password"
type="password"
value={settings.ldap_bind_password}
onChange={(event) => setSettings((prev) => ({ ...prev, ldap_bind_password: event.target.value }))}
/>
<TextField
label="User Base DN"
value={settings.ldap_user_base_dn}
onChange={(event) => setSettings((prev) => ({ ...prev, ldap_user_base_dn: event.target.value }))}
/>
<TextField
label="User Filter"
value={settings.ldap_user_filter}
onChange={(event) => setSettings((prev) => ({ ...prev, ldap_user_filter: event.target.value }))}
helperText="Use {username} placeholder."
/>
<FormControlLabel
control={<Checkbox checked={settings.ldap_tls_insecure_skip_verify} onChange={(event) => setSettings((prev) => ({ ...prev, ldap_tls_insecure_skip_verify: event.target.checked }))} />}
label="TLS insecure skip verify (testing/self-signed only)"
/>
<Autocomplete<UserGroup, false, false, false>
options={userGroups}
value={userGroups.find((g) => g.id === settings.ldap_default_user_group_id) || null}
onChange={(_event, value) => setSettings((prev) => ({ ...prev, ldap_default_user_group_id: value?.id || '' }))}
getOptionLabel={(g) => g.name}
isOptionEqualToValue={(option, value) => option.id === value.id}
noOptionsText="No user groups"
renderInput={(params) => <TextField {...params} label="Default User Group for new LDAP users" helperText="Automatically added to this group on first login." />}
/>
</SectionCard>
</Box>
<SectionCard
title="Test LDAP"
subtitle={testSubtitle}
actions={
<Button variant="outlined" onClick={handleTest} color={testing ? 'warning' : 'primary'} disabled={loading}>
{testing ? 'Cancel Test' : 'Test Connection'} {testing ? 'Cancel Test' : 'Test Connection'}
</Button> </Button>
}
>
<TextField label="Test Username" value={testUsername} onChange={(event) => setTestUsername(event.target.value)} />
<TextField label="Test Password" type="password" value={testPassword} onChange={(event) => setTestPassword(event.target.value)} />
</SectionCard>
</Box> </Box>
</Box> </Box>
) : null}
</>) : null}
</FormDialogContent>
<DialogActions>
<Button variant="contained" onClick={handleSaveProvider} disabled={saving}>
{saving ? 'Saving...' : editingProvider ? 'Save' : 'Create'}
</Button>
<Button onClick={() => setDialogOpen(false)} disabled={saving}>Cancel</Button>
</DialogActions>
</Dialog>
{/* Delete dialog */}
<ConfirmDeleteDialog
open={!!deleteItem}
title="Delete Auth Provider"
prompt="Type the provider name to confirm deletion."
expectedText={deleteItem?.name}
confirmLabel="Provider name"
confirmValue={deleteConfirm}
onConfirmValueChange={setDeleteConfirm}
error={deleteError}
busy={deleting}
confirmDisabled={(deleteItem?.user_count ?? 0) > 0 && !forceDelete}
onConfirm={handleDelete}
onClose={() => { if (!deleting) { setDeleteItem(null); setDeleteConfirm('') } }}
>
{(deleteItem?.user_count ?? 0) > 0 ? (
<>
<Alert severity="warning">
This provider has <strong>{deleteItem!.user_count}</strong> associated{' '}
{deleteItem!.user_count === 1 ? 'user' : 'users'}. Deleting it will unlink those users
from this provider.
</Alert>
<FormControlLabel
control={
<Checkbox
checked={forceDelete}
onChange={(e) => setForceDelete(e.target.checked)}
color="warning"
/>
}
label={`Delete even though ${deleteItem!.user_count} ${deleteItem!.user_count === 1 ? 'user' : 'users'} will be unlinked`}
/>
</>
) : null}
</ConfirmDeleteDialog>
</Box>
) )
} }
@@ -186,7 +186,7 @@ export default function AdminTLSAuthPoliciesPage() {
<SectionCard <SectionCard
title={ title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0, flexWrap: 'wrap' }}>
<Typography variant="h6">Policies</Typography> <Typography variant="h6">Policies ({filteredItems.length})</Typography>
<TextField <TextField
size="small" size="small"
label="Search" label="Search"
+74 -3
View File
@@ -61,6 +61,7 @@ export default function AdminTLSSettingsPage() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [listeners, setListeners] = useState<TLSListener[]>([]) const [listeners, setListeners] = useState<TLSListener[]>([])
const [listenersLoading, setListenersLoading] = useState(false) const [listenersLoading, setListenersLoading] = useState(false)
const [listenerSearch, setListenerSearch] = useState('')
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [editingMain, setEditingMain] = useState(false) const [editingMain, setEditingMain] = useState(false)
const [editingID, setEditingID] = useState<string | null>(null) const [editingID, setEditingID] = useState<string | null>(null)
@@ -189,6 +190,51 @@ export default function AdminTLSSettingsPage() {
.join(' · ') .join(' · ')
} }
const listenerMatchesSearch = (item: ListenerForm & { id?: string }, value: string) => {
const normalized: string = value.trim().toLowerCase()
if (!normalized) {
return true
}
return [
item.id || '',
item.name,
(item.http_addrs || []).join(' '),
(item.https_addrs || []).join(' '),
summarizeEndpointPolicies(item.endpoint_policies || []),
item.tls_server_cert_source,
item.tls_pki_server_cert_id,
item.tls_client_auth,
item.tls_pki_client_ca_id,
item.tls_min_version
]
.join(' ')
.toLowerCase()
.includes(normalized)
}
const mainListenerMatches: boolean = listenerMatchesSearch({
name: 'Main Listener',
enabled: true,
http_addrs: settings.http_addrs || [],
https_addrs: settings.https_addrs || [],
endpoint_policies: settings.endpoint_policies || [],
tls_server_cert_source: settings.tls_server_cert_source || 'pki',
tls_cert_file: settings.tls_cert_file || '',
tls_key_file: settings.tls_key_file || '',
tls_pki_server_cert_id: settings.tls_pki_server_cert_id || '',
tls_client_auth: settings.tls_client_auth || 'none',
tls_client_ca_file: settings.tls_client_ca_file || '',
tls_pki_client_ca_id: settings.tls_pki_client_ca_id || '',
tls_min_version: settings.tls_min_version || ''
}, listenerSearch)
const filteredListeners = useMemo(() => {
return (listeners || []).filter((item: TLSListener) => listenerMatchesSearch(item, listenerSearch))
}, [authPolicies, listenerSearch, listeners])
const listenerCount: number = filteredListeners.length + (mainListenerMatches ? 1 : 0)
const openCreateDialog = () => { const openCreateDialog = () => {
setEditingMain(false) setEditingMain(false)
setEditingID(null) setEditingID(null)
@@ -389,7 +435,19 @@ export default function AdminTLSSettingsPage() {
Admin: Site TLS Admin: Site TLS
</Typography> </Typography>
<SectionCard <SectionCard
title="Listeners" title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0, flexWrap: 'wrap' }}>
<Typography variant="h6">Listeners ({listenerCount})</Typography>
<TextField
size="small"
label="Search"
placeholder="name, address, policy, or TLS setting"
value={listenerSearch}
onChange={(event) => setListenerSearch(event.target.value)}
sx={{ minWidth: 240, maxWidth: 320 }}
/>
</Box>
}
actions={ actions={
<Button variant="outlined" onClick={openCreateDialog}> <Button variant="outlined" onClick={openCreateDialog}>
New Listener New Listener
@@ -403,6 +461,7 @@ export default function AdminTLSSettingsPage() {
</Typography> </Typography>
) : null} ) : null}
<List> <List>
{mainListenerMatches ? (
<ListItem divider sx={{ alignItems: 'flex-start' }}> <ListItem divider sx={{ alignItems: 'flex-start' }}>
<CompactListItemText <CompactListItemText
primary={ primary={
@@ -435,7 +494,8 @@ export default function AdminTLSSettingsPage() {
<ListRowActionButton onClick={openMainEditDialog}>Edit</ListRowActionButton> <ListRowActionButton onClick={openMainEditDialog}>Edit</ListRowActionButton>
</ListRowActions> </ListRowActions>
</ListItem> </ListItem>
{!listenersLoading && (listeners || []).length === 0 ? ( ) : null}
{!listenersLoading && !listenerSearch.trim() && (listeners || []).length === 0 ? (
<ListItem> <ListItem>
<CompactListItemText <CompactListItemText
primary={ primary={
@@ -446,7 +506,18 @@ export default function AdminTLSSettingsPage() {
/> />
</ListItem> </ListItem>
) : null} ) : null}
{(listeners || []).map((item) => ( {!listenersLoading && listenerSearch.trim() && listenerCount === 0 ? (
<ListItem>
<CompactListItemText
primary={
<Typography variant="body2" color="text.secondary">
No matching listeners.
</Typography>
}
/>
</ListItem>
) : null}
{filteredListeners.map((item: TLSListener) => (
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}> <ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
<CompactListItemText <CompactListItemText
primary={ primary={
+2 -2
View File
@@ -65,7 +65,7 @@ export default function AdminUsersPage() {
|| item.username.toLowerCase().includes(normalized) || item.username.toLowerCase().includes(normalized)
|| (item.display_name || '').toLowerCase().includes(normalized) || (item.display_name || '').toLowerCase().includes(normalized)
|| (item.email || '').toLowerCase().includes(normalized) || (item.email || '').toLowerCase().includes(normalized)
|| (item.auth_source || '').toLowerCase().includes(normalized) || (item.auth_provider_id || '').toLowerCase().includes(normalized)
}) })
}, [users, userSearch]) }, [users, userSearch])
@@ -305,7 +305,7 @@ export default function AdminUsersPage() {
} }
secondary={ secondary={
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
{u.is_admin ? 'admin' : 'user'} · source: {u.auth_source || '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 || 'db'}{u.totp_required ? ' · TOTP required' : ''}{u.totp_enabled ? ' · TOTP enabled' : ''}{hasDirectProjectCreate(u.id) ? ' · direct project.create' : ''}
</Typography> </Typography>
} }
/> />
+11 -21
View File
@@ -1,14 +1,13 @@
import { Box, Button, Paper, TextField, Typography } from '@mui/material' import { Box, Button, Paper, TextField, Typography } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { api } from '../api' import { api, PublicAuthProvider } from '../api'
import { useBrand } from '../branding' import { useBrand } from '../branding'
export default function LoginPage() { export default function LoginPage() {
const brand = useBrand() const brand = useBrand()
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [oidcEnabled, setOIDCEnabled] = useState(false) const [oidcProviders, setOIDCProviders] = useState<PublicAuthProvider[]>([])
const [oidcConfigured, setOIDCConfigured] = useState(true)
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [otpCode, setOTPCode] = useState('') const [otpCode, setOTPCode] = useState('')
@@ -17,14 +16,12 @@ export default function LoginPage() {
useEffect(() => { useEffect(() => {
api api
.oidcStatus() .listPublicAuthProviders()
.then((res) => { .then((providers) => {
setOIDCEnabled(Boolean(res.enabled)) setOIDCProviders(providers.filter((p) => p.type === 'oidc'))
setOIDCConfigured(res.configured !== false)
}) })
.catch(() => { .catch(() => {
setOIDCEnabled(false) setOIDCProviders([])
setOIDCConfigured(false)
}) })
}, []) }, [])
@@ -74,27 +71,20 @@ export default function LoginPage() {
<Button type="submit" variant="contained" fullWidth sx={{ mt: 2 }}> <Button type="submit" variant="contained" fullWidth sx={{ mt: 2 }}>
{otpRequired ? 'Verify' : 'Login'} {otpRequired ? 'Verify' : 'Login'}
</Button> </Button>
{oidcEnabled ? ( {oidcProviders.map((p) => (
<>
<Button <Button
key={p.id}
type="button" type="button"
variant="outlined" variant="outlined"
fullWidth fullWidth
sx={{ mt: 1 }} sx={{ mt: 1 }}
onClick={() => { onClick={() => {
window.location.assign('/api/auth/oidc/login') window.location.assign(`/api/auth/oidc/${p.id}/login`)
}} }}
disabled={!oidcConfigured}
> >
Login with OIDC Login with {p.name}
</Button> </Button>
{!oidcConfigured ? ( ))}
<Typography color="warning.main" variant="body2" sx={{ mt: 1 }}>
OIDC is selected but not fully configured.
</Typography>
) : null}
</>
) : null}
</Box> </Box>
</Paper> </Paper>
) )