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 item models.PKICert
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
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`)
} 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
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
ORDER BY c.created_at DESC`)
} 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
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 = ?)
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 item models.PKICert
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
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)
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 {
@@ -305,9 +313,9 @@ func (s *Store) CreatePKICert(item models.PKICert) (models.PKICert, error) {
now = time.Now().UTC().Unix()
item.CreatedAt = now
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)
VALUES (?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM pki_cas WHERE public_id = ?) END, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
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)
_, 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, ?, (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.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 {
return item, err
}
@@ -333,7 +341,8 @@ func (s *Store) ReplacePKICert(item models.PKICert) (models.PKICert, error) {
SET
ca_id = CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM pki_cas WHERE public_id = ?) END,
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 = ?,
issuance_source = ?,
serial_hex = ?,
@@ -351,7 +360,8 @@ func (s *Store) ReplacePKICert(item models.PKICert) (models.PKICert, error) {
WHERE public_id = ?`,
item.CAID, item.CAID,
item.CreatedByKind,
item.CreatedBySubjectID,
item.CreatedBySubjectID, item.CreatedByKind,
item.CreatedBySubjectID, item.CreatedByKind,
item.CreatedBySubjectName,
item.IssuanceSource,
item.SerialHex,
@@ -387,7 +397,7 @@ func (s *Store) CountTLSServerCertReferences(certID string) (int, int, error) {
if err != nil {
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)
if err != nil {
return 0, 0, err
@@ -405,7 +415,7 @@ func (s *Store) CountTLSClientCAReferences(caID string) (int, int, error) {
if err != nil {
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)
if err != nil {
return 0, 0, err
+76 -30
View File
@@ -45,9 +45,11 @@ func (s *Store) ListSSHServers() ([]models.SSHServer, error) {
var item models.SSHServer
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
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`)
if err != nil {
return nil, err
@@ -78,9 +80,11 @@ func (s *Store) GetSSHServer(id string) (models.SSHServer, error) {
var tagsJSON string
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
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))
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 }
@@ -99,11 +103,13 @@ func (s *Store) ListSSHServersForUser(userID string) ([]models.SSHServer, error)
var trimmedUserID string
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
LEFT JOIN users ou ON ou.id = srv.owner_user_id
WHERE srv.owner_scope = 'user' AND ou.public_id = ?
ORDER BY srv.name, srv.host, srv.port`, trimmedUserID)
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.owner_scope = ? AND ou.public_id = ?
ORDER BY srv.name, srv.host, srv.port`, SSHOwnerScopeUser, trimmedUserID)
if err != nil {
return nil, err
}
@@ -203,11 +209,12 @@ func (s *Store) CreateSSHServer(item models.SSHServer) (models.SSHServer, error)
owner_scope,
owner_user_id,
created_by_kind,
created_by_subject_id,
created_by_user_id,
created_by_principal_id,
created_by_subject_name,
created_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,
strings.TrimSpace(item.Name),
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.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,
@@ -305,7 +315,7 @@ func (s *Store) ListSSHAccessProfiles() ([]models.SSHAccessProfile, error) {
COALESCE(g.description, ''),
COALESCE(g.enabled, 0),
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_at, 0),
COALESCE(g.updated_at, 0),
@@ -331,7 +341,7 @@ func (s *Store) ListSSHAccessProfiles() ([]models.SSHAccessProfile, error) {
p.valid_after,
p.valid_before,
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_at,
p.updated_at,
@@ -346,15 +356,21 @@ func (s *Store) ListSSHAccessProfiles() ([]models.SSHAccessProfile, error) {
s.owner_scope,
COALESCE(sou.public_id, ''),
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_at,
s.updated_at
FROM ssh_access_profiles p
JOIN ssh_servers s ON s.id = p.server_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 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 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_credentials c ON c.id = p.ssh_credential_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.enabled, 0),
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_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_before,
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_at,
p.updated_at,
@@ -499,15 +515,21 @@ func (s *Store) GetSSHAccessProfile(id string) (models.SSHAccessProfile, error)
s.owner_scope,
COALESCE(sou.public_id, ''),
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_at,
s.updated_at
FROM ssh_access_profiles p
JOIN ssh_servers s ON s.id = p.server_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 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 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_credentials c ON c.id = p.ssh_credential_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_before,
created_by_kind,
created_by_subject_id,
created_by_user_id,
created_by_principal_id,
created_by_subject_name,
created_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,
strings.TrimSpace(item.ServerID),
strings.TrimSpace(item.ServerTargetType),
@@ -723,6 +746,9 @@ func (s *Store) CreateSSHAccessProfile(item models.SSHAccessProfile) (models.SSH
item.ValidBefore,
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,
@@ -945,7 +971,7 @@ func (s *Store) ListSSHAccessProfilesForUser(userID string) ([]models.SSHAccessP
COALESCE(g.description, ''),
COALESCE(g.enabled, 0),
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_at, 0),
COALESCE(g.updated_at, 0),
@@ -971,7 +997,7 @@ func (s *Store) ListSSHAccessProfilesForUser(userID string) ([]models.SSHAccessP
p.valid_after,
p.valid_before,
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_at,
p.updated_at,
@@ -986,15 +1012,21 @@ func (s *Store) ListSSHAccessProfilesForUser(userID string) ([]models.SSHAccessP
s.owner_scope,
COALESCE(sou.public_id, ''),
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_at,
s.updated_at
FROM ssh_access_profiles p
JOIN ssh_servers s ON s.id = p.server_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 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 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_credentials c ON c.id = p.ssh_credential_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 tu ON t.target_type = 'user' AND tu.id = t.target_id
WHERE (
(p.owner_scope = 'user' AND pou.public_id = ?)
(p.owner_scope = ? AND pou.public_id = ?)
OR
(
p.owner_scope = 'admin_shared'
p.owner_scope = ?
AND p.enabled = 1
AND s.enabled = 1
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 {
return nil, err
}
@@ -1130,7 +1162,7 @@ func (s *Store) GetSSHAccessProfileForUser(userID string, id string) (models.SSH
COALESCE(g.description, ''),
COALESCE(g.enabled, 0),
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_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_before,
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_at,
p.updated_at,
@@ -1171,15 +1203,21 @@ func (s *Store) GetSSHAccessProfileForUser(userID string, id string) (models.SSH
s.owner_scope,
COALESCE(sou.public_id, ''),
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_at,
s.updated_at
FROM ssh_access_profiles p
JOIN ssh_servers s ON s.id = p.server_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 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 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_credentials c ON c.id = p.ssh_credential_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
WHERE p.public_id = ?
AND (
(p.owner_scope = 'user' AND pou.public_id = ?)
(p.owner_scope = ? AND pou.public_id = ?)
OR
(
p.owner_scope = 'admin_shared'
p.owner_scope = ?
AND p.enabled = 1
AND s.enabled = 1
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(
&item.ID,
&item.ServerID,
@@ -1369,7 +1407,11 @@ func (s *Store) GetSSHSecret(id string) (models.SSHSecret, error) {
var item models.SSHSecret
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)
if err != nil {
return item, err
@@ -1988,11 +2030,12 @@ func createSSHSecretTx(tx *sql.Tx, item models.SSHSecret) (models.SSHSecret, err
password,
metadata_json,
created_by_kind,
created_by_subject_id,
created_by_user_id,
created_by_principal_id,
created_by_subject_name,
created_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,
strings.TrimSpace(item.Kind),
item.Payload,
@@ -2000,6 +2043,9 @@ func createSSHSecretTx(tx *sql.Tx, item models.SSHSecret) (models.SSHSecret, err
normalizeJSONText(item.MetadataJSON, "{}"),
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,
+21 -12
View File
@@ -31,12 +31,14 @@ func (s *Store) ListSSHCredentials() ([]models.SSHCredential, error) {
var item models.SSHCredential
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
LEFT JOIN ssh_secrets sec ON sec.id = c.secret_id
LEFT JOIN users u ON u.id = c.owner_user_id
WHERE owner_scope = 'admin'
ORDER BY name`)
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.owner_scope = ?
ORDER BY c.name`, SSHOwnerScopeAdmin)
if err != nil { return nil, err }
defer rows.Close()
for rows.Next() {
@@ -55,12 +57,14 @@ func (s *Store) ListSSHCredentialsForUser(userID string) ([]models.SSHCredential
var item models.SSHCredential
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
LEFT JOIN ssh_secrets sec ON sec.id = c.secret_id
LEFT JOIN users u ON u.id = c.owner_user_id
WHERE c.owner_scope = 'user' AND u.public_id = ?
ORDER BY c.name`, 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.owner_scope = ? AND u.public_id = ?
ORDER BY c.name`, SSHOwnerScopeUser, strings.TrimSpace(userID))
if err != nil { return nil, err }
defer rows.Close()
for rows.Next() {
@@ -78,10 +82,12 @@ func (s *Store) GetSSHCredential(id string) (models.SSHCredential, error) {
var item models.SSHCredential
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
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 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))
err = scanSSHCredential(row, &item)
return item, err
@@ -92,11 +98,13 @@ func (s *Store) GetSSHCredentialForUser(userID string, id string) (models.SSHCre
var item models.SSHCredential
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
LEFT JOIN ssh_secrets sec ON sec.id = c.secret_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)
return item, err
}
@@ -131,8 +139,8 @@ func (s *Store) CreateSSHCredential(item models.SSHCredential, secret models.SSH
item.CreatedAt = now
item.UpdatedAt = now
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)
VALUES (?, ?, ?, ?, (SELECT id FROM ssh_secrets WHERE public_id = ?), ?, ?, ?, ?, (SELECT id FROM users WHERE public_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_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 = ?), ?, (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),
@@ -144,7 +152,8 @@ func (s *Store) CreateSSHCredential(item models.SSHCredential, secret models.SSH
strings.TrimSpace(item.OwnerScope),
strings.TrimSpace(item.OwnerUserID),
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),
item.CreatedAt,
item.UpdatedAt,
+14 -4
View File
@@ -47,7 +47,11 @@ func (s *Store) ListSSHServerGroups() ([]models.SSHServerGroup, error) {
var item models.SSHServerGroup
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 }
defer rows.Close()
for rows.Next() {
@@ -67,7 +71,11 @@ func (s *Store) GetSSHServerGroup(id string) (models.SSHServerGroup, error) {
var item models.SSHServerGroup
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)
if err != nil { return item, err }
item.ServerIDs, err = s.listSSHServerGroupMemberIDs(item.ID)
@@ -82,11 +90,13 @@ func (s *Store) ListSSHServersForGroup(groupID string) ([]models.SSHServer, erro
var tagsJSON string
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
JOIN ssh_servers s ON s.id = gm.server_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 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 = ?
ORDER BY s.name`, strings.TrimSpace(groupID))
if err != nil { return nil, err }
@@ -123,7 +133,7 @@ func (s *Store) CreateSSHServerGroup(item models.SSHServerGroup) (models.SSHServ
item.UpdatedAt = now
tx, owned, err = s.begin()
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 }
err = commitIfOwned(tx, owned)
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()
user.CreatedAt = nowUnix
user.UpdatedAt = nowUnix
if user.AuthSource == "" {
user.AuthSource = "db"
}
_, 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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, user.ID, user.Username, user.DisplayName, user.Email, passwordHash, user.IsAdmin, user.Disabled, user.AuthSource, user.TOTPRequired, now, now)
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 (?, ?, ?, ?, ?, ?, ?, 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.AuthProviderID, user.AuthProviderID, user.TOTPRequired, user.ExternalSubject, now, now)
return user, err
}
@@ -84,8 +81,8 @@ func (s *Store) GetUserByID(id string) (models.User, error) {
var created time.Time
var updated time.Time
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)
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &user.TOTPEnabled, &user.TOTPRequired, &created, &updated)
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.AuthProviderID, &user.TOTPEnabled, &user.TOTPRequired, &created, &updated)
if err != nil {
return user, err
}
@@ -101,8 +98,8 @@ func (s *Store) GetUserByUsername(username string) (models.User, string, error)
var err error
var created 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)
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &passwordHash, &user.TOTPEnabled, &user.TOTPRequired, &created, &updated)
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.AuthProviderID, &passwordHash, &user.TOTPEnabled, &user.TOTPRequired, &created, &updated)
if err != nil {
return user, passwordHash.String, err
}
@@ -118,13 +115,13 @@ func (s *Store) ListUsers() ([]models.User, error) {
var u models.User
var created 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 {
return nil, err
}
defer rows.Close()
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 {
return nil, err
}
@@ -167,243 +164,28 @@ func (s *Store) DeleteUser(id string) error {
return err
}
func (s *Store) GetAuthSettings() (models.AuthSettings, error) {
var settings models.AuthSettings
var rows *sql.Rows
func (s *Store) GetUserByProviderAndSub(providerPublicID string, sub string) (models.User, error) {
var user models.User
var row *sql.Row
var created time.Time
var updated time.Time
var err error
var key string
var value string
rows, err = s.Query(`SELECT key, value FROM app_settings WHERE key IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
"auth.mode",
"auth.oidc.enabled",
"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.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")
row = s.QueryRow(`
SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled,
COALESCE(ap.public_id, ''), u.external_subject, 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 ap.public_id = ? AND u.external_subject = ?
`, strings.TrimSpace(providerPublicID), strings.TrimSpace(sub))
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled,
&user.AuthProviderID, &user.ExternalSubject, &user.TOTPEnabled, &user.TOTPRequired, &created, &updated)
if err != nil {
return settings, err
return user, err
}
defer rows.Close()
for rows.Next() {
err = rows.Scan(&key, &value)
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
user.CreatedAt = created.Unix()
user.UpdatedAt = updated.Unix()
return user, nil
}
func (s *Store) GetTLSSettings() (models.TLSSettings, error) {
@@ -836,16 +618,17 @@ func (s *Store) GetUserByAPIKeyHash(tokenHash string) (models.User, error) {
now = time.Now().UTC()
currentUnix = now.Unix()
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
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 = ?
AND u.disabled = 0
AND k.disabled = 0
AND (k.expires_at = 0 OR k.expires_at > ?)
LIMIT 1
`, 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 {
rollbackIfOwned(tx, owned)
return user, err
@@ -901,7 +684,7 @@ func (s *Store) GetSessionUser(token string) (models.User, time.Time, bool, erro
var created 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, 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 (
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
@@ -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
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 auth_providers ap ON ap.id = u.auth_provider_id
WHERE s.token = ? AND u.disabled = 0
`, 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 {
return user, time.Time{}, false, err
}
+43 -7
View File
@@ -171,7 +171,7 @@ func (s *Store) ListTLSAuthPolicies() ([]models.TLSAuthPolicy, error) {
var allowedFingerprints 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 {
return nil, err
}
@@ -201,7 +201,7 @@ func (s *Store) GetTLSAuthPolicy(id string) (models.TLSAuthPolicy, error) {
var allowedFingerprints 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)
if err != nil {
return item, err
@@ -216,6 +216,10 @@ func (s *Store) CreateTLSAuthPolicy(item models.TLSAuthPolicy) (models.TLSAuthPo
var id string
var now int64
var err error
var result sql.Result
var internalID int64
var i int
var certPublicID string
if item.ID == "" {
id, err = util.NewID()
@@ -227,23 +231,55 @@ func (s *Store) CreateTLSAuthPolicy(item models.TLSAuthPolicy) (models.TLSAuthPo
now = time.Now().UTC().Unix()
item.CreatedAt = 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
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)
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.AllowedCertFingerprints, ","), strings.Join(item.RequiredPermissions, ","), item.PermissionMatch, item.RequiredScope, item.CreatedAt, item.UpdatedAt)
if err != nil {
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
}
func (s *Store) UpdateTLSAuthPolicy(item models.TLSAuthPolicy) error {
var now int64
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()
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 = ?`,
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)
return err
_, 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.AllowedCertFingerprints, ","), strings.Join(item.RequiredPermissions, ","), item.PermissionMatch, item.RequiredScope, item.UpdatedAt, item.ID)
if err != nil {
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 {
+4 -4
View File
@@ -15,7 +15,7 @@ func (s *Store) ListTLSListeners() ([]models.TLSListener, error) {
var httpAddrs string
var httpsAddrs 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 {
return nil, err
}
@@ -44,7 +44,7 @@ func (s *Store) GetTLSListener(id string) (models.TLSListener, error) {
var httpsAddrs string
var endpointPoliciesJSON string
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)
if err != nil {
return item, err
@@ -74,7 +74,7 @@ func (s *Store) CreateTLSListener(item models.TLSListener) (models.TLSListener,
if err != nil {
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)
if err != nil {
return item, err
@@ -92,7 +92,7 @@ func (s *Store) UpdateTLSListener(item models.TLSListener) error {
if err != nil {
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)
return err
}
+97 -295
View File
@@ -161,27 +161,7 @@ type updateMeRequest struct {
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 {
AuthMode string `json:"auth_mode"`
LDAPURL string `json:"ldap_url"`
LDAPBindDN string `json:"ldap_bind_dn"`
LDAPBindPassword string `json:"ldap_bind_password"`
@@ -439,11 +419,11 @@ type projectGroupRoleRequest struct {
}
type userGroupRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Disabled bool `json:"disabled"`
Name string `json:"name"`
Description string `json:"description"`
Disabled bool `json:"disabled"`
TOTPRequired bool `json:"totp_required"`
Scope string `json:"scope"`
Scope string `json:"scope"`
}
type userGroupMemberRequest struct {
@@ -458,7 +438,14 @@ func (api *API) Login(w http.ResponseWriter, r *http.Request, _ map[string]strin
var ldapUser auth.LDAPUser
var newUser 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)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json")
@@ -469,48 +456,80 @@ func (api *API) Login(w http.ResponseWriter, r *http.Request, _ map[string]strin
return
}
// we need to find the user in the database
user, storedHash, err = api.store(r).GetUserByUsername(req.Username)
if err == nil && user.Disabled {
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "user disabled")
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)
return
}
authCfg, _ = api.effectiveAuthConfig(r.Context())
if authCfg.AuthMode == "ldap" || authCfg.AuthMode == "hybrid" {
api.Logger.Write(logIDAuth, codit_logger.LOG_INFO, "ldap login attempt username=%s mode=%s", req.Username, authCfg.AuthMode)
ldapUser, err = auth.LDAPAuthenticateContext(r.Context(), authCfg, req.Username, req.Password)
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")
// try ldap authentication
providers, err = api.store(r).ListEnabledAuthProviders() // gets the enabled providers only
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, fmt.Sprintf("failed to list enabled auth providers - %s", err.Error()))
return
}
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{
Username: ldapUser.Username,
DisplayName: ldapUser.DisplayName,
Email: ldapUser.Email,
AuthProviderID: p.ID,
}
if newUser.Email == "" { newUser.Email = ldapUser.Username + "@ldap" }
created, err = api.store(r).CreateUser(newUser, "")
if err == nil {
user = created
if p.DefaultUserGroupID != "" {
_ = api.store(r).AddUserGroupMember(p.DefaultUserGroupID, user.ID)
}
} else {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if user.ID == "" {
newUser = models.User{
Username: ldapUser.Username,
DisplayName: ldapUser.DisplayName,
Email: ldapUser.Email,
AuthSource: "ldap",
}
if newUser.Email == "" {
newUser.Email = ldapUser.Username + "@local"
}
created, err = api.store(r).CreateUser(newUser, "")
if err == nil {
user = created
if authCfg.LDAPDefaultUserGroupID != "" {
_ = api.store(r).AddUserGroupMember(authCfg.LDAPDefaultUserGroupID, user.ID)
}
}
}
api.finishLogin(w, r, user, req.OTPCode)
return
}
api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "ldap login failed username=%s err=%v", req.Username, err)
api.finishLogin(w, r, user, req.OTPCode)
return
}
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 expires time.Time
var cookie *http.Cookie
token, err = auth.NewSessionToken()
if err != nil {
return
}
if err != nil { return }
expires = auth.SessionExpiry(api.Cfg)
_ = api.store(r).DeleteExpiredSessions(time.Now().UTC())
_ = 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
}
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")
return
}
@@ -674,12 +693,12 @@ func (api *API) CreateUser(w http.ResponseWriter, r *http.Request, _ map[string]
return
}
user = models.User{
Username: req.Username,
DisplayName: req.DisplayName,
Email: req.Email,
IsAdmin: req.IsAdmin,
TOTPRequired: req.TOTPRequired,
AuthSource: "db",
Username: req.Username,
DisplayName: req.DisplayName,
Email: req.Email,
IsAdmin: req.IsAdmin,
TOTPRequired: req.TOTPRequired,
AuthProviderID: db.BuiltinDBProviderPublicID,
}
created, err = api.store(r).CreateUser(user, hash)
if err != nil {
@@ -922,87 +941,11 @@ func (api *API) RemoveUserGroupMember(w http.ResponseWriter, r *http.Request, pa
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) {
var req testLDAPSettingsRequest
var err error
var merged models.AuthSettings
var cfg config.Config
var user auth.LDAPUser
var ldapUser auth.LDAPUser
if !api.requireAdmin(w, r) {
return
}
@@ -1011,42 +954,18 @@ func (api *API) TestLDAPSettings(w http.ResponseWriter, r *http.Request, _ map[s
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json")
return
}
merged, err = api.getMergedAuthSettings(r.Context())
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if strings.TrimSpace(req.AuthMode) != "" {
merged.AuthMode = normalizeAuthMode(req.AuthMode)
}
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)
cfg = api.Cfg
cfg.LDAPURL = strings.TrimSpace(req.LDAPURL)
cfg.LDAPBindDN = strings.TrimSpace(req.LDAPBindDN)
cfg.LDAPBindPassword = req.LDAPBindPassword
cfg.LDAPUserBaseDN = strings.TrimSpace(req.LDAPUserBaseDN)
cfg.LDAPUserFilter = strings.TrimSpace(req.LDAPUserFilter)
if cfg.LDAPUserFilter == "" {
cfg.LDAPUserFilter = "(uid={username})"
}
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)
if err != nil {
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)
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 {
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())
return
}
api.Logger.Write(logIDAuth, codit_logger.LOG_INFO, "ldap test bind success username=%s", user.Username)
WriteJSON(w, http.StatusOK, map[string]string{"status": "ok", "user": 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": ldapUser.Username})
return
}
WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"})
@@ -5532,123 +5451,6 @@ func normalizeProjectHomePage(value string) string {
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) {
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"})
}
+113 -122
View File
@@ -3,7 +3,7 @@ package handlers
import codit_logger "codit/logger"
import "context"
import "crypto/rand"
import "crypto/sha1"
import "crypto/sha256"
import "crypto/tls"
import "database/sql"
import "encoding/base64"
@@ -17,7 +17,7 @@ import "net/url"
import "strings"
import "time"
import "codit/config"
import "codit/internal/db"
import "codit/internal/models"
type oidcTokenResponse struct {
@@ -37,44 +37,46 @@ type oidcErrorResponse struct {
ErrorDescription string `json:"error_description"`
}
func (api *API) OIDCEnabled(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var settings models.AuthSettings
func (api *API) ListPublicAuthProviders(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var providers []models.AuthProvider
var err error
var configured bool
settings, err = api.getMergedAuthSettings(r.Context())
var result []map[string]string
var p models.AuthProvider
providers, err = api.store(r).ListEnabledAuthProviders()
if err != nil {
WriteJSON(w, http.StatusOK, map[string]any{"enabled": false, "configured": false})
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
configured = api.oidcConfiguredFromSettings(settings)
WriteJSON(w, http.StatusOK, map[string]any{
"enabled": settings.OIDCEnabled,
"configured": configured,
})
result = make([]map[string]string, 0, len(providers))
for _, p = range providers {
if p.Type == db.AuthProviderTypeDB {
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) {
var settings models.AuthSettings
var cfg config.Config
func (api *API) OIDCLoginWithProvider(w http.ResponseWriter, r *http.Request, params map[string]string) {
var provider models.AuthProvider
var state string
var err error
var authURL string
settings, err = api.getMergedAuthSettings(r.Context())
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, "failed to load auth settings")
provider, err = api.store(r).GetAuthProvider(params["id"])
if err != nil || provider.Type != db.AuthProviderTypeOIDC {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "oidc provider not found")
return
}
cfg, err = api.effectiveAuthConfig(r.Context())
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, "failed to load auth settings")
if !provider.Enabled {
WriteJSONWithErrorReason(w, r, http.StatusServiceUnavailable, "oidc provider is disabled")
return
}
if !settings.OIDCEnabled {
WriteJSONWithErrorReason(w, r, http.StatusServiceUnavailable, "oidc login is disabled")
return
}
if !api.oidcConfiguredFromSettings(settings) {
WriteJSONWithErrorReason(w, r, http.StatusServiceUnavailable, "oidc is not configured")
if !oidcProviderConfigured(provider) {
WriteJSONWithErrorReason(w, r, http.StatusServiceUnavailable, "oidc provider is not configured")
return
}
state, err = newOIDCState()
@@ -83,21 +85,20 @@ func (api *API) OIDCLogin(w http.ResponseWriter, r *http.Request, _ map[string]s
return
}
http.SetCookie(w, &http.Cookie{
Name: api.oidcStateCookieName(),
Name: oidcStateCookieNameForProvider(api, provider.ID),
Value: state,
HttpOnly: true,
Path: "/api/auth/oidc",
Expires: time.Now().UTC().Add(10 * time.Minute),
SameSite: http.SameSiteLaxMode,
})
authURL = api.buildOIDCAuthorizeURL(cfg, state)
api.Logger.Write(logIDAuth, codit_logger.LOG_INFO, "oidc login redirect authorize_url=%s", cfg.OIDCAuthorizeURL)
authURL = buildOIDCAuthorizeURLFromProvider(provider, state)
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)
}
func (api *API) OIDCCallback(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var settings models.AuthSettings
var cfg config.Config
func (api *API) OIDCCallbackWithProvider(w http.ResponseWriter, r *http.Request, params map[string]string) {
var provider models.AuthProvider
var state string
var code string
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 required bool
var sessionVerified bool
settings, err = api.getMergedAuthSettings(r.Context())
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, "failed to load auth settings")
provider, err = api.store(r).GetAuthProvider(params["id"])
if err != nil || provider.Type != db.AuthProviderTypeOIDC {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "oidc provider not found")
return
}
cfg, err = api.effectiveAuthConfig(r.Context())
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, "failed to load auth settings")
if !provider.Enabled {
WriteJSONWithErrorReason(w, r, http.StatusServiceUnavailable, "oidc provider is disabled")
return
}
if !settings.OIDCEnabled {
WriteJSONWithErrorReason(w, r, http.StatusServiceUnavailable, "oidc login is disabled")
return
}
if !api.oidcConfiguredFromSettings(settings) {
WriteJSONWithErrorReason(w, r, http.StatusServiceUnavailable, "oidc is not configured")
if !oidcProviderConfigured(provider) {
WriteJSONWithErrorReason(w, r, http.StatusServiceUnavailable, "oidc provider is not configured")
return
}
state = strings.TrimSpace(r.URL.Query().Get("state"))
code = strings.TrimSpace(r.URL.Query().Get("code"))
cookie, err = r.Cookie(api.oidcStateCookieName())
api.clearOIDCStateCookie(w)
cookie, err = r.Cookie(oidcStateCookieNameForProvider(api, provider.ID))
clearOIDCStateCookieForProvider(w, api, provider.ID)
if err != nil || cookie.Value == "" || state == "" || state != cookie.Value {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid oidc state")
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")
return
}
token, err = api.oidcExchangeCode(r.Context(), cfg, code)
token, err = api.oidcExchangeCodeFromProvider(r.Context(), provider, code)
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())
return
}
claims, err = api.oidcResolveClaims(r.Context(), cfg, token)
claims, err = api.oidcResolveClaimsFromProvider(r.Context(), provider, token)
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")
return
}
user, err = api.oidcGetOrCreateUser(r.Context(), claims)
user, err = api.oidcGetOrCreateUser(r.Context(), claims, provider)
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")
return
}
@@ -169,59 +165,74 @@ func (api *API) OIDCCallback(w http.ResponseWriter, r *http.Request, _ map[strin
user.TOTPSetupRequired = required && !user.TOTPEnabled
sessionVerified = !required
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)
}
func (api *API) oidcConfiguredFromSettings(settings models.AuthSettings) bool {
if strings.TrimSpace(settings.OIDCClientID) == "" {
func oidcProviderConfigured(p models.AuthProvider) bool {
if strings.TrimSpace(p.OIDCClientID) == "" {
return false
}
if strings.TrimSpace(settings.OIDCClientSecret) == "" {
if strings.TrimSpace(p.OIDCClientSecret) == "" {
return false
}
if strings.TrimSpace(settings.OIDCAuthorizeURL) == "" {
if strings.TrimSpace(p.OIDCAuthorizeURL) == "" {
return false
}
if strings.TrimSpace(settings.OIDCTokenURL) == "" {
if strings.TrimSpace(p.OIDCTokenURL) == "" {
return false
}
if strings.TrimSpace(settings.OIDCRedirectURL) == "" {
if strings.TrimSpace(p.OIDCRedirectURL) == "" {
return false
}
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 scopes string
var endpoint string
values = url.Values{}
values.Set("response_type", "code")
values.Set("client_id", cfg.OIDCClientID)
values.Set("redirect_uri", cfg.OIDCRedirectURL)
scopes = strings.TrimSpace(cfg.OIDCScopes)
values.Set("client_id", p.OIDCClientID)
values.Set("redirect_uri", p.OIDCRedirectURL)
scopes = strings.TrimSpace(p.OIDCScopes)
if scopes == "" {
scopes = "openid profile email"
}
values.Set("scope", scopes)
values.Set("state", state)
endpoint = cfg.OIDCAuthorizeURL
endpoint = p.OIDCAuthorizeURL
if strings.Contains(endpoint, "?") {
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
transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.OIDCTLSInsecureSkipVerify},
TLSClientConfig: &tls.Config{InsecureSkipVerify: p.OIDCTLSInsecureSkipVerify},
}
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 form url.Values
var req *http.Request
@@ -229,14 +240,14 @@ func (api *API) oidcExchangeCode(ctx context.Context, cfg config.Config, code st
var body []byte
var token oidcTokenResponse
var err error
client = api.oidcHTTPClient(cfg)
client = oidcHTTPClientFromProvider(p)
form = url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", code)
form.Set("redirect_uri", cfg.OIDCRedirectURL)
form.Set("client_id", cfg.OIDCClientID)
form.Set("client_secret", cfg.OIDCClientSecret)
req, err = http.NewRequestWithContext(ctx, http.MethodPost, cfg.OIDCTokenURL, strings.NewReader(form.Encode()))
form.Set("redirect_uri", p.OIDCRedirectURL)
form.Set("client_id", p.OIDCClientID)
form.Set("client_secret", p.OIDCClientSecret)
req, err = http.NewRequestWithContext(ctx, http.MethodPost, p.OIDCTokenURL, strings.NewReader(form.Encode()))
if err != nil {
return token, err
}
@@ -287,11 +298,11 @@ func oidcErrorDetail(body []byte) string {
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 err error
if strings.TrimSpace(cfg.OIDCUserInfoURL) != "" && strings.TrimSpace(token.AccessToken) != "" {
claims, err = api.oidcUserInfo(ctx, cfg, token.AccessToken)
if strings.TrimSpace(p.OIDCUserInfoURL) != "" && strings.TrimSpace(token.AccessToken) != "" {
claims, err = api.oidcUserInfoFromProvider(ctx, p, token.AccessToken)
if err == nil {
return claims, nil
}
@@ -306,15 +317,15 @@ func (api *API) oidcResolveClaims(ctx context.Context, cfg config.Config, token
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 req *http.Request
var res *http.Response
var body []byte
var claims oidcUserClaims
var err error
client = api.oidcHTTPClient(cfg)
req, err = http.NewRequestWithContext(ctx, http.MethodGet, cfg.OIDCUserInfoURL, nil)
client = oidcHTTPClientFromProvider(p)
req, err = http.NewRequestWithContext(ctx, http.MethodGet, p.OIDCUserInfoURL, nil)
if err != nil {
return claims, err
}
@@ -342,52 +353,42 @@ func (api *API) oidcUserInfo(ctx context.Context, cfg config.Config, accessToken
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 displayName string
var email string
var user models.User
var hash string
var err error
var created models.User
var authCfg models.AuthSettings
username = oidcUsernameFromSub(claims.Sub)
user, hash, err = api.storeContext(ctx).GetUserByUsername(username)
_ = hash
user, err = api.storeContext(ctx).GetUserByProviderAndSub(provider.ID, claims.Sub)
if err == nil {
if user.Disabled {
return user, errors.New("user disabled")
}
return user, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return user, err
}
if !errors.Is(err, sql.ErrNoRows) { return user, err }
username = oidcUsernameFromProviderAndSub(provider.ID, claims.Sub)
displayName = strings.TrimSpace(claims.Name)
if displayName == "" {
displayName = strings.TrimSpace(claims.PreferredUsername)
}
if displayName == "" {
displayName = username
}
if displayName == "" { displayName = strings.TrimSpace(claims.PreferredUsername) }
if displayName == "" { displayName = username }
email = strings.TrimSpace(claims.Email)
if email == "" {
email = username + "@oidc.local"
}
if email == "" { email = username + "@oidc.local" }
user = models.User{
Username: username,
DisplayName: displayName,
Email: email,
AuthSource: "oidc",
Username: username,
DisplayName: displayName,
Email: email,
AuthProviderID: provider.ID,
ExternalSubject: strings.TrimSpace(claims.Sub),
}
created, err = api.storeContext(ctx).CreateUser(user, "")
if err != nil {
return created, err
}
authCfg, err = api.storeContext(ctx).GetAuthSettings()
if err == nil && authCfg.OIDCDefaultUserGroupID != "" {
_ = api.storeContext(ctx).AddUserGroupMember(authCfg.OIDCDefaultUserGroupID, created.ID)
if err != nil { return created, err }
if provider.DefaultUserGroupID != "" {
_ = api.storeContext(ctx).AddUserGroupMember(provider.DefaultUserGroupID, created.ID)
}
return created, nil
}
@@ -415,10 +416,11 @@ func oidcClaimsFromIDToken(idToken string) (oidcUserClaims, error) {
return claims, nil
}
func oidcUsernameFromSub(sub string) string {
var sum [20]byte
sum = sha1.Sum([]byte(strings.TrimSpace(sub)))
return "oidc-" + hex.EncodeToString(sum[:6])
// Username is a display artifact only; OIDC identity is looked up by (auth_provider_id, external_subject).
func oidcUsernameFromProviderAndSub(providerID string, sub string) string {
var sum [32]byte
sum = sha256.Sum256([]byte(providerID + ":" + strings.TrimSpace(sub)))
return "oidc-" + hex.EncodeToString(sum[:8])
}
func newOIDCState() (string, error) {
@@ -431,14 +433,3 @@ func newOIDCState() (string, error) {
}
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 item models.SSHServerGroup
var err error
if !api.requireAdmin(w, r) { return }
err = DecodeJSON(r, &req)
if err != nil {
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)
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, err = api.store(r).CreateSSHServerGroup(item)
if err != nil {
@@ -1783,12 +1786,20 @@ func (api *API) CreateSSHSessionForSelf(w http.ResponseWriter, r *http.Request,
}
middleware.RegisterAfterCommit(r.Context(), func() {
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{
Password: req.Password,
OTPCode: req.OTPCode,
})
}
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)
}
})
@@ -19,7 +19,7 @@ type meResponse struct {
Email string `json:"email"`
IsAdmin bool `json:"is_admin"`
Disabled bool `json:"disabled"`
AuthSource string `json:"auth_source"`
AuthProviderID string `json:"auth_provider_id"`
TOTPEnabled bool `json:"totp_enabled"`
TOTPRequired bool `json:"totp_required"`
TOTPEffectiveRequired bool `json:"totp_effective_required"`
@@ -179,7 +179,7 @@ func buildMeResponse(user models.User, permissions []string) meResponse {
Email: user.Email,
IsAdmin: user.IsAdmin,
Disabled: user.Disabled,
AuthSource: user.AuthSource,
AuthProviderID: user.AuthProviderID,
TOTPEnabled: user.TOTPEnabled,
TOTPRequired: user.TOTPRequired,
TOTPEffectiveRequired: user.TOTPEffectiveRequired,
+39 -34
View File
@@ -1,19 +1,45 @@
package models
type User struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
IsAdmin bool `json:"is_admin"`
Disabled bool `json:"disabled"`
AuthSource string `json:"auth_source"`
TOTPEnabled bool `json:"totp_enabled"`
TOTPRequired bool `json:"totp_required"`
TOTPEffectiveRequired bool `json:"totp_effective_required"`
TOTPSetupRequired bool `json:"totp_setup_required"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
IsAdmin bool `json:"is_admin"`
Disabled bool `json:"disabled"`
AuthProviderID string `json:"auth_provider_id"`
ExternalSubject string `json:"-"`
TOTPEnabled bool `json:"totp_enabled"`
TOTPRequired bool `json:"totp_required"`
TOTPEffectiveRequired bool `json:"totp_effective_required"`
TOTPSetupRequired bool `json:"totp_setup_required"`
CreatedAt int64 `json:"created_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 {
@@ -247,27 +273,6 @@ type PrincipalAPIKey struct {
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 {
HTTPAddrs []string `json:"http_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 != '';
+18 -21
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("POST", "/api/login", api.Login)
router.Handle("POST", "/api/logout", api.Logout)
router.Handle("GET", "/api/auth/oidc/enabled", api.OIDCEnabled)
router.Handle("GET", "/api/auth/oidc/login", api.OIDCLogin)
router.Handle("GET", "/api/auth/oidc/callback", api.OIDCCallback)
router.Handle("GET", "/api/auth/providers", api.ListPublicAuthProviders)
router.Handle("GET", "/api/auth/oidc/:id/login", api.OIDCLoginWithProvider)
router.Handle("GET", "/api/auth/oidc/:id/callback", api.OIDCCallbackWithProvider)
router.Handle("GET", "/api/me", api.Me)
router.Handle("PATCH", "/api/me", api.UpdateMe)
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("POST", "/api/admin/permissions", api.CreateSubjectPermissions)
router.Handle("DELETE", "/api/admin/permissions/:permission/:subjectType/:subjectID", api.DeleteSubjectPermission)
router.Handle("GET", "/api/admin/auth", api.GetAuthSettings)
router.Handle("PATCH", "/api/admin/auth", api.UpdateAuthSettings)
router.Handle("POST", "/api/admin/auth/test", api.TestLDAPSettings)
router.Handle("GET", "/api/admin/auth-providers", api.ListAuthProviders)
router.Handle("POST", "/api/admin/auth-providers", api.CreateAuthProvider)
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("PATCH", "/api/admin/tls", api.UpdateTLSSettings)
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("POST", "/api/admin/cert-principal-bindings", api.UpsertCertPrincipalBinding)
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/:id", api.GetPKICA)
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/health", middleware.AccessLog(logger, router))
// apiPublicHandler = middleware.WithRequestStore(store,
// middleware.AccessLog(logger,
// middleware.WithStoreTransaction(store,
// middleware.WithUser(store, router))))
// apiPublicHandler bypasses the authentication layer. WithAPIAuth is not called.
apiPublicHandler = middleware.WithUserCookie(store, sessionCookieNameFromServerId(app.serverId), router)
apiPublicHandler = middleware.WithStoreTransaction(store, apiPublicHandler)
apiPublicHandler = middleware.AccessLog(logger, apiPublicHandler)
@@ -978,9 +976,8 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
apiPublicHandler = middleware.WithCors(apiPublicHandler)
mux.Handle("/api/login", apiPublicHandler)
mux.Handle("/api/logout", apiPublicHandler)
mux.Handle("/api/auth/oidc/enabled", apiPublicHandler)
mux.Handle("/api/auth/oidc/login", apiPublicHandler)
mux.Handle("/api/auth/oidc/callback", apiPublicHandler)
mux.Handle("/api/auth/providers", apiPublicHandler)
mux.Handle("/api/auth/oidc/", apiPublicHandler) // for /api/auth/oidc/:id/login and /api/auth/oidc/:id/callback without authentication
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)))
@@ -2457,11 +2454,11 @@ func bootstrapAdmin(store *db.Store, envPrefix string) error {
}
_, err = txStore.CreateUser(models.User{
Username: username,
DisplayName: username,
Email: username + "@local",
IsAdmin: true,
AuthSource: "db",
Username: username,
DisplayName: username,
Email: username + "@local",
IsAdmin: true,
AuthProviderID: db.BuiltinDBProviderPublicID,
}, hash)
if err != nil {
_ = txStore.Rollback()
+27 -19
View File
@@ -4,7 +4,7 @@ export interface User {
display_name: string
email: string
is_admin: boolean
auth_source?: string
auth_provider_id?: string
totp_enabled?: boolean
totp_required?: boolean
totp_effective_required?: boolean
@@ -434,9 +434,11 @@ export interface PrincipalProjectRole {
created_at: number
}
export interface AuthSettings {
auth_mode: 'db' | 'ldap' | 'hybrid'
oidc_enabled: boolean
export interface AuthProvider {
id: string
name: string
type: 'ldap' | 'oidc' | 'db'
enabled: boolean
ldap_url: string
ldap_bind_dn: string
ldap_bind_password: string
@@ -451,8 +453,16 @@ export interface AuthSettings {
oidc_redirect_url: string
oidc_scopes: string
oidc_tls_insecure_skip_verify: boolean
ldap_default_user_group_id: string
oidc_default_user_group_id: string
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 {
@@ -509,10 +519,6 @@ export interface TLSListener {
updated_at: number
}
export interface OIDCStatus {
enabled: boolean
configured?: boolean
}
export interface PKICA {
id: string
@@ -1348,7 +1354,7 @@ async function requestText(path: string, options: RequestInit = {}): Promise<str
}
export const api = {
oidcStatus: () => request<OIDCStatus>('/api/auth/oidc/enabled'),
listPublicAuthProviders: () => request<PublicAuthProvider[]>('/api/auth/providers'),
login: (username: string, password: string, otpCode?: string) =>
request<User>('/api/login', {
method: 'POST',
@@ -1418,14 +1424,16 @@ export const api = {
request<CertPrincipalBinding>('/api/admin/cert-principal-bindings', { method: 'POST', body: JSON.stringify(payload) }),
deleteCertPrincipalBinding: (fingerprint: string) =>
request<void>(`/api/admin/cert-principal-bindings/${encodeURIComponent(fingerprint)}`, { method: 'DELETE' }),
getAuthSettings: () => request<AuthSettings>('/api/admin/auth'),
updateAuthSettings: (payload: AuthSettings) =>
request<AuthSettings>('/api/admin/auth', { method: 'PATCH', body: JSON.stringify(payload) }),
testAuthSettings: (
payload: Partial<AuthSettings> & { username?: string; password?: string },
signal?: AbortSignal
) =>
request<{ status: string; user?: string }>('/api/admin/auth/test', {
listAuthProviders: () => request<AuthProvider[]>('/api/admin/auth-providers'),
getAuthProvider: (id: string) => request<AuthProvider>(`/api/admin/auth-providers/${id}`),
createAuthProvider: (payload: Omit<AuthProvider, 'id' | 'user_count' | 'created_at' | 'updated_at'>) =>
request<AuthProvider>('/api/admin/auth-providers', { method: 'POST', body: JSON.stringify(payload) }),
updateAuthProvider: (id: string, payload: Omit<AuthProvider, 'id' | 'user_count' | 'created_at' | 'updated_at'>) =>
request<AuthProvider>(`/api/admin/auth-providers/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
deleteAuthProvider: (id: string, force?: boolean) =>
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',
body: JSON.stringify(payload),
signal
+1 -2
View File
@@ -111,8 +111,7 @@ export const routes: RouteObject[] = [
{ path: 'admin/principals', element: <AdminServicePrincipalsPage /> },
{ path: 'admin/auth', element: <AdminSiteAuthPage /> },
{ path: 'admin/tls/auth-policies', element: <AdminTLSAuthPoliciesPage /> },
{ path: 'admin/tls', element: <AdminTLSSettingsPage /> },
{ path: 'admin/auth/ldap', element: <AdminSiteAuthPage /> }
{ path: 'admin/tls', element: <AdminTLSSettingsPage /> }
]
},
{ path: '*', element: <NotFoundPage /> }
@@ -30,7 +30,7 @@ export default function UserDetailsDialog(props: UserDetailsDialogProps) {
<TextField label="Email" value={item?.email || '-'} 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="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 Required" value={yesNo(item?.totp_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"
value={password}
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
label="Confirm New Password"
@@ -250,7 +250,7 @@ export default function AccountPage() {
type="password"
value={totpPassword}
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
label="Authenticator Code"
+1 -1
View File
@@ -226,7 +226,7 @@ export default function AdminApiKeysPage() {
<CompactListItemText
primary={
<Typography component="div">
{key.name}({key.prefix})
{key.name} ({key.prefix})
{key.disabled? (<> <Chip size="small" color='error' label='Disabled'/></>): null}
</Typography>
}
@@ -325,7 +325,7 @@ export default function AdminPKIClientProfilesPage() {
<CompactListItemText
primary={
<Typography component="div">
{item.name}({item.id})
{item.name} ({item.id})
{item.enabled? null: (<> <Chip size="small" color='error' label='Disabled'/></>)}
</Typography>
}
@@ -422,7 +422,7 @@ export default function AdminSSHAccessProfilesPage() {
<SectionCard
title={
<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
size="small"
label="Search"
+35 -4
View File
@@ -12,7 +12,7 @@ import List from '@mui/material/List'
import ListItem from '@mui/material/ListItem'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import FormDialogContent from '../components/FormDialogContent'
import HeaderActionButton from '../components/HeaderActionButton'
@@ -56,6 +56,22 @@ export default function AdminSSHCredentialsPage() {
const [viewItem, setViewItem] = useState<SSHCredential | null>(null)
const [deleteItem, setDeleteItem] = useState<SSHCredential | null>(null)
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 () => {
let list: SSHCredential[]
@@ -135,7 +151,19 @@ export default function AdminSSHCredentialsPage() {
<Box>
<Typography variant="h5" sx={{ mb: 2 }}>Admin: SSH Credentials</Typography>
<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={
<HeaderActionButton variant="outlined" startIcon={<AddIcon />} onClick={openCreate}>
Import Key
@@ -144,12 +172,12 @@ export default function AdminSSHCredentialsPage() {
>
<PageAlert message={error} onClose={() => setError(null)} sx={{ mb: 1 }} />
<List>
{items.map((item) => (
{filteredItems.map((item: SSHCredential) => (
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
<CompactListItemText
primary={
<Typography component="div">
{item.name}({item.id})
{item.name} ({item.id})
{item.enabled? null: (<> <Chip size="small" color='error' label='Disabled'/></>)}
</Typography>
}
@@ -174,6 +202,9 @@ export default function AdminSSHCredentialsPage() {
</ListRowActions>
</ListItem>
))}
{filteredItems.length === 0 ? (
<Typography variant="body2" color="text.secondary">{items.length ? 'No matching SSH credentials.' : 'No SSH credentials found.'}</Typography>
) : null}
</List>
</SectionCard>
@@ -14,7 +14,7 @@ import {
TextField,
Typography
} from '@mui/material'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
@@ -22,10 +22,16 @@ import SectionCard from '../components/SectionCard'
import SSHPrincipalGrantDetailsDialog from '../components/SSHPrincipalGrantDetailsDialog'
import SSHPrincipalGrantFormDialog, { SSHPrincipalGrantFormState } from '../components/SSHPrincipalGrantFormDialog'
import { api, SSHPrincipalGrant, SSHPrincipalGrantUpsertPayload, User, UserGroup } from '../api'
import Autocomplete from '../components/Autocomplete'
import SelectField from '../components/SelectField'
import CompactListItemText from '../components/CompactListItemText'
import PageAlert from '../components/PageAlert'
type TargetOption = {
id: string
label: string
}
function fmt(value: number): string {
if (!value || value <= 0) {
return '-'
@@ -79,6 +85,7 @@ export default function AdminSSHPrincipalGrantsPage() {
const [filterTargetType, setFilterTargetType] = useState<'user' | 'group' | ''>('')
const [filterTargetID, setFilterTargetID] = useState('')
const [search, setSearch] = useState('')
const [viewItem, setViewItem] = useState<SSHPrincipalGrant | null>(null)
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,
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,
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 (
<Box>
<Typography variant="h5" sx={{ mb: 2 }}>Admin: SSH Principal Grants</Typography>
<SectionCard
title={
<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
select
size="small"
@@ -265,34 +296,30 @@ export default function AdminSSHPrincipalGrantsPage() {
<MenuItem value="group">Group</MenuItem>
</SelectField>
{filterTargetType === 'user' ? (
<SelectField
select
<Autocomplete<TargetOption, false, false, false>
size="small"
label="User"
value={filterTargetID}
onChange={(event) => setFilterTargetID(event.target.value)}
options={userOptions}
value={userOptions.find((item: TargetOption) => item.id === filterTargetID) || null}
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 }}
>
<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}
{filterTargetType === 'group' ? (
<SelectField
select
<Autocomplete<TargetOption, false, false, false>
size="small"
label="Group"
value={filterTargetID}
onChange={(event) => setFilterTargetID(event.target.value)}
options={groupOptions}
value={groupOptions.find((item: TargetOption) => item.id === filterTargetID) || null}
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 }}
>
<MenuItem value="">(all groups)</MenuItem>
{groups.map((item) => (
<MenuItem key={item.id} value={item.id}>{item.name}</MenuItem>
))}
</SelectField>
/>
) : null}
</Box>
}
@@ -304,10 +331,10 @@ export default function AdminSSHPrincipalGrantsPage() {
>
<PageAlert message={error} onClose={() => setError(null)} sx={{ mb: 1 }} />
{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 ? (
<List dense>
{items.map((item) => (
{filteredItems.map((item: SSHPrincipalGrant) => (
<ListItem key={item.id} divider sx={{ alignItems: 'flex-start' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, width: '100%', minWidth: 0 }}>
<CompactListItemText
+36 -29
View File
@@ -178,35 +178,42 @@ export default function AdminSSHSignHistoryPage() {
<Box>
<Typography variant="h5" sx={{ mb: 2 }}>Admin: SSH Signing History</Typography>
<PageAlert message={error} onClose={() => setError(null)} sx={{ mb: 1 }} />
<SectionCard title="SSH Signing History">
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Autocomplete<SSHUserCA, false, false, false>
size="small"
options={cas}
value={cas.find((item: SSHUserCA) => item.id === caID) || null}
onChange={(_event, value: SSHUserCA | null) => {
const nextCAID: string = value?.id || ''
setCAID(nextCAID)
load(nextCAID).catch(() => {})
}}
getOptionLabel={(item: SSHUserCA) => `${item.name} (${item.id})`}
isOptionEqualToValue={(option: SSHUserCA, value: SSHUserCA) => option.id === value.id}
noOptionsText="No SSH CAs"
renderInput={(params) => <TextField {...params} label="CA" placeholder="(all)" />}
sx={{ minWidth: 400 }}
/>
<TextField
size="small"
label="Limit"
value={limitText}
onChange={(event) => setLimitText(event.target.value)}
sx={{ width: 120 }}
/>
<Button variant="outlined" onClick={() => load().catch(() => {})} disabled={loading}>
{loading ? 'Loading...' : 'Refresh'}
</Button>
</Box>
<SectionCard
title={
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="h6">SSH Signing History</Typography>
<Autocomplete<SSHUserCA, false, false, false>
size="small"
options={cas}
value={cas.find((item: SSHUserCA) => item.id === caID) || null}
onChange={(_event, value: SSHUserCA | null) => {
const nextCAID: string = value?.id || ''
setCAID(nextCAID)
load(nextCAID).catch(() => {})
}}
getOptionLabel={(item: SSHUserCA) => `${item.name} (${item.id})`}
isOptionEqualToValue={(option: SSHUserCA, value: SSHUserCA) => option.id === value.id}
noOptionsText="No SSH CAs"
renderInput={(params) => <TextField {...params} label="CA" placeholder="(all)" />}
sx={{ minWidth: 400 }}
/>
</Box>
}
actions={
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<TextField
size="small"
label="Limit"
value={limitText}
onChange={(event) => setLimitText(event.target.value)}
sx={{ width: 120 }}
/>
<Button variant="outlined" onClick={() => load().catch(() => {})} disabled={loading}>
{loading ? 'Loading...' : 'Refresh'}
</Button>
</Box>
}
>
{!loading && items.length === 0 ? <Typography variant="body2" color="text.secondary">No signing history found.</Typography> : null}
<List>
{items.map((item: SSHUserCAIssuance) => (
+1 -1
View File
@@ -355,7 +355,7 @@ export default function AdminSSHUserCAPage() {
<CompactListItemText
primary={
<Typography component="div">
{item.name}({item.id})
{item.name} ({item.id})
{item.enabled? null: (<> <Chip size="small" color='error' label='Disabled'/></>)}
</Typography>
}
@@ -792,17 +792,20 @@ export default function AdminServicePrincipalsPage() {
<ListItem key={item.fingerprint} divider sx={{ alignItems: 'flex-start' }}>
<CompactListItemText
primary={
pkiCertByFingerprint(item.fingerprint) ? (
<Link
underline="hover"
onClick={() => void openCertView(pkiCertByFingerprint(item.fingerprint)?.id || '')}
sx={{ cursor: 'default' }}
>
{pkiCertByFingerprint(item.fingerprint)?.common_name || pkiCertByFingerprint(item.fingerprint)?.serial_hex} ({pkiCertByFingerprint(item.fingerprint)?.id})
</Link>
) : (
<Typography>{item.fingerprint}</Typography>
)
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0, flexWrap: 'wrap' }}>
{pkiCertByFingerprint(item.fingerprint) ? (
<Link
underline="hover"
onClick={() => void openCertView(pkiCertByFingerprint(item.fingerprint)?.id || '')}
sx={{ cursor: 'default' }}
>
{pkiCertByFingerprint(item.fingerprint)?.common_name || pkiCertByFingerprint(item.fingerprint)?.serial_hex} ({pkiCertByFingerprint(item.fingerprint)?.id})
</Link>
) : (
<Typography>{item.fingerprint}</Typography>
)}
{item.enabled ? null : <Chip size="small" color="error" label="Disabled" />}
</Box>
}
secondary={
<Box sx={{ display: 'grid' }}>
+476 -201
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 ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
import { useEffect, useRef, useState } from 'react'
import PageAlert from '../components/PageAlert'
import { api, AuthSettings, UserGroup } from '../api'
import { useEffect, useMemo, useRef, useState } from 'react'
import CompactListItemText from '../components/CompactListItemText'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import FormDialogContent from '../components/FormDialogContent'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard'
import SelectField from '../components/SelectField'
import PageAlert from '../components/PageAlert'
import Autocomplete from '../components/Autocomplete'
import { api, AuthProvider, UserGroup } from '../api'
export default function AdminSiteAuthPage() {
const [settings, setSettings] = useState<AuthSettings>({
auth_mode: 'db',
oidc_enabled: false,
type ProviderFormState = {
name: string
type: 'ldap' | 'oidc' | 'db'
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_bind_dn: '',
ldap_bind_password: '',
@@ -26,71 +65,164 @@ export default function AdminSiteAuthPage() {
oidc_redirect_url: '',
oidc_scopes: 'openid profile email',
oidc_tls_insecure_skip_verify: false,
ldap_default_user_group_id: '',
oidc_default_user_group_id: ''
})
default_user_group_id: ''
}
}
export default function AdminSiteAuthPage() {
// Providers list
const [providers, setProviders] = useState<AuthProvider[]>([])
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)
// 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 [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
const [testError, setTestError] = useState<string | null>(null)
const [testResult, setTestResult] = useState<string | null>(null)
const [testUsername, setTestUsername] = useState('')
const [testPassword, setTestPassword] = useState('')
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(() => {
setLoading(true)
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))
loadProviders()
}, [])
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)
setError(null)
setSaved(false)
setDialogError(null)
try {
const updated = await api.updateAuthSettings(settings)
setSettings(updated)
setSaved(true)
if (editingProvider) {
await api.updateAuthProvider(editingProvider.id, form)
} else {
await api.createAuthProvider(form)
}
setDialogOpen(false)
await loadProviders()
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save authentication settings'
setError(message)
setDialogError(err instanceof Error ? err.message : 'Failed to save auth provider')
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (!deleteItem) return
setDeleting(true)
setDeleteError(null)
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 () => {
let controller: AbortController
if (!editingProvider) return
if (testing) {
if (testControllerRef.current) {
testControllerRef.current.abort()
}
if (testControllerRef.current) testControllerRef.current.abort()
return
}
controller = new AbortController()
const controller = new AbortController()
testControllerRef.current = controller
setTesting(true)
setTestError(null)
setTestResult(null)
try {
const result = await api.testAuthSettings(
{
...settings,
username: testUsername.trim() || undefined,
password: testPassword || undefined
},
const result = await api.testAuthProvider(
editingProvider.id,
{ username: testUsername.trim() || undefined, password: testPassword || undefined },
controller.signal
)
setTestResult(result.user ? `Connection ok. User test ok: ${result.user}` : 'Connection ok.')
@@ -99,180 +231,323 @@ export default function AdminSiteAuthPage() {
setTestResult('LDAP test canceled.')
return
}
const message = err instanceof Error ? err.message : 'LDAP test failed'
setTestError(message)
setTestError(err instanceof Error ? err.message : 'LDAP test failed')
} finally {
setTesting(false)
testControllerRef.current = null
}
}
useEffect(() => {
return () => {
if (testControllerRef.current) {
testControllerRef.current.abort()
}
}
}, [])
const testSubtitle = testError ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: 'error.main' }}>
<ErrorOutlineIcon fontSize="small" />
<Typography variant="body2" color="inherit">
{testError}
</Typography>
</Box>
) : testResult ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: 'success.main' }}>
<CheckCircleOutlineIcon fontSize="small" />
<Typography variant="body2" color="inherit">
{testResult}
</Typography>
</Box>
) : 'Optional user bind'
const isLDAP = form.type === 'ldap'
const isOIDC = form.type === 'oidc'
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}
<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">
<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={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
label="Client Secret"
type="password"
value={settings.oidc_client_secret}
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_client_secret: event.target.value }))}
/>
<TextField
label="Authorize URL"
value={settings.oidc_authorize_url}
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_authorize_url: event.target.value }))}
/>
<TextField
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)"
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 === settings.oidc_default_user_group_id) || null}
onChange={(_event, value) => setSettings((prev) => ({ ...prev, oidc_default_user_group_id: value?.id || '' }))}
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 for new OIDC users" helperText="Automatically added to this group on first login." />}
renderInput={(params) => (
<TextField
{...params}
label="Default User Group"
helperText="New users from this provider are added to this group automatically."
/>
)}
/>
</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'}
</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>
{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' }}>
<ErrorOutlineIcon fontSize="small" />
<Typography variant="body2" color="inherit">{testError}</Typography>
</Box>
) : testResult ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: 'success.main' }}>
<CheckCircleOutlineIcon fontSize="small" />
<Typography variant="body2" color="inherit">{testResult}</Typography>
</Box>
) : null}
<TextField
label="Test Username (optional)"
size="small"
value={testUsername}
onChange={(e) => setTestUsername(e.target.value)}
/>
<TextField
label="Test Password (optional)"
type="password"
size="small"
value={testPassword}
onChange={(e) => setTestPassword(e.target.value)}
/>
<Box>
<Button
variant="outlined"
size="small"
onClick={handleTest}
color={testing ? 'warning' : 'primary'}
>
{testing ? 'Cancel Test' : 'Test Connection'}
</Button>
</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
title={
<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
size="small"
label="Search"
+74 -3
View File
@@ -61,6 +61,7 @@ export default function AdminTLSSettingsPage() {
const [error, setError] = useState<string | null>(null)
const [listeners, setListeners] = useState<TLSListener[]>([])
const [listenersLoading, setListenersLoading] = useState(false)
const [listenerSearch, setListenerSearch] = useState('')
const [dialogOpen, setDialogOpen] = useState(false)
const [editingMain, setEditingMain] = useState(false)
const [editingID, setEditingID] = useState<string | null>(null)
@@ -189,6 +190,51 @@ export default function AdminTLSSettingsPage() {
.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 = () => {
setEditingMain(false)
setEditingID(null)
@@ -389,7 +435,19 @@ export default function AdminTLSSettingsPage() {
Admin: Site TLS
</Typography>
<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={
<Button variant="outlined" onClick={openCreateDialog}>
New Listener
@@ -403,6 +461,7 @@ export default function AdminTLSSettingsPage() {
</Typography>
) : null}
<List>
{mainListenerMatches ? (
<ListItem divider sx={{ alignItems: 'flex-start' }}>
<CompactListItemText
primary={
@@ -435,7 +494,8 @@ export default function AdminTLSSettingsPage() {
<ListRowActionButton onClick={openMainEditDialog}>Edit</ListRowActionButton>
</ListRowActions>
</ListItem>
{!listenersLoading && (listeners || []).length === 0 ? (
) : null}
{!listenersLoading && !listenerSearch.trim() && (listeners || []).length === 0 ? (
<ListItem>
<CompactListItemText
primary={
@@ -446,7 +506,18 @@ export default function AdminTLSSettingsPage() {
/>
</ListItem>
) : 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' }}>
<CompactListItemText
primary={
+3 -3
View File
@@ -65,7 +65,7 @@ export default function AdminUsersPage() {
|| item.username.toLowerCase().includes(normalized)
|| (item.display_name || '').toLowerCase().includes(normalized)
|| (item.email || '').toLowerCase().includes(normalized)
|| (item.auth_source || '').toLowerCase().includes(normalized)
|| (item.auth_provider_id || '').toLowerCase().includes(normalized)
})
}, [users, userSearch])
@@ -299,13 +299,13 @@ export default function AdminUsersPage() {
<CompactListItemText
primary={
<Typography component="div">
{u.display_name || u.username}({u.username})
{u.display_name || u.username} ({u.username})
{u.disabled? (<> <Chip size="small" color='error' label='Disabled'/></>): null}
</Typography>
}
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>
}
/>
+1 -1
View File
@@ -226,7 +226,7 @@ export default function ApiKeysPage() {
<CompactListItemText
primary={
<Typography component="div">
{key.name}({key.prefix})
{key.name} ({key.prefix})
{key.disabled? (<> <Chip size="small" color='error' label='Disabled'/></>): null}
</Typography>
}
+20 -30
View File
@@ -1,14 +1,13 @@
import { Box, Button, Paper, TextField, Typography } from '@mui/material'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api'
import { api, PublicAuthProvider } from '../api'
import { useBrand } from '../branding'
export default function LoginPage() {
const brand = useBrand()
const [error, setError] = useState<string | null>(null)
const [oidcEnabled, setOIDCEnabled] = useState(false)
const [oidcConfigured, setOIDCConfigured] = useState(true)
const [oidcProviders, setOIDCProviders] = useState<PublicAuthProvider[]>([])
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [otpCode, setOTPCode] = useState('')
@@ -17,14 +16,12 @@ export default function LoginPage() {
useEffect(() => {
api
.oidcStatus()
.then((res) => {
setOIDCEnabled(Boolean(res.enabled))
setOIDCConfigured(res.configured !== false)
.listPublicAuthProviders()
.then((providers) => {
setOIDCProviders(providers.filter((p) => p.type === 'oidc'))
})
.catch(() => {
setOIDCEnabled(false)
setOIDCConfigured(false)
setOIDCProviders([])
})
}, [])
@@ -74,27 +71,20 @@ export default function LoginPage() {
<Button type="submit" variant="contained" fullWidth sx={{ mt: 2 }}>
{otpRequired ? 'Verify' : 'Login'}
</Button>
{oidcEnabled ? (
<>
<Button
type="button"
variant="outlined"
fullWidth
sx={{ mt: 1 }}
onClick={() => {
window.location.assign('/api/auth/oidc/login')
}}
disabled={!oidcConfigured}
>
Login with OIDC
</Button>
{!oidcConfigured ? (
<Typography color="warning.main" variant="body2" sx={{ mt: 1 }}>
OIDC is selected but not fully configured.
</Typography>
) : null}
</>
) : null}
{oidcProviders.map((p) => (
<Button
key={p.id}
type="button"
variant="outlined"
fullWidth
sx={{ mt: 1 }}
onClick={() => {
window.location.assign(`/api/auth/oidc/${p.id}/login`)
}}
>
Login with {p.name}
</Button>
))}
</Box>
</Paper>
)