Compare commits

..

5 Commits

Author SHA1 Message Date
bf80ad9d61 added import of foreign certificates 2026-02-16 11:20:01 +09:00
ad12690d33 service principal enhancement 2026-02-15 19:02:07 +09:00
007987869d primitive service principal and binding to certificate 2026-02-15 17:44:45 +09:00
484e96f407 multiple authentication and policy options for a listener defintion 2026-02-15 17:27:27 +09:00
7a84045f33 added pki management
added listener management
2026-02-15 16:18:53 +09:00
24 changed files with 6133 additions and 87 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,8 @@ import "time"
import "strconv" import "strconv"
type Config struct { type Config struct {
HTTPAddr string `json:"http_addr"` HTTPAddrs []string `json:"http_addrs"`
HTTPSAddrs []string `json:"https_addrs"`
PublicBaseURL string `json:"public_base_url"` PublicBaseURL string `json:"public_base_url"`
DataDir string `json:"data_dir"` DataDir string `json:"data_dir"`
DBDriver string `json:"db_driver"` DBDriver string `json:"db_driver"`
@@ -30,6 +31,14 @@ type Config struct {
OIDCScopes string `json:"oidc_scopes"` OIDCScopes string `json:"oidc_scopes"`
OIDCEnabled bool `json:"oidc_enabled"` OIDCEnabled bool `json:"oidc_enabled"`
OIDCTLSInsecureSkipVerify bool `json:"oidc_tls_insecure_skip_verify"` OIDCTLSInsecureSkipVerify bool `json:"oidc_tls_insecure_skip_verify"`
TLSServerCertSource string `json:"tls_server_cert_source"`
TLSCertFile string `json:"tls_cert_file"`
TLSKeyFile string `json:"tls_key_file"`
TLSPKIServerCertID string `json:"tls_pki_server_cert_id"`
TLSClientAuth string `json:"tls_client_auth"`
TLSClientCAFile string `json:"tls_client_ca_file"`
TLSPKIClientCAID string `json:"tls_pki_client_ca_id"`
TLSMinVersion string `json:"tls_min_version"`
GitHTTPPrefix string `json:"git_http_prefix"` GitHTTPPrefix string `json:"git_http_prefix"`
RPMHTTPPrefix string `json:"rpm_http_prefix"` RPMHTTPPrefix string `json:"rpm_http_prefix"`
} }
@@ -39,7 +48,8 @@ func Load(path string) (Config, error) {
var data []byte var data []byte
var err error var err error
cfg = Config{ cfg = Config{
HTTPAddr: ":1080", HTTPAddrs: []string{":1080"},
HTTPSAddrs: []string{},
DataDir: "./codit-data", DataDir: "./codit-data",
DBDriver: "sqlite", DBDriver: "sqlite",
DBDSN: "file:./codit-data/codit.db?_pragma=foreign_keys(1)", DBDSN: "file:./codit-data/codit.db?_pragma=foreign_keys(1)",
@@ -47,6 +57,9 @@ func Load(path string) (Config, error) {
AuthMode: "db", AuthMode: "db",
LDAPUserFilter: "(uid={username})", LDAPUserFilter: "(uid={username})",
OIDCScopes: "openid profile email", OIDCScopes: "openid profile email",
TLSServerCertSource: "files",
TLSClientAuth: "none",
TLSMinVersion: "1.2",
GitHTTPPrefix: "/git", GitHTTPPrefix: "/git",
RPMHTTPPrefix: "/rpm", RPMHTTPPrefix: "/rpm",
} }
@@ -62,6 +75,13 @@ func Load(path string) (Config, error) {
} }
override(&cfg) override(&cfg)
cfg.AuthMode = strings.ToLower(strings.TrimSpace(cfg.AuthMode)) cfg.AuthMode = strings.ToLower(strings.TrimSpace(cfg.AuthMode))
cfg.TLSServerCertSource = strings.ToLower(strings.TrimSpace(cfg.TLSServerCertSource))
cfg.TLSClientAuth = strings.ToLower(strings.TrimSpace(cfg.TLSClientAuth))
cfg.HTTPAddrs = normalizeHTTPAddrs(cfg.HTTPAddrs)
cfg.HTTPSAddrs = normalizeHTTPAddrs(cfg.HTTPSAddrs)
if len(cfg.HTTPAddrs) == 0 && len(cfg.HTTPSAddrs) == 0 {
return cfg, errors.New("http_addrs or https_addrs is required")
}
if cfg.DBDSN == "" { if cfg.DBDSN == "" {
return cfg, errors.New("db dsn is required") return cfg, errors.New("db dsn is required")
} }
@@ -70,9 +90,13 @@ func Load(path string) (Config, error) {
func override(cfg *Config) { func override(cfg *Config) {
var v string var v string
v = os.Getenv("CODIT_HTTP_ADDR") v = os.Getenv("CODIT_HTTP_ADDRS")
if v != "" { if v != "" {
cfg.HTTPAddr = v cfg.HTTPAddrs = splitCSV(v)
}
v = os.Getenv("CODIT_HTTPS_ADDRS")
if v != "" {
cfg.HTTPSAddrs = splitCSV(v)
} }
v = os.Getenv("CODIT_PUBLIC_BASE_URL") v = os.Getenv("CODIT_PUBLIC_BASE_URL")
if v != "" { if v != "" {
@@ -154,6 +178,38 @@ func override(cfg *Config) {
if v != "" { if v != "" {
cfg.OIDCTLSInsecureSkipVerify = parseEnvBool(v) cfg.OIDCTLSInsecureSkipVerify = parseEnvBool(v)
} }
v = os.Getenv("CODIT_TLS_SERVER_CERT_SOURCE")
if v != "" {
cfg.TLSServerCertSource = v
}
v = os.Getenv("CODIT_TLS_CERT_FILE")
if v != "" {
cfg.TLSCertFile = v
}
v = os.Getenv("CODIT_TLS_KEY_FILE")
if v != "" {
cfg.TLSKeyFile = v
}
v = os.Getenv("CODIT_TLS_PKI_SERVER_CERT_ID")
if v != "" {
cfg.TLSPKIServerCertID = v
}
v = os.Getenv("CODIT_TLS_CLIENT_AUTH")
if v != "" {
cfg.TLSClientAuth = v
}
v = os.Getenv("CODIT_TLS_CLIENT_CA_FILE")
if v != "" {
cfg.TLSClientCAFile = v
}
v = os.Getenv("CODIT_TLS_PKI_CLIENT_CA_ID")
if v != "" {
cfg.TLSPKIClientCAID = v
}
v = os.Getenv("CODIT_TLS_MIN_VERSION")
if v != "" {
cfg.TLSMinVersion = v
}
v = os.Getenv("CODIT_GIT_HTTP_PREFIX") v = os.Getenv("CODIT_GIT_HTTP_PREFIX")
if v != "" { if v != "" {
cfg.GitHTTPPrefix = v cfg.GitHTTPPrefix = v
@@ -209,3 +265,33 @@ func parseEnvBool(v string) bool {
} }
return false return false
} }
func splitCSV(v string) []string {
var parts []string
var out []string
var i int
var p string
parts = strings.Split(v, ",")
for i = 0; i < len(parts); i++ {
p = strings.TrimSpace(parts[i])
if p == "" {
continue
}
out = append(out, p)
}
return out
}
func normalizeHTTPAddrs(values []string) []string {
var out []string
var i int
var v string
for i = 0; i < len(values); i++ {
v = strings.TrimSpace(values[i])
if v == "" {
continue
}
out = append(out, v)
}
return out
}

337
backend/internal/db/pki.go Normal file
View File

@@ -0,0 +1,337 @@
package db
import "database/sql"
import "strings"
import "time"
import "codit/internal/models"
import "codit/internal/util"
func (s *Store) ListPKICAs() ([]models.PKICA, error) {
var rows *sql.Rows
var err error
var items []models.PKICA
var item models.PKICA
rows, err = s.DB.Query(`SELECT id, name, COALESCE(parent_ca_id, ''), is_root, cert_pem, key_pem, serial_counter, status, created_at, updated_at FROM pki_cas ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
err = rows.Scan(&item.ID, &item.Name, &item.ParentCAID, &item.IsRoot, &item.CertPEM, &item.KeyPEM, &item.SerialCounter, &item.Status, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, err
}
items = append(items, item)
}
err = rows.Err()
if err != nil {
return nil, err
}
return items, nil
}
func (s *Store) GetPKICA(id string) (models.PKICA, error) {
var row *sql.Row
var item models.PKICA
var err error
row = s.DB.QueryRow(`SELECT id, name, COALESCE(parent_ca_id, ''), is_root, cert_pem, key_pem, serial_counter, status, created_at, updated_at FROM pki_cas WHERE id = ?`, id)
err = row.Scan(&item.ID, &item.Name, &item.ParentCAID, &item.IsRoot, &item.CertPEM, &item.KeyPEM, &item.SerialCounter, &item.Status, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return item, err
}
return item, nil
}
func (s *Store) UpdatePKICAName(id string, name string) error {
var err error
_, err = s.DB.Exec(`UPDATE pki_cas SET name = ?, updated_at = ? WHERE id = ?`, name, time.Now().UTC().Unix(), id)
return err
}
func (s *Store) CreatePKICA(item models.PKICA) (models.PKICA, error) {
var id string
var now int64
var parentValue any
var err error
if item.ID == "" {
id, err = util.NewID()
if err != nil {
return item, err
}
item.ID = id
}
if item.SerialCounter <= 0 {
item.SerialCounter = 1
}
if item.Status == "" {
item.Status = "active"
}
now = time.Now().UTC().Unix()
item.CreatedAt = now
item.UpdatedAt = now
parentValue = nil
if item.ParentCAID != "" {
parentValue = item.ParentCAID
}
_, err = s.DB.Exec(`INSERT INTO pki_cas (id, name, parent_ca_id, is_root, cert_pem, key_pem, serial_counter, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
item.ID, item.Name, parentValue, item.IsRoot, item.CertPEM, item.KeyPEM, item.SerialCounter, item.Status, item.CreatedAt, item.UpdatedAt)
if err != nil {
return item, err
}
return item, nil
}
func (s *Store) CountPKICAChildren(id string) (int, error) {
var row *sql.Row
var count int
var err error
row = s.DB.QueryRow(`SELECT COUNT(*) FROM pki_cas WHERE parent_ca_id = ?`, id)
err = row.Scan(&count)
if err != nil {
return 0, err
}
return count, nil
}
func (s *Store) CountPKICertsByCA(id string) (int, error) {
var row *sql.Row
var count int
var err error
row = s.DB.QueryRow(`SELECT COUNT(*) FROM pki_certs WHERE ca_id = ?`, id)
err = row.Scan(&count)
if err != nil {
return 0, err
}
return count, nil
}
func (s *Store) DeletePKICA(id string) error {
var err error
_, err = s.DB.Exec(`DELETE FROM pki_cas WHERE id = ?`, id)
return err
}
func (s *Store) DeletePKICASubtree(id string) error {
var tx *sql.Tx
var rows *sql.Rows
var err error
var itemID string
var parentID sql.NullString
var parentByID map[string]string
var pending []string
var current string
var i int
var j int
var target string
var toDelete []string
var contains bool
parentByID = map[string]string{}
tx, err = s.DB.Begin()
if err != nil {
return err
}
rows, err = tx.Query(`SELECT id, parent_ca_id FROM pki_cas`)
if err != nil {
_ = tx.Rollback()
return err
}
defer rows.Close()
for rows.Next() {
err = rows.Scan(&itemID, &parentID)
if err != nil {
_ = tx.Rollback()
return err
}
if parentID.Valid {
parentByID[itemID] = parentID.String
} else {
parentByID[itemID] = ""
}
}
err = rows.Err()
if err != nil {
_ = tx.Rollback()
return err
}
pending = append(pending, id)
for len(pending) > 0 {
current = pending[0]
pending = pending[1:]
contains = false
for i = 0; i < len(toDelete); i++ {
if toDelete[i] == current {
contains = true
break
}
}
if !contains {
toDelete = append(toDelete, current)
}
for target = range parentByID {
if parentByID[target] == current {
pending = append(pending, target)
}
}
}
for i = len(toDelete) - 1; i >= 0; i-- {
j = i
_ = j
_, err = tx.Exec(`DELETE FROM pki_cas WHERE id = ?`, toDelete[i])
if err != nil {
_ = tx.Rollback()
return err
}
}
err = tx.Commit()
if err != nil {
return err
}
return nil
}
func (s *Store) NextPKICASerial(caID string) (int64, error) {
var tx *sql.Tx
var err error
var row *sql.Row
var serial int64
tx, err = s.DB.Begin()
if err != nil {
return 0, err
}
row = tx.QueryRow(`SELECT serial_counter FROM pki_cas WHERE id = ?`, caID)
err = row.Scan(&serial)
if err != nil {
_ = tx.Rollback()
return 0, err
}
_, err = tx.Exec(`UPDATE pki_cas SET serial_counter = ?, updated_at = ? WHERE id = ?`, serial+1, time.Now().UTC().Unix(), caID)
if err != nil {
_ = tx.Rollback()
return 0, err
}
err = tx.Commit()
if err != nil {
return 0, err
}
return serial, nil
}
func (s *Store) ListPKICerts(caID string) ([]models.PKICert, error) {
var rows *sql.Rows
var err error
var items []models.PKICert
var item models.PKICert
if caID == "" {
rows, err = s.DB.Query(`SELECT id, COALESCE(ca_id, ''), 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 FROM pki_certs ORDER BY created_at DESC`)
} else if caID == "standalone" {
rows, err = s.DB.Query(`SELECT id, COALESCE(ca_id, ''), 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 FROM pki_certs WHERE ca_id IS NULL OR ca_id = '' ORDER BY created_at DESC`)
} else {
rows, err = s.DB.Query(`SELECT id, COALESCE(ca_id, ''), 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 FROM pki_certs WHERE ca_id = ? ORDER BY created_at DESC`, caID)
}
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
err = rows.Scan(&item.ID, &item.CAID, &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 nil, err
}
items = append(items, item)
}
err = rows.Err()
if err != nil {
return nil, err
}
return items, nil
}
func (s *Store) GetPKICert(id string) (models.PKICert, error) {
var row *sql.Row
var item models.PKICert
var err error
row = s.DB.QueryRow(`SELECT id, COALESCE(ca_id, ''), 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 FROM pki_certs WHERE id = ?`, id)
err = row.Scan(&item.ID, &item.CAID, &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
}
return item, nil
}
func (s *Store) CreatePKICert(item models.PKICert) (models.PKICert, error) {
var id string
var now int64
var caIDValue any
var err error
if item.ID == "" {
id, err = util.NewID()
if err != nil {
return item, err
}
item.ID = id
}
if item.Status == "" {
item.Status = "active"
}
now = time.Now().UTC().Unix()
item.CreatedAt = now
caIDValue = nil
if strings.TrimSpace(item.CAID) != "" {
caIDValue = strings.TrimSpace(item.CAID)
}
_, err = s.DB.Exec(`INSERT INTO pki_certs (id, ca_id, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
item.ID, caIDValue, 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
}
return item, nil
}
func (s *Store) RevokePKICert(id string, reason string) error {
var err error
_, err = s.DB.Exec(`UPDATE pki_certs SET status = 'revoked', revoked_at = ?, revocation_reason = ? WHERE id = ?`, time.Now().UTC().Unix(), reason, id)
return err
}
func (s *Store) DeletePKICert(id string) error {
var err error
_, err = s.DB.Exec(`DELETE FROM pki_certs WHERE id = ?`, id)
return err
}
func (s *Store) CountTLSServerCertReferences(certID string) (int, int, error) {
var row *sql.Row
var appCount int
var listenerCount int
var err error
row = s.DB.QueryRow(`SELECT COUNT(*) FROM app_settings WHERE key = 'tls.pki_server_cert_id' AND value = ?`, certID)
err = row.Scan(&appCount)
if err != nil {
return 0, 0, err
}
row = s.DB.QueryRow(`SELECT COUNT(*) FROM tls_listeners WHERE tls_pki_server_cert_id = ?`, certID)
err = row.Scan(&listenerCount)
if err != nil {
return 0, 0, err
}
return appCount, listenerCount, nil
}
func (s *Store) CountTLSClientCAReferences(caID string) (int, int, error) {
var row *sql.Row
var appCount int
var listenerCount int
var err error
row = s.DB.QueryRow(`SELECT COUNT(*) FROM app_settings WHERE key = 'tls.pki_client_ca_id' AND value = ?`, caID)
err = row.Scan(&appCount)
if err != nil {
return 0, 0, err
}
row = s.DB.QueryRow(`SELECT COUNT(*) FROM tls_listeners WHERE tls_pki_client_ca_id = ?`, caID)
err = row.Scan(&listenerCount)
if err != nil {
return 0, 0, err
}
return appCount, listenerCount, nil
}

View File

@@ -0,0 +1,203 @@
package db
import "database/sql"
import "strings"
import "time"
import "codit/internal/models"
import "codit/internal/util"
func (s *Store) ListServicePrincipals() ([]models.ServicePrincipal, error) {
var rows *sql.Rows
var items []models.ServicePrincipal
var item models.ServicePrincipal
var err error
rows, err = s.DB.Query(`SELECT id, name, description, is_admin, disabled, created_at, updated_at FROM service_principals ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
err = rows.Scan(&item.ID, &item.Name, &item.Description, &item.IsAdmin, &item.Disabled, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, err
}
items = append(items, item)
}
err = rows.Err()
if err != nil {
return nil, err
}
return items, nil
}
func (s *Store) GetServicePrincipal(id string) (models.ServicePrincipal, error) {
var row *sql.Row
var item models.ServicePrincipal
var err error
row = s.DB.QueryRow(`SELECT id, name, description, is_admin, disabled, created_at, updated_at FROM service_principals WHERE id = ?`, id)
err = row.Scan(&item.ID, &item.Name, &item.Description, &item.IsAdmin, &item.Disabled, &item.CreatedAt, &item.UpdatedAt)
return item, err
}
func (s *Store) CreateServicePrincipal(item models.ServicePrincipal) (models.ServicePrincipal, error) {
var id string
var now int64
var err error
if item.ID == "" {
id, err = util.NewID()
if err != nil {
return item, err
}
item.ID = id
}
now = time.Now().UTC().Unix()
item.CreatedAt = now
item.UpdatedAt = now
_, err = s.DB.Exec(`INSERT INTO service_principals (id, name, description, is_admin, disabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
item.ID, item.Name, item.Description, item.IsAdmin, item.Disabled, item.CreatedAt, item.UpdatedAt)
if err != nil {
return item, err
}
return item, nil
}
func (s *Store) UpdateServicePrincipal(item models.ServicePrincipal) error {
var now int64
var err error
now = time.Now().UTC().Unix()
item.UpdatedAt = now
_, err = s.DB.Exec(`UPDATE service_principals SET name = ?, description = ?, is_admin = ?, disabled = ?, updated_at = ? WHERE id = ?`,
item.Name, item.Description, item.IsAdmin, item.Disabled, item.UpdatedAt, item.ID)
return err
}
func (s *Store) DeleteServicePrincipal(id string) error {
var err error
_, err = s.DB.Exec(`DELETE FROM service_principals WHERE id = ?`, id)
return err
}
func (s *Store) ListCertPrincipalBindings() ([]models.CertPrincipalBinding, error) {
var rows *sql.Rows
var items []models.CertPrincipalBinding
var item models.CertPrincipalBinding
var err error
rows, err = s.DB.Query(`SELECT fingerprint, principal_id, enabled, created_at, updated_at FROM cert_principal_bindings ORDER BY fingerprint`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
err = rows.Scan(&item.Fingerprint, &item.PrincipalID, &item.Enabled, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, err
}
items = append(items, item)
}
err = rows.Err()
if err != nil {
return nil, err
}
return items, nil
}
func (s *Store) UpsertCertPrincipalBinding(item models.CertPrincipalBinding) (models.CertPrincipalBinding, error) {
var now int64
var err error
now = time.Now().UTC().Unix()
item.Fingerprint = strings.ToLower(strings.TrimSpace(item.Fingerprint))
item.UpdatedAt = now
_, err = s.DB.Exec(`INSERT INTO cert_principal_bindings (fingerprint, principal_id, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(fingerprint) DO UPDATE SET principal_id = excluded.principal_id, enabled = excluded.enabled, updated_at = excluded.updated_at`,
item.Fingerprint, item.PrincipalID, item.Enabled, now, now)
if err != nil {
return item, err
}
item.CreatedAt = now
return item, nil
}
func (s *Store) DeleteCertPrincipalBinding(fingerprint string) error {
var err error
fingerprint = strings.ToLower(strings.TrimSpace(fingerprint))
_, err = s.DB.Exec(`DELETE FROM cert_principal_bindings WHERE fingerprint = ?`, fingerprint)
return err
}
func (s *Store) GetPrincipalByCertFingerprint(fingerprint string) (models.ServicePrincipal, bool, error) {
var row *sql.Row
var item models.ServicePrincipal
var enabled bool
var err error
fingerprint = strings.ToLower(strings.TrimSpace(fingerprint))
row = s.DB.QueryRow(`SELECT p.id, p.name, p.description, p.is_admin, p.disabled, p.created_at, p.updated_at, b.enabled
FROM cert_principal_bindings b
INNER JOIN service_principals p ON p.id = b.principal_id
WHERE b.fingerprint = ?`, fingerprint)
err = row.Scan(&item.ID, &item.Name, &item.Description, &item.IsAdmin, &item.Disabled, &item.CreatedAt, &item.UpdatedAt, &enabled)
if err != nil {
if err == sql.ErrNoRows {
return item, false, nil
}
return item, false, err
}
if item.Disabled || !enabled {
return item, false, nil
}
return item, true, nil
}
func (s *Store) ListPrincipalProjectRoles(principalID string) ([]models.PrincipalProjectRole, error) {
var rows *sql.Rows
var items []models.PrincipalProjectRole
var item models.PrincipalProjectRole
var err error
rows, err = s.DB.Query(`SELECT principal_id, project_id, role, created_at FROM principal_project_roles WHERE principal_id = ? ORDER BY project_id`, principalID)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
err = rows.Scan(&item.PrincipalID, &item.ProjectID, &item.Role, &item.CreatedAt)
if err != nil {
return nil, err
}
items = append(items, item)
}
err = rows.Err()
if err != nil {
return nil, err
}
return items, nil
}
func (s *Store) UpsertPrincipalProjectRole(item models.PrincipalProjectRole) (models.PrincipalProjectRole, error) {
var now int64
var err error
now = time.Now().UTC().Unix()
item.CreatedAt = now
_, err = s.DB.Exec(`INSERT INTO principal_project_roles (principal_id, project_id, role, created_at) VALUES (?, ?, ?, ?)
ON CONFLICT(principal_id, project_id) DO UPDATE SET role = excluded.role`,
item.PrincipalID, item.ProjectID, item.Role, item.CreatedAt)
if err != nil {
return item, err
}
return item, nil
}
func (s *Store) DeletePrincipalProjectRole(principalID string, projectID string) error {
var err error
_, err = s.DB.Exec(`DELETE FROM principal_project_roles WHERE principal_id = ? AND project_id = ?`, principalID, projectID)
return err
}
func (s *Store) GetPrincipalProjectRole(principalID string, projectID string) (string, error) {
var row *sql.Row
var role string
var err error
row = s.DB.QueryRow(`SELECT role FROM principal_project_roles WHERE principal_id = ? AND project_id = ?`, principalID, projectID)
err = row.Scan(&role)
return role, err
}

View File

@@ -358,6 +358,164 @@ func (s *Store) SetAuthSettings(settings models.AuthSettings) error {
return nil return nil
} }
func (s *Store) GetTLSSettings() (models.TLSSettings, error) {
var settings models.TLSSettings
var rows *sql.Rows
var err error
var key string
var value string
rows, err = s.DB.Query(`SELECT key, value FROM app_settings WHERE key IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
"tls.http_addrs",
"tls.https_addrs",
"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")
if err != nil {
return settings, err
}
defer rows.Close()
for rows.Next() {
err = rows.Scan(&key, &value)
if err != nil {
return settings, err
}
switch key {
case "tls.http_addrs":
settings.HTTPAddrs = splitCSVValue(value)
case "tls.https_addrs":
settings.HTTPSAddrs = splitCSVValue(value)
case "tls.server_cert_source":
settings.TLSServerCertSource = value
case "tls.cert_file":
settings.TLSCertFile = value
case "tls.key_file":
settings.TLSKeyFile = value
case "tls.pki_server_cert_id":
settings.TLSPKIServerCertID = value
case "tls.client_auth":
settings.TLSClientAuth = value
case "tls.client_ca_file":
settings.TLSClientCAFile = value
case "tls.pki_client_ca_id":
settings.TLSPKIClientCAID = value
case "tls.min_version":
settings.TLSMinVersion = value
}
}
err = rows.Err()
if err != nil {
return settings, err
}
return settings, nil
}
func (s *Store) SetTLSSettings(settings models.TLSSettings) error {
var tx *sql.Tx
var err error
var now int64
tx, err = s.DB.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`,
"tls.http_addrs", strings.Join(settings.HTTPAddrs, ","), now)
if err != nil {
_ = tx.Rollback()
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`,
"tls.https_addrs", strings.Join(settings.HTTPSAddrs, ","), now)
if err != nil {
_ = tx.Rollback()
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`,
"tls.server_cert_source", settings.TLSServerCertSource, now)
if err != nil {
_ = tx.Rollback()
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`,
"tls.cert_file", settings.TLSCertFile, now)
if err != nil {
_ = tx.Rollback()
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`,
"tls.key_file", settings.TLSKeyFile, now)
if err != nil {
_ = tx.Rollback()
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`,
"tls.pki_server_cert_id", settings.TLSPKIServerCertID, now)
if err != nil {
_ = tx.Rollback()
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`,
"tls.client_auth", settings.TLSClientAuth, now)
if err != nil {
_ = tx.Rollback()
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`,
"tls.client_ca_file", settings.TLSClientCAFile, now)
if err != nil {
_ = tx.Rollback()
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`,
"tls.pki_client_ca_id", settings.TLSPKIClientCAID, now)
if err != nil {
_ = tx.Rollback()
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`,
"tls.min_version", settings.TLSMinVersion, now)
if err != nil {
_ = tx.Rollback()
return err
}
err = tx.Commit()
if err != nil {
return err
}
return nil
}
func splitCSVValue(value string) []string {
var parts []string
var out []string
var i int
var p string
parts = strings.Split(value, ",")
for i = 0; i < len(parts); i++ {
p = strings.TrimSpace(parts[i])
if p == "" {
continue
}
out = append(out, p)
}
return out
}
func (s *Store) SetUserDisabled(id string, disabled bool) error { func (s *Store) SetUserDisabled(id string, disabled bool) error {
var err error var err error
var now time.Time var now time.Time

View File

@@ -0,0 +1,94 @@
package db
import "database/sql"
import "strings"
import "time"
import "codit/internal/models"
import "codit/internal/util"
func (s *Store) ListTLSListeners() ([]models.TLSListener, error) {
var rows *sql.Rows
var err error
var items []models.TLSListener
var item models.TLSListener
var httpAddrs string
var httpsAddrs string
var certAllowlist string
rows, err = s.DB.Query(`SELECT id, name, enabled, http_addrs, https_addrs, auth_policy, apply_policy_api, apply_policy_git, apply_policy_rpm, apply_policy_v2, client_cert_allowlist, 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 FROM tls_listeners ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
err = rows.Scan(&item.ID, &item.Name, &item.Enabled, &httpAddrs, &httpsAddrs, &item.AuthPolicy, &item.ApplyPolicyAPI, &item.ApplyPolicyGit, &item.ApplyPolicyRPM, &item.ApplyPolicyV2, &certAllowlist, &item.TLSServerCertSource, &item.TLSCertFile, &item.TLSKeyFile, &item.TLSPKIServerCertID, &item.TLSClientAuth, &item.TLSClientCAFile, &item.TLSPKIClientCAID, &item.TLSMinVersion, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, err
}
item.HTTPAddrs = splitCSVValue(httpAddrs)
item.HTTPSAddrs = splitCSVValue(httpsAddrs)
item.ClientCertAllowlist = splitCSVValue(certAllowlist)
items = append(items, item)
}
err = rows.Err()
if err != nil {
return nil, err
}
return items, nil
}
func (s *Store) GetTLSListener(id string) (models.TLSListener, error) {
var row *sql.Row
var item models.TLSListener
var httpAddrs string
var httpsAddrs string
var certAllowlist string
var err error
row = s.DB.QueryRow(`SELECT id, name, enabled, http_addrs, https_addrs, auth_policy, apply_policy_api, apply_policy_git, apply_policy_rpm, apply_policy_v2, client_cert_allowlist, 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 FROM tls_listeners WHERE id = ?`, id)
err = row.Scan(&item.ID, &item.Name, &item.Enabled, &httpAddrs, &httpsAddrs, &item.AuthPolicy, &item.ApplyPolicyAPI, &item.ApplyPolicyGit, &item.ApplyPolicyRPM, &item.ApplyPolicyV2, &certAllowlist, &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
}
item.HTTPAddrs = splitCSVValue(httpAddrs)
item.HTTPSAddrs = splitCSVValue(httpsAddrs)
item.ClientCertAllowlist = splitCSVValue(certAllowlist)
return item, nil
}
func (s *Store) CreateTLSListener(item models.TLSListener) (models.TLSListener, error) {
var id string
var now int64
var err error
if item.ID == "" {
id, err = util.NewID()
if err != nil {
return item, err
}
item.ID = id
}
now = time.Now().UTC().Unix()
item.CreatedAt = now
item.UpdatedAt = now
_, err = s.DB.Exec(`INSERT INTO tls_listeners (id, name, enabled, http_addrs, https_addrs, auth_policy, apply_policy_api, apply_policy_git, apply_policy_rpm, apply_policy_v2, client_cert_allowlist, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
item.ID, item.Name, item.Enabled, strings.Join(item.HTTPAddrs, ","), strings.Join(item.HTTPSAddrs, ","), item.AuthPolicy, item.ApplyPolicyAPI, item.ApplyPolicyGit, item.ApplyPolicyRPM, item.ApplyPolicyV2, strings.Join(item.ClientCertAllowlist, ","), 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
}
return item, nil
}
func (s *Store) UpdateTLSListener(item models.TLSListener) error {
var err error
var now int64
now = time.Now().UTC().Unix()
item.UpdatedAt = now
_, err = s.DB.Exec(`UPDATE tls_listeners SET name = ?, enabled = ?, http_addrs = ?, https_addrs = ?, auth_policy = ?, apply_policy_api = ?, apply_policy_git = ?, apply_policy_rpm = ?, apply_policy_v2 = ?, client_cert_allowlist = ?, 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 id = ?`,
item.Name, item.Enabled, strings.Join(item.HTTPAddrs, ","), strings.Join(item.HTTPSAddrs, ","), item.AuthPolicy, item.ApplyPolicyAPI, item.ApplyPolicyGit, item.ApplyPolicyRPM, item.ApplyPolicyV2, strings.Join(item.ClientCertAllowlist, ","), item.TLSServerCertSource, item.TLSCertFile, item.TLSKeyFile, item.TLSPKIServerCertID, item.TLSClientAuth, item.TLSClientCAFile, item.TLSPKIClientCAID, item.TLSMinVersion, item.UpdatedAt, item.ID)
return err
}
func (s *Store) DeleteTLSListener(id string) error {
var err error
_, err = s.DB.Exec(`DELETE FROM tls_listeners WHERE id = ?`, id)
return err
}

View File

@@ -574,6 +574,7 @@ func resolveManifest(repoPath string, reference string) (ociDescriptor, error) {
var desc ociDescriptor var desc ociDescriptor
var err error var err error
var ok bool var ok bool
var data []byte
if isDigestRef(reference) { if isDigestRef(reference) {
ok, err = HasBlob(repoPath, reference) ok, err = HasBlob(repoPath, reference)
if err != nil { if err != nil {
@@ -583,6 +584,11 @@ func resolveManifest(repoPath string, reference string) (ociDescriptor, error) {
return desc, ErrNotFound return desc, ErrNotFound
} }
desc = ociDescriptor{Digest: reference} desc = ociDescriptor{Digest: reference}
data, err = ReadBlob(repoPath, reference)
if err == nil {
desc.Size = int64(len(data))
desc.MediaType = detectManifestMediaType(data)
}
return desc, nil return desc, nil
} }
desc, err = resolveTag(repoPath, reference) desc, err = resolveTag(repoPath, reference)

View File

@@ -33,6 +33,8 @@ type API struct {
DockerBase string DockerBase string
Uploads storage.FileStore Uploads storage.FileStore
Logger *util.Logger Logger *util.Logger
OnTLSListenersChanged func()
OnTLSListenerRuntimeStatus func() map[string]int
} }
type loginRequest struct { type loginRequest struct {
@@ -85,6 +87,58 @@ type testLDAPSettingsRequest struct {
Password string `json:"password"` Password string `json:"password"`
} }
type updateTLSSettingsRequest struct {
HTTPAddrs []string `json:"http_addrs"`
HTTPSAddrs []string `json:"https_addrs"`
TLSServerCertSource string `json:"tls_server_cert_source"`
TLSCertFile string `json:"tls_cert_file"`
TLSKeyFile string `json:"tls_key_file"`
TLSPKIServerCertID string `json:"tls_pki_server_cert_id"`
TLSClientAuth string `json:"tls_client_auth"`
TLSClientCAFile string `json:"tls_client_ca_file"`
TLSPKIClientCAID string `json:"tls_pki_client_ca_id"`
TLSMinVersion string `json:"tls_min_version"`
}
type tlsListenerRequest struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
HTTPAddrs []string `json:"http_addrs"`
HTTPSAddrs []string `json:"https_addrs"`
AuthPolicy string `json:"auth_policy"`
ApplyPolicyAPI bool `json:"apply_policy_api"`
ApplyPolicyGit bool `json:"apply_policy_git"`
ApplyPolicyRPM bool `json:"apply_policy_rpm"`
ApplyPolicyV2 bool `json:"apply_policy_v2"`
ClientCertAllowlist []string `json:"client_cert_allowlist"`
TLSServerCertSource string `json:"tls_server_cert_source"`
TLSCertFile string `json:"tls_cert_file"`
TLSKeyFile string `json:"tls_key_file"`
TLSPKIServerCertID string `json:"tls_pki_server_cert_id"`
TLSClientAuth string `json:"tls_client_auth"`
TLSClientCAFile string `json:"tls_client_ca_file"`
TLSPKIClientCAID string `json:"tls_pki_client_ca_id"`
TLSMinVersion string `json:"tls_min_version"`
}
type servicePrincipalRequest struct {
Name string `json:"name"`
Description string `json:"description"`
IsAdmin bool `json:"is_admin"`
Disabled bool `json:"disabled"`
}
type certPrincipalBindingRequest struct {
Fingerprint string `json:"fingerprint"`
PrincipalID string `json:"principal_id"`
Enabled bool `json:"enabled"`
}
type principalProjectRoleRequest struct {
ProjectID string `json:"project_id"`
Role string `json:"role"`
}
type createProjectRequest struct { type createProjectRequest struct {
Slug string `json:"slug"` Slug string `json:"slug"`
Name string `json:"name"` Name string `json:"name"`
@@ -711,6 +765,397 @@ func (api *API) TestLDAPSettings(w http.ResponseWriter, r *http.Request, _ map[s
WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"}) WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"})
} }
func (api *API) GetTLSSettings(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var settings models.TLSSettings
var err error
if !api.requireAdmin(w, r) {
return
}
settings, err = api.getMergedTLSSettings()
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
WriteJSON(w, http.StatusOK, settings)
}
func (api *API) UpdateTLSSettings(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var req updateTLSSettingsRequest
var settings models.TLSSettings
var err error
if !api.requireAdmin(w, r) {
return
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
settings = models.TLSSettings{
HTTPAddrs: normalizeAddrList(req.HTTPAddrs),
HTTPSAddrs: normalizeAddrList(req.HTTPSAddrs),
TLSServerCertSource: "pki",
TLSCertFile: "",
TLSKeyFile: "",
TLSPKIServerCertID: strings.TrimSpace(req.TLSPKIServerCertID),
TLSClientAuth: normalizeTLSClientAuth(req.TLSClientAuth),
TLSClientCAFile: "",
TLSPKIClientCAID: strings.TrimSpace(req.TLSPKIClientCAID),
TLSMinVersion: normalizeTLSMinVersion(req.TLSMinVersion),
}
if len(settings.HTTPAddrs) == 0 && len(settings.HTTPSAddrs) == 0 {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "at least one of http_addrs or https_addrs is required"})
return
}
if len(settings.HTTPSAddrs) > 0 && strings.TrimSpace(settings.TLSPKIServerCertID) == "" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "tls_pki_server_cert_id is required when https_addrs is configured"})
return
}
if tlsClientAuthNeedsCA(settings.TLSClientAuth) && !tlsClientCAConfigured(settings.TLSPKIClientCAID) {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "tls_pki_client_ca_id is required for selected tls_client_auth"})
return
}
err = api.Store.SetTLSSettings(settings)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
WriteJSON(w, http.StatusOK, settings)
}
func (api *API) ListTLSListeners(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var items []models.TLSListener
var err error
if !api.requireAdmin(w, r) {
return
}
items, err = api.Store.ListTLSListeners()
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
WriteJSON(w, http.StatusOK, items)
}
func (api *API) CreateTLSListener(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var req tlsListenerRequest
var item models.TLSListener
var created models.TLSListener
var err error
if !api.requireAdmin(w, r) {
return
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
item = normalizeTLSListenerRequest(req)
if item.Name == "" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
return
}
if len(item.HTTPAddrs) == 0 && len(item.HTTPSAddrs) == 0 {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "at least one of http_addrs or https_addrs is required"})
return
}
if len(item.HTTPSAddrs) > 0 && strings.TrimSpace(item.TLSPKIServerCertID) == "" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "tls_pki_server_cert_id is required when https_addrs is configured"})
return
}
if tlsClientAuthNeedsCA(item.TLSClientAuth) && !tlsClientCAConfigured(item.TLSPKIClientCAID) {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "tls_pki_client_ca_id is required for selected tls_client_auth"})
return
}
created, err = api.Store.CreateTLSListener(item)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if api.OnTLSListenersChanged != nil {
api.OnTLSListenersChanged()
}
WriteJSON(w, http.StatusCreated, created)
}
func (api *API) UpdateTLSListener(w http.ResponseWriter, r *http.Request, params map[string]string) {
var req tlsListenerRequest
var item models.TLSListener
var err error
if !api.requireAdmin(w, r) {
return
}
item, err = api.Store.GetTLSListener(params["id"])
if err != nil {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "listener not found"})
return
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
item = mergeTLSListener(item, normalizeTLSListenerRequest(req))
if item.Name == "" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
return
}
if len(item.HTTPAddrs) == 0 && len(item.HTTPSAddrs) == 0 {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "at least one of http_addrs or https_addrs is required"})
return
}
if len(item.HTTPSAddrs) > 0 && strings.TrimSpace(item.TLSPKIServerCertID) == "" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "tls_pki_server_cert_id is required when https_addrs is configured"})
return
}
if tlsClientAuthNeedsCA(item.TLSClientAuth) && !tlsClientCAConfigured(item.TLSPKIClientCAID) {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "tls_pki_client_ca_id is required for selected tls_client_auth"})
return
}
err = api.Store.UpdateTLSListener(item)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if api.OnTLSListenersChanged != nil {
api.OnTLSListenersChanged()
}
WriteJSON(w, http.StatusOK, item)
}
func (api *API) DeleteTLSListener(w http.ResponseWriter, r *http.Request, params map[string]string) {
var err error
if !api.requireAdmin(w, r) {
return
}
err = api.Store.DeleteTLSListener(params["id"])
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if api.OnTLSListenersChanged != nil {
api.OnTLSListenersChanged()
}
w.WriteHeader(http.StatusNoContent)
}
func (api *API) GetTLSListenerRuntimeStatus(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var status map[string]int
if !api.requireAdmin(w, r) {
return
}
status = make(map[string]int)
if api.OnTLSListenerRuntimeStatus != nil {
status = api.OnTLSListenerRuntimeStatus()
}
WriteJSON(w, http.StatusOK, status)
}
func (api *API) ListServicePrincipals(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var items []models.ServicePrincipal
var err error
if !api.requireAdmin(w, r) {
return
}
items, err = api.Store.ListServicePrincipals()
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
WriteJSON(w, http.StatusOK, items)
}
func (api *API) CreateServicePrincipal(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var req servicePrincipalRequest
var item models.ServicePrincipal
var created models.ServicePrincipal
var err error
if !api.requireAdmin(w, r) {
return
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
item = models.ServicePrincipal{
Name: strings.TrimSpace(req.Name),
Description: strings.TrimSpace(req.Description),
IsAdmin: req.IsAdmin,
Disabled: req.Disabled,
}
if item.Name == "" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
return
}
created, err = api.Store.CreateServicePrincipal(item)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
WriteJSON(w, http.StatusCreated, created)
}
func (api *API) UpdateServicePrincipal(w http.ResponseWriter, r *http.Request, params map[string]string) {
var req servicePrincipalRequest
var item models.ServicePrincipal
var err error
if !api.requireAdmin(w, r) {
return
}
item, err = api.Store.GetServicePrincipal(params["id"])
if err != nil {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "principal not found"})
return
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
item.Name = strings.TrimSpace(req.Name)
item.Description = strings.TrimSpace(req.Description)
item.IsAdmin = req.IsAdmin
item.Disabled = req.Disabled
if item.Name == "" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
return
}
err = api.Store.UpdateServicePrincipal(item)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
WriteJSON(w, http.StatusOK, item)
}
func (api *API) DeleteServicePrincipal(w http.ResponseWriter, r *http.Request, params map[string]string) {
var err error
if !api.requireAdmin(w, r) {
return
}
err = api.Store.DeleteServicePrincipal(params["id"])
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
w.WriteHeader(http.StatusNoContent)
}
func (api *API) ListCertPrincipalBindings(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var items []models.CertPrincipalBinding
var err error
if !api.requireAdmin(w, r) {
return
}
items, err = api.Store.ListCertPrincipalBindings()
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
WriteJSON(w, http.StatusOK, items)
}
func (api *API) UpsertCertPrincipalBinding(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var req certPrincipalBindingRequest
var item models.CertPrincipalBinding
var updated models.CertPrincipalBinding
var err error
if !api.requireAdmin(w, r) {
return
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
item = models.CertPrincipalBinding{
Fingerprint: strings.ToLower(strings.TrimSpace(req.Fingerprint)),
PrincipalID: strings.TrimSpace(req.PrincipalID),
Enabled: req.Enabled,
}
if item.Fingerprint == "" || item.PrincipalID == "" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "fingerprint and principal_id are required"})
return
}
updated, err = api.Store.UpsertCertPrincipalBinding(item)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
WriteJSON(w, http.StatusOK, updated)
}
func (api *API) DeleteCertPrincipalBinding(w http.ResponseWriter, r *http.Request, params map[string]string) {
var err error
if !api.requireAdmin(w, r) {
return
}
err = api.Store.DeleteCertPrincipalBinding(params["fingerprint"])
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
w.WriteHeader(http.StatusNoContent)
}
func (api *API) ListPrincipalProjectRoles(w http.ResponseWriter, r *http.Request, params map[string]string) {
var items []models.PrincipalProjectRole
var err error
if !api.requireAdmin(w, r) {
return
}
items, err = api.Store.ListPrincipalProjectRoles(params["id"])
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
WriteJSON(w, http.StatusOK, items)
}
func (api *API) UpsertPrincipalProjectRole(w http.ResponseWriter, r *http.Request, params map[string]string) {
var req principalProjectRoleRequest
var item models.PrincipalProjectRole
var saved models.PrincipalProjectRole
var err error
if !api.requireAdmin(w, r) {
return
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
item = models.PrincipalProjectRole{
PrincipalID: strings.TrimSpace(params["id"]),
ProjectID: strings.TrimSpace(req.ProjectID),
Role: normalizeRole(req.Role),
}
if item.PrincipalID == "" || item.ProjectID == "" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "principal id and project id are required"})
return
}
saved, err = api.Store.UpsertPrincipalProjectRole(item)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
WriteJSON(w, http.StatusOK, saved)
}
func (api *API) DeletePrincipalProjectRole(w http.ResponseWriter, r *http.Request, params map[string]string) {
var err error
if !api.requireAdmin(w, r) {
return
}
err = api.Store.DeletePrincipalProjectRole(params["id"], params["projectId"])
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
w.WriteHeader(http.StatusNoContent)
}
func (api *API) ListProjects(w http.ResponseWriter, r *http.Request, _ map[string]string) { func (api *API) ListProjects(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var projects []models.Project var projects []models.Project
var err error var err error
@@ -3518,33 +3963,51 @@ func (api *API) Health(w http.ResponseWriter, _ *http.Request, _ map[string]stri
func (api *API) requireAdmin(w http.ResponseWriter, r *http.Request) bool { func (api *API) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
var user models.User var user models.User
var ok bool var ok bool
var principal models.ServicePrincipal
user, ok = middleware.UserFromContext(r.Context()) user, ok = middleware.UserFromContext(r.Context())
if !ok || !user.IsAdmin { if ok && user.IsAdmin {
w.WriteHeader(http.StatusForbidden) return true
return false
} }
return true principal, ok = middleware.PrincipalFromContext(r.Context())
if ok && principal.IsAdmin && !principal.Disabled {
return true
}
w.WriteHeader(http.StatusForbidden)
return false
} }
func (api *API) requireProjectRole(w http.ResponseWriter, r *http.Request, projectID, required string) bool { func (api *API) requireProjectRole(w http.ResponseWriter, r *http.Request, projectID, required string) bool {
var user models.User var user models.User
var principal models.ServicePrincipal
var ok bool var ok bool
user, ok = middleware.UserFromContext(r.Context()) user, ok = middleware.UserFromContext(r.Context())
if !ok { if ok && user.IsAdmin {
w.WriteHeader(http.StatusUnauthorized)
return false
}
if user.IsAdmin {
return true return true
} }
var role string var role string
var err error var err error
role, err = api.Store.GetProjectMemberRole(projectID, user.ID) if ok {
if err != nil { role, err = api.Store.GetProjectMemberRole(projectID, user.ID)
w.WriteHeader(http.StatusForbidden) if err != nil {
w.WriteHeader(http.StatusForbidden)
return false
}
if !roleAllows(role, required) {
w.WriteHeader(http.StatusForbidden)
return false
}
return true
}
principal, ok = middleware.PrincipalFromContext(r.Context())
if !ok || principal.Disabled {
w.WriteHeader(http.StatusUnauthorized)
return false return false
} }
if !roleAllows(role, required) { if principal.IsAdmin {
return true
}
role, err = api.Store.GetPrincipalProjectRole(principal.ID, projectID)
if err != nil || !roleAllows(role, required) {
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)
return false return false
} }
@@ -3553,17 +4016,14 @@ func (api *API) requireProjectRole(w http.ResponseWriter, r *http.Request, proje
func (api *API) requireRepoRole(w http.ResponseWriter, r *http.Request, repoID, required string) bool { func (api *API) requireRepoRole(w http.ResponseWriter, r *http.Request, repoID, required string) bool {
var user models.User var user models.User
var principal models.ServicePrincipal
var ok bool var ok bool
var projectIDs []string var projectIDs []string
var err error var err error
var i int var i int
var role string var role string
user, ok = middleware.UserFromContext(r.Context()) user, ok = middleware.UserFromContext(r.Context())
if !ok { if ok && user.IsAdmin {
w.WriteHeader(http.StatusUnauthorized)
return false
}
if user.IsAdmin {
return true return true
} }
projectIDs, err = api.Store.GetRepoProjectIDs(repoID) projectIDs, err = api.Store.GetRepoProjectIDs(repoID)
@@ -3571,8 +4031,29 @@ func (api *API) requireRepoRole(w http.ResponseWriter, r *http.Request, repoID,
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)
return false return false
} }
if ok {
for i = 0; i < len(projectIDs); i++ {
role, err = api.Store.GetProjectMemberRole(projectIDs[i], user.ID)
if err != nil {
continue
}
if roleAllows(role, required) {
return true
}
}
w.WriteHeader(http.StatusForbidden)
return false
}
principal, ok = middleware.PrincipalFromContext(r.Context())
if !ok || principal.Disabled {
w.WriteHeader(http.StatusUnauthorized)
return false
}
if principal.IsAdmin {
return true
}
for i = 0; i < len(projectIDs); i++ { for i = 0; i < len(projectIDs); i++ {
role, err = api.Store.GetProjectMemberRole(projectIDs[i], user.ID) role, err = api.Store.GetPrincipalProjectRole(principal.ID, projectIDs[i])
if err != nil { if err != nil {
continue continue
} }
@@ -3720,6 +4201,51 @@ func (api *API) effectiveAuthConfig() (config.Config, error) {
return cfg, nil return cfg, nil
} }
func (api *API) getMergedTLSSettings() (models.TLSSettings, error) {
var settings models.TLSSettings
var saved models.TLSSettings
var err error
settings = models.TLSSettings{
HTTPAddrs: normalizeAddrList(api.Cfg.HTTPAddrs),
HTTPSAddrs: normalizeAddrList(api.Cfg.HTTPSAddrs),
TLSServerCertSource: "pki",
TLSCertFile: "",
TLSKeyFile: "",
TLSPKIServerCertID: api.Cfg.TLSPKIServerCertID,
TLSClientAuth: normalizeTLSClientAuth(api.Cfg.TLSClientAuth),
TLSClientCAFile: "",
TLSPKIClientCAID: api.Cfg.TLSPKIClientCAID,
TLSMinVersion: normalizeTLSMinVersion(api.Cfg.TLSMinVersion),
}
saved, err = api.Store.GetTLSSettings()
if err != nil {
return settings, err
}
if len(saved.HTTPAddrs) > 0 {
settings.HTTPAddrs = normalizeAddrList(saved.HTTPAddrs)
}
if len(saved.HTTPSAddrs) > 0 {
settings.HTTPSAddrs = normalizeAddrList(saved.HTTPSAddrs)
}
settings.TLSServerCertSource = "pki"
settings.TLSCertFile = ""
settings.TLSKeyFile = ""
if strings.TrimSpace(saved.TLSPKIServerCertID) != "" {
settings.TLSPKIServerCertID = saved.TLSPKIServerCertID
}
if strings.TrimSpace(saved.TLSClientAuth) != "" {
settings.TLSClientAuth = normalizeTLSClientAuth(saved.TLSClientAuth)
}
settings.TLSClientCAFile = ""
if strings.TrimSpace(saved.TLSPKIClientCAID) != "" {
settings.TLSPKIClientCAID = saved.TLSPKIClientCAID
}
if strings.TrimSpace(saved.TLSMinVersion) != "" {
settings.TLSMinVersion = normalizeTLSMinVersion(saved.TLSMinVersion)
}
return settings, nil
}
func normalizeRepoType(value string) (string, bool) { func normalizeRepoType(value string) (string, bool) {
var v string var v string
v = strings.ToLower(strings.TrimSpace(value)) v = strings.ToLower(strings.TrimSpace(value))
@@ -3732,6 +4258,139 @@ func normalizeRepoType(value string) (string, bool) {
return "", false return "", false
} }
func normalizeAddrList(values []string) []string {
var out []string
var i int
var v string
for i = 0; i < len(values); i++ {
v = strings.TrimSpace(values[i])
if v == "" {
continue
}
out = append(out, v)
}
return out
}
func normalizeTLSServerCertSource(value string) string {
_ = value
return "pki"
}
func normalizeTLSClientAuth(value string) string {
var v string
v = strings.ToLower(strings.TrimSpace(value))
if v == "request" || v == "require" || v == "verify_if_given" || v == "require_and_verify" {
return v
}
return "none"
}
func normalizeTLSMinVersion(value string) string {
var v string
v = strings.ToLower(strings.TrimSpace(value))
if v == "1.0" || v == "1.1" || v == "1.3" || v == "tls1.0" || v == "tls1.1" || v == "tls1.3" {
return v
}
return "1.2"
}
func tlsClientAuthNeedsCA(value string) bool {
var v string
v = normalizeTLSClientAuth(value)
return v == "require_and_verify" || v == "verify_if_given"
}
func tlsClientCAConfigured(pkiCAID string) bool {
return strings.TrimSpace(pkiCAID) != ""
}
func normalizeTLSListenerRequest(req tlsListenerRequest) models.TLSListener {
var item models.TLSListener
var applyAPI bool
var applyGit bool
var applyRPM bool
var applyV2 bool
applyAPI = req.ApplyPolicyAPI
applyGit = req.ApplyPolicyGit
applyRPM = req.ApplyPolicyRPM
applyV2 = req.ApplyPolicyV2
if !applyAPI && !applyGit && !applyRPM && !applyV2 {
applyAPI = true
applyGit = true
applyRPM = true
applyV2 = true
}
item = models.TLSListener{
Name: strings.TrimSpace(req.Name),
Enabled: req.Enabled,
HTTPAddrs: normalizeAddrList(req.HTTPAddrs),
HTTPSAddrs: normalizeAddrList(req.HTTPSAddrs),
AuthPolicy: normalizeTLSAuthPolicy(req.AuthPolicy),
ApplyPolicyAPI: applyAPI,
ApplyPolicyGit: applyGit,
ApplyPolicyRPM: applyRPM,
ApplyPolicyV2: applyV2,
ClientCertAllowlist: normalizeTLSCertAllowlist(req.ClientCertAllowlist),
TLSServerCertSource: "pki",
TLSCertFile: "",
TLSKeyFile: "",
TLSPKIServerCertID: strings.TrimSpace(req.TLSPKIServerCertID),
TLSClientAuth: normalizeTLSClientAuth(req.TLSClientAuth),
TLSClientCAFile: "",
TLSPKIClientCAID: strings.TrimSpace(req.TLSPKIClientCAID),
TLSMinVersion: normalizeTLSMinVersion(req.TLSMinVersion),
}
return item
}
func mergeTLSListener(current models.TLSListener, updated models.TLSListener) models.TLSListener {
var out models.TLSListener
out = current
out.Name = updated.Name
out.Enabled = updated.Enabled
out.HTTPAddrs = updated.HTTPAddrs
out.HTTPSAddrs = updated.HTTPSAddrs
out.AuthPolicy = updated.AuthPolicy
out.ApplyPolicyAPI = updated.ApplyPolicyAPI
out.ApplyPolicyGit = updated.ApplyPolicyGit
out.ApplyPolicyRPM = updated.ApplyPolicyRPM
out.ApplyPolicyV2 = updated.ApplyPolicyV2
out.ClientCertAllowlist = updated.ClientCertAllowlist
out.TLSServerCertSource = updated.TLSServerCertSource
out.TLSCertFile = updated.TLSCertFile
out.TLSKeyFile = updated.TLSKeyFile
out.TLSPKIServerCertID = updated.TLSPKIServerCertID
out.TLSClientAuth = updated.TLSClientAuth
out.TLSClientCAFile = updated.TLSClientCAFile
out.TLSPKIClientCAID = updated.TLSPKIClientCAID
out.TLSMinVersion = updated.TLSMinVersion
return out
}
func normalizeTLSAuthPolicy(value string) string {
var v string
v = strings.ToLower(strings.TrimSpace(value))
if v == "read_open_write_cert" || v == "read_open_write_cert_or_auth" || v == "cert_only" || v == "read_only_public" {
return v
}
return "default"
}
func normalizeTLSCertAllowlist(values []string) []string {
var out []string
var i int
var v string
for i = 0; i < len(values); i++ {
v = strings.ToLower(strings.TrimSpace(values[i]))
if v == "" {
continue
}
out = append(out, v)
}
return out
}
func roleAllows(actual, required string) bool { func roleAllows(actual, required string) bool {
actual = normalizeRole(actual) actual = normalizeRole(actual)
required = normalizeRole(required) required = normalizeRole(required)

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@ import "codit/internal/util"
type ctxKey string type ctxKey string
const userKey ctxKey = "user" const userKey ctxKey = "user"
const principalKey ctxKey = "principal"
func WithUser(store *db.Store, next http.Handler) http.Handler { func WithUser(store *db.Store, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -68,6 +69,19 @@ func UserFromContext(ctx context.Context) (models.User, bool) {
return user, ok return user, ok
} }
func WithPrincipal(r *http.Request, principal models.ServicePrincipal) *http.Request {
var ctx context.Context
ctx = context.WithValue(r.Context(), principalKey, principal)
return r.WithContext(ctx)
}
func PrincipalFromContext(ctx context.Context) (models.ServicePrincipal, bool) {
var principal models.ServicePrincipal
var ok bool
principal, ok = ctx.Value(principalKey).(models.ServicePrincipal)
return principal, ok
}
func RequireAuth(next http.Handler) http.Handler { func RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var ok bool var ok bool

View File

@@ -128,3 +128,96 @@ type AuthSettings struct {
OIDCScopes string `json:"oidc_scopes"` OIDCScopes string `json:"oidc_scopes"`
OIDCTLSInsecureSkipVerify bool `json:"oidc_tls_insecure_skip_verify"` OIDCTLSInsecureSkipVerify bool `json:"oidc_tls_insecure_skip_verify"`
} }
type TLSSettings struct {
HTTPAddrs []string `json:"http_addrs"`
HTTPSAddrs []string `json:"https_addrs"`
TLSServerCertSource string `json:"tls_server_cert_source"`
TLSCertFile string `json:"tls_cert_file"`
TLSKeyFile string `json:"tls_key_file"`
TLSPKIServerCertID string `json:"tls_pki_server_cert_id"`
TLSClientAuth string `json:"tls_client_auth"`
TLSClientCAFile string `json:"tls_client_ca_file"`
TLSPKIClientCAID string `json:"tls_pki_client_ca_id"`
TLSMinVersion string `json:"tls_min_version"`
}
type TLSListener struct {
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
HTTPAddrs []string `json:"http_addrs"`
HTTPSAddrs []string `json:"https_addrs"`
AuthPolicy string `json:"auth_policy"`
ApplyPolicyAPI bool `json:"apply_policy_api"`
ApplyPolicyGit bool `json:"apply_policy_git"`
ApplyPolicyRPM bool `json:"apply_policy_rpm"`
ApplyPolicyV2 bool `json:"apply_policy_v2"`
ClientCertAllowlist []string `json:"client_cert_allowlist"`
TLSServerCertSource string `json:"tls_server_cert_source"`
TLSCertFile string `json:"tls_cert_file"`
TLSKeyFile string `json:"tls_key_file"`
TLSPKIServerCertID string `json:"tls_pki_server_cert_id"`
TLSClientAuth string `json:"tls_client_auth"`
TLSClientCAFile string `json:"tls_client_ca_file"`
TLSPKIClientCAID string `json:"tls_pki_client_ca_id"`
TLSMinVersion string `json:"tls_min_version"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type PKICA struct {
ID string `json:"id"`
Name string `json:"name"`
ParentCAID string `json:"parent_ca_id"`
IsRoot bool `json:"is_root"`
CertPEM string `json:"cert_pem"`
KeyPEM string `json:"key_pem"`
SerialCounter int64 `json:"serial_counter"`
Status string `json:"status"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type PKICert struct {
ID string `json:"id"`
CAID string `json:"ca_id"`
SerialHex string `json:"serial_hex"`
CommonName string `json:"common_name"`
SANDNS string `json:"san_dns"`
SANIPs string `json:"san_ips"`
IsCA bool `json:"is_ca"`
CertPEM string `json:"cert_pem"`
KeyPEM string `json:"key_pem"`
NotBefore int64 `json:"not_before"`
NotAfter int64 `json:"not_after"`
Status string `json:"status"`
RevokedAt int64 `json:"revoked_at"`
RevocationReason string `json:"revocation_reason"`
CreatedAt int64 `json:"created_at"`
}
type ServicePrincipal struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
IsAdmin bool `json:"is_admin"`
Disabled bool `json:"disabled"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type CertPrincipalBinding struct {
Fingerprint string `json:"fingerprint"`
PrincipalID string `json:"principal_id"`
Enabled bool `json:"enabled"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type PrincipalProjectRole struct {
PrincipalID string `json:"principal_id"`
ProjectID string `json:"project_id"`
Role string `json:"role"`
CreatedAt int64 `json:"created_at"`
}

View File

@@ -0,0 +1,33 @@
CREATE TABLE IF NOT EXISTS pki_cas (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
parent_ca_id TEXT,
is_root INTEGER NOT NULL DEFAULT 0,
cert_pem TEXT NOT NULL,
key_pem TEXT NOT NULL,
serial_counter INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'active',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(parent_ca_id) REFERENCES pki_cas(id)
);
CREATE TABLE IF NOT EXISTS pki_certs (
id TEXT PRIMARY KEY,
ca_id TEXT NOT NULL,
serial_hex TEXT NOT NULL,
common_name TEXT NOT NULL,
san_dns TEXT NOT NULL DEFAULT '',
san_ips TEXT NOT NULL DEFAULT '',
is_ca INTEGER NOT NULL DEFAULT 0,
cert_pem TEXT NOT NULL,
key_pem TEXT NOT NULL,
not_before INTEGER NOT NULL,
not_after INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
revoked_at INTEGER NOT NULL DEFAULT 0,
revocation_reason TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
FOREIGN KEY(ca_id) REFERENCES pki_cas(id) ON DELETE CASCADE,
UNIQUE(ca_id, serial_hex)
);

View File

@@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS tls_listeners (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
enabled INTEGER NOT NULL DEFAULT 1,
http_addrs TEXT NOT NULL DEFAULT '',
https_addrs TEXT NOT NULL DEFAULT '',
tls_server_cert_source TEXT NOT NULL DEFAULT 'files',
tls_cert_file TEXT NOT NULL DEFAULT '',
tls_key_file TEXT NOT NULL DEFAULT '',
tls_pki_server_cert_id TEXT NOT NULL DEFAULT '',
tls_client_auth TEXT NOT NULL DEFAULT 'none',
tls_client_ca_file TEXT NOT NULL DEFAULT '',
tls_pki_client_ca_id TEXT NOT NULL DEFAULT '',
tls_min_version TEXT NOT NULL DEFAULT '1.2',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);

View File

@@ -0,0 +1,6 @@
ALTER TABLE tls_listeners ADD COLUMN auth_policy TEXT NOT NULL DEFAULT 'default';
ALTER TABLE tls_listeners ADD COLUMN apply_policy_api INTEGER NOT NULL DEFAULT 1;
ALTER TABLE tls_listeners ADD COLUMN apply_policy_git INTEGER NOT NULL DEFAULT 1;
ALTER TABLE tls_listeners ADD COLUMN apply_policy_rpm INTEGER NOT NULL DEFAULT 1;
ALTER TABLE tls_listeners ADD COLUMN apply_policy_v2 INTEGER NOT NULL DEFAULT 1;
ALTER TABLE tls_listeners ADD COLUMN client_cert_allowlist TEXT NOT NULL DEFAULT '';

View File

@@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS service_principals (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
disabled INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS cert_principal_bindings (
fingerprint TEXT PRIMARY KEY,
principal_id TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (principal_id) REFERENCES service_principals(id) ON DELETE CASCADE
);

View File

@@ -0,0 +1,11 @@
ALTER TABLE service_principals ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0;
CREATE TABLE IF NOT EXISTS principal_project_roles (
principal_id TEXT NOT NULL,
project_id TEXT NOT NULL,
role TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (principal_id, project_id),
FOREIGN KEY (principal_id) REFERENCES service_principals(id) ON DELETE CASCADE,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);

View File

@@ -0,0 +1,31 @@
CREATE TABLE IF NOT EXISTS pki_certs_new (
id TEXT PRIMARY KEY,
ca_id TEXT,
serial_hex TEXT NOT NULL,
common_name TEXT NOT NULL,
san_dns TEXT NOT NULL DEFAULT '',
san_ips TEXT NOT NULL DEFAULT '',
is_ca INTEGER NOT NULL DEFAULT 0,
cert_pem TEXT NOT NULL,
key_pem TEXT NOT NULL,
not_before INTEGER NOT NULL,
not_after INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
revoked_at INTEGER NOT NULL DEFAULT 0,
revocation_reason TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
FOREIGN KEY(ca_id) REFERENCES pki_cas(id) ON DELETE CASCADE,
UNIQUE(ca_id, serial_hex)
);
INSERT INTO pki_certs_new (
id, ca_id, 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
)
SELECT
id, ca_id, 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
FROM pki_certs;
DROP TABLE pki_certs;
ALTER TABLE pki_certs_new RENAME TO pki_certs;

View File

@@ -193,6 +193,31 @@ export interface AdminAPIKey extends APIKey {
email: string email: string
} }
export interface ServicePrincipal {
id: string
name: string
description: string
is_admin: boolean
disabled: boolean
created_at: number
updated_at: number
}
export interface CertPrincipalBinding {
fingerprint: string
principal_id: string
enabled: boolean
created_at: number
updated_at: number
}
export interface PrincipalProjectRole {
principal_id: string
project_id: string
role: 'viewer' | 'writer' | 'admin'
created_at: number
}
export interface AuthSettings { export interface AuthSettings {
auth_mode: 'db' | 'ldap' | 'hybrid' auth_mode: 'db' | 'ldap' | 'hybrid'
oidc_enabled: boolean oidc_enabled: boolean
@@ -212,12 +237,87 @@ export interface AuthSettings {
oidc_tls_insecure_skip_verify: boolean oidc_tls_insecure_skip_verify: boolean
} }
export interface TLSSettings {
http_addrs: string[]
https_addrs: string[]
tls_server_cert_source: 'pki'
tls_cert_file: string
tls_key_file: string
tls_pki_server_cert_id: string
tls_client_auth: 'none' | 'request' | 'require' | 'verify_if_given' | 'require_and_verify'
tls_client_ca_file: string
tls_pki_client_ca_id: string
tls_min_version: '1.0' | '1.1' | '1.2' | '1.3'
}
export interface TLSListener {
id: string
name: string
enabled: boolean
http_addrs: string[]
https_addrs: string[]
auth_policy: 'default' | 'read_open_write_cert' | 'read_open_write_cert_or_auth' | 'cert_only' | 'read_only_public'
apply_policy_api: boolean
apply_policy_git: boolean
apply_policy_rpm: boolean
apply_policy_v2: boolean
client_cert_allowlist: string[]
tls_server_cert_source: 'pki'
tls_cert_file: string
tls_key_file: string
tls_pki_server_cert_id: string
tls_client_auth: 'none' | 'request' | 'require' | 'verify_if_given' | 'require_and_verify'
tls_client_ca_file: string
tls_pki_client_ca_id: string
tls_min_version: '1.0' | '1.1' | '1.2' | '1.3'
created_at: number
updated_at: number
}
export interface OIDCStatus { export interface OIDCStatus {
enabled: boolean enabled: boolean
configured?: boolean configured?: boolean
auth_mode?: string auth_mode?: string
} }
export interface PKICA {
id: string
name: string
parent_ca_id: string
is_root: boolean
status: string
created_at: number
updated_at: number
}
export interface PKICADetail extends PKICA {
cert_pem: string
key_pem: string
serial_counter: number
}
export interface PKICert {
id: string
ca_id: string
serial_hex: string
common_name: string
fingerprint?: string
san_dns: string
san_ips: string
is_ca: boolean
not_before: number
not_after: number
status: string
revoked_at: number
revocation_reason: string
created_at: number
}
export interface PKICertDetail extends PKICert {
cert_pem: string
key_pem: string
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> { async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(path, { const res = await fetch(path, {
credentials: 'include', credentials: 'include',
@@ -287,6 +387,22 @@ export const api = {
deleteAdminAPIKey: (id: string) => request<void>(`/api/admin/api-keys/${id}`, { method: 'DELETE' }), deleteAdminAPIKey: (id: string) => request<void>(`/api/admin/api-keys/${id}`, { method: 'DELETE' }),
disableAdminAPIKey: (id: string) => request<void>(`/api/admin/api-keys/${id}/disable`, { method: 'POST' }), disableAdminAPIKey: (id: string) => request<void>(`/api/admin/api-keys/${id}/disable`, { method: 'POST' }),
enableAdminAPIKey: (id: string) => request<void>(`/api/admin/api-keys/${id}/enable`, { method: 'POST' }), enableAdminAPIKey: (id: string) => request<void>(`/api/admin/api-keys/${id}/enable`, { method: 'POST' }),
listServicePrincipals: () => request<ServicePrincipal[]>('/api/admin/service-principals'),
createServicePrincipal: (payload: { name: string; description?: string; is_admin?: boolean; disabled?: boolean }) =>
request<ServicePrincipal>('/api/admin/service-principals', { method: 'POST', body: JSON.stringify(payload) }),
updateServicePrincipal: (id: string, payload: { name: string; description?: string; is_admin: boolean; disabled: boolean }) =>
request<ServicePrincipal>(`/api/admin/service-principals/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }),
deleteServicePrincipal: (id: string) => request<void>(`/api/admin/service-principals/${id}`, { method: 'DELETE' }),
listPrincipalProjectRoles: (principalID: string) => request<PrincipalProjectRole[]>(`/api/admin/service-principals/${principalID}/roles`),
upsertPrincipalProjectRole: (principalID: string, payload: { project_id: string; role: 'viewer' | 'writer' | 'admin' }) =>
request<PrincipalProjectRole>(`/api/admin/service-principals/${principalID}/roles`, { method: 'POST', body: JSON.stringify(payload) }),
deletePrincipalProjectRole: (principalID: string, projectID: string) =>
request<void>(`/api/admin/service-principals/${principalID}/roles/${projectID}`, { method: 'DELETE' }),
listCertPrincipalBindings: () => request<CertPrincipalBinding[]>('/api/admin/cert-principal-bindings'),
upsertCertPrincipalBinding: (payload: { fingerprint: string; principal_id: string; enabled: boolean }) =>
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'), getAuthSettings: () => request<AuthSettings>('/api/admin/auth'),
updateAuthSettings: (payload: AuthSettings) => updateAuthSettings: (payload: AuthSettings) =>
request<AuthSettings>('/api/admin/auth', { method: 'PATCH', body: JSON.stringify(payload) }), request<AuthSettings>('/api/admin/auth', { method: 'PATCH', body: JSON.stringify(payload) }),
@@ -299,6 +415,49 @@ export const api = {
body: JSON.stringify(payload), body: JSON.stringify(payload),
signal signal
}), }),
getTLSSettings: () => request<TLSSettings>('/api/admin/tls'),
updateTLSSettings: (payload: TLSSettings) =>
request<TLSSettings>('/api/admin/tls', { method: 'PATCH', body: JSON.stringify(payload) }),
listTLSListeners: () => request<TLSListener[]>('/api/admin/tls/listeners'),
getTLSListenerRuntimeStatus: () => request<Record<string, number>>('/api/admin/tls/listeners/runtime'),
createTLSListener: (payload: Omit<TLSListener, 'id' | 'created_at' | 'updated_at'>) =>
request<TLSListener>('/api/admin/tls/listeners', { method: 'POST', body: JSON.stringify(payload) }),
updateTLSListener: (id: string, payload: Omit<TLSListener, 'id' | 'created_at' | 'updated_at'>) =>
request<TLSListener>(`/api/admin/tls/listeners/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }),
deleteTLSListener: (id: string) => request<void>(`/api/admin/tls/listeners/${id}`, { method: 'DELETE' }),
listPKICAs: () => request<PKICA[]>('/api/admin/pki/cas'),
getPKICA: (id: string) => request<PKICADetail>(`/api/admin/pki/cas/${id}`),
updatePKICA: (id: string, payload: { name: string }) =>
request<PKICADetail>(`/api/admin/pki/cas/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }),
downloadPKICABundle: (id: string) => requestBinary(`/api/admin/pki/cas/${id}/bundle`),
createPKIRootCA: (payload: { name: string; common_name: string; days: number; cert_pem?: string; key_pem?: string }) =>
request<PKICA>('/api/admin/pki/cas/root', { method: 'POST', body: JSON.stringify(payload) }),
createPKIIntermediateCA: (payload: { name: string; parent_ca_id: string; common_name: string; days: number; cert_pem?: string; key_pem?: string }) =>
request<PKICA>('/api/admin/pki/cas/intermediate', { method: 'POST', body: JSON.stringify(payload) }),
deletePKICA: (id: string, force?: boolean) =>
request<void>(`/api/admin/pki/cas/${id}${force ? '?force=1' : ''}`, { method: 'DELETE' }),
listPKICerts: (ca_id?: string) => {
const params = new URLSearchParams()
if (ca_id) params.set('ca_id', ca_id)
const qs = params.toString()
return request<PKICert[]>(`/api/admin/pki/certs${qs ? `?${qs}` : ''}`)
},
getPKICert: (id: string) => request<PKICertDetail>(`/api/admin/pki/certs/${id}`),
downloadPKICertBundle: (id: string) => requestBinary(`/api/admin/pki/certs/${id}/bundle`),
issuePKICert: (payload: {
ca_id: string
common_name: string
san_dns: string[]
san_ips: string[]
days: number
is_ca: boolean
}) => request<PKICert>('/api/admin/pki/certs', { method: 'POST', body: JSON.stringify(payload) }),
importPKICert: (payload: { ca_id?: string; cert_pem: string; key_pem: string }) =>
request<PKICert>('/api/admin/pki/certs/import', { method: 'POST', body: JSON.stringify(payload) }),
revokePKICert: (id: string, reason: string) =>
request<{ status: string }>(`/api/admin/pki/certs/${id}/revoke`, { method: 'POST', body: JSON.stringify({ reason }) }),
deletePKICert: (id: string) => request<void>(`/api/admin/pki/certs/${id}`, { method: 'DELETE' }),
getPKICRL: (id: string) => request<{ crl_pem: string }>(`/api/admin/pki/cas/${id}/crl`),
listUsers: () => request<User[]>('/api/users'), listUsers: () => request<User[]>('/api/users'),
createUser: (payload: { username: string; display_name: string; email: string; password: string; is_admin: boolean }) => createUser: (payload: { username: string; display_name: string; email: string; password: string; is_admin: boolean }) =>

View File

@@ -24,6 +24,9 @@ import KeyIcon from '@mui/icons-material/Key'
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings' import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'
import PersonIcon from '@mui/icons-material/Person' import PersonIcon from '@mui/icons-material/Person'
import BadgeIcon from '@mui/icons-material/Badge' import BadgeIcon from '@mui/icons-material/Badge'
import SecurityIcon from '@mui/icons-material/Security'
import HttpsIcon from '@mui/icons-material/Https'
import VpnKeyIcon from '@mui/icons-material/VpnKey'
import DarkModeIcon from '@mui/icons-material/DarkMode' import DarkModeIcon from '@mui/icons-material/DarkMode'
import LightModeIcon from '@mui/icons-material/LightMode' import LightModeIcon from '@mui/icons-material/LightMode'
import { ThemeModeContext } from './ThemeModeContext' import { ThemeModeContext } from './ThemeModeContext'
@@ -58,7 +61,10 @@ export default function Layout() {
if (user?.is_admin) { if (user?.is_admin) {
items.push({ label: 'Admin Users', path: '/admin/users', icon: <PeopleIcon fontSize="small" /> }) items.push({ label: 'Admin Users', path: '/admin/users', icon: <PeopleIcon fontSize="small" /> })
items.push({ label: 'Admin API Keys', path: '/admin/api-keys', icon: <AdminPanelSettingsIcon fontSize="small" /> }) items.push({ label: 'Admin API Keys', path: '/admin/api-keys', icon: <AdminPanelSettingsIcon fontSize="small" /> })
items.push({ label: 'Admin PKI', path: '/admin/pki', icon: <SecurityIcon fontSize="small" /> })
items.push({ label: 'Service Principals', path: '/admin/principals', icon: <VpnKeyIcon fontSize="small" /> })
items.push({ label: 'Site Auth', path: '/admin/auth', icon: <BadgeIcon fontSize="small" /> }) items.push({ label: 'Site Auth', path: '/admin/auth', icon: <BadgeIcon fontSize="small" /> })
items.push({ label: 'Site TLS', path: '/admin/tls', icon: <HttpsIcon fontSize="small" /> })
} }
return items return items
}, [user]) }, [user])

View File

@@ -17,6 +17,9 @@ import FilesPage from '../pages/FilesPage'
import AdminUsersPage from '../pages/AdminUsersPage' import AdminUsersPage from '../pages/AdminUsersPage'
import AdminApiKeysPage from '../pages/AdminApiKeysPage' import AdminApiKeysPage from '../pages/AdminApiKeysPage'
import AdminAuthLdapPage from '../pages/AdminAuthLdapPage' import AdminAuthLdapPage from '../pages/AdminAuthLdapPage'
import AdminPKIPage from '../pages/AdminPKIPage'
import AdminTLSSettingsPage from '../pages/AdminTLSSettingsPage'
import AdminServicePrincipalsPage from '../pages/AdminServicePrincipalsPage'
import ApiKeysPage from '../pages/ApiKeysPage' import ApiKeysPage from '../pages/ApiKeysPage'
import AccountPage from '../pages/AccountPage' import AccountPage from '../pages/AccountPage'
import NotFoundPage from '../pages/NotFoundPage' import NotFoundPage from '../pages/NotFoundPage'
@@ -44,7 +47,10 @@ export const routes: RouteObject[] = [
{ path: 'projects/:projectId/files', element: <FilesPage /> }, { path: 'projects/:projectId/files', element: <FilesPage /> },
{ path: 'admin/users', element: <AdminUsersPage /> }, { path: 'admin/users', element: <AdminUsersPage /> },
{ path: 'admin/api-keys', element: <AdminApiKeysPage /> }, { path: 'admin/api-keys', element: <AdminApiKeysPage /> },
{ path: 'admin/pki', element: <AdminPKIPage /> },
{ path: 'admin/principals', element: <AdminServicePrincipalsPage /> },
{ path: 'admin/auth', element: <AdminAuthLdapPage /> }, { path: 'admin/auth', element: <AdminAuthLdapPage /> },
{ path: 'admin/tls', element: <AdminTLSSettingsPage /> },
{ path: 'admin/auth/ldap', element: <AdminAuthLdapPage /> } { path: 'admin/auth/ldap', element: <AdminAuthLdapPage /> }
] ]
}, },

View File

@@ -36,7 +36,7 @@ export default function AdminAuthLdapPage() {
setLoading(true) setLoading(true)
api api
.getAuthSettings() .getAuthSettings()
.then((data) => setSettings(data)) .then((data) => setSettings(data))
.catch((err) => { .catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load authentication settings' const message = err instanceof Error ? err.message : 'Failed to load authentication settings'
setError(message) setError(message)
@@ -74,11 +74,14 @@ export default function AdminAuthLdapPage() {
setError(null) setError(null)
setTestResult(null) setTestResult(null)
try { try {
const result = await api.testAuthSettings({ const result = await api.testAuthSettings(
...settings, {
username: testUsername.trim() || undefined, ...settings,
password: testPassword || undefined username: testUsername.trim() || undefined,
}, controller.signal) password: testPassword || undefined
},
controller.signal
)
setTestResult(result.user ? `Connection ok. User test ok: ${result.user}` : 'Connection ok.') setTestResult(result.user ? `Connection ok. User test ok: ${result.user}` : 'Connection ok.')
} catch (err) { } catch (err) {
if (err instanceof Error && err.name === 'AbortError') { if (err instanceof Error && err.name === 'AbortError') {
@@ -107,7 +110,11 @@ export default function AdminAuthLdapPage() {
Admin: Site Authentication Admin: Site Authentication
</Typography> </Typography>
<Paper sx={{ p: 2, maxWidth: 820 }}> <Paper sx={{ p: 2, maxWidth: 820 }}>
{loading ? <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>Loading...</Typography> : null} {loading ? (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Loading...
</Typography>
) : null}
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null} {error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
{saved ? <Alert severity="success" sx={{ mb: 1 }}>Saved.</Alert> : null} {saved ? <Alert severity="success" sx={{ mb: 1 }}>Saved.</Alert> : null}
{testResult ? <Alert severity="success" sx={{ mb: 1 }}>{testResult}</Alert> : null} {testResult ? <Alert severity="success" sx={{ mb: 1 }}>{testResult}</Alert> : null}
@@ -126,19 +133,10 @@ export default function AdminAuthLdapPage() {
OIDC OIDC
</Typography> </Typography>
<FormControlLabel <FormControlLabel
control={ control={<Checkbox checked={settings.oidc_enabled} onChange={(event) => setSettings((prev) => ({ ...prev, oidc_enabled: event.target.checked }))} />}
<Checkbox
checked={settings.oidc_enabled}
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_enabled: event.target.checked }))}
/>
}
label="Enable OIDC login" label="Enable OIDC login"
/> />
<TextField <TextField label="Client ID" value={settings.oidc_client_id} onChange={(event) => setSettings((prev) => ({ ...prev, oidc_client_id: event.target.value }))} />
label="Client ID"
value={settings.oidc_client_id}
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_client_id: event.target.value }))}
/>
<TextField <TextField
label="Client Secret" label="Client Secret"
type="password" type="password"
@@ -172,27 +170,14 @@ export default function AdminAuthLdapPage() {
helperText="Example: openid profile email" helperText="Example: openid profile email"
/> />
<FormControlLabel <FormControlLabel
control={ control={<Checkbox checked={settings.oidc_tls_insecure_skip_verify} onChange={(event) => setSettings((prev) => ({ ...prev, oidc_tls_insecure_skip_verify: event.target.checked }))} />}
<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)" label="OIDC TLS insecure skip verify (testing/self-signed only)"
/> />
<Typography variant="subtitle2" sx={{ mt: 1 }}> <Typography variant="subtitle2" sx={{ mt: 1 }}>
LDAP LDAP
</Typography> </Typography>
<TextField <TextField label="LDAP URL" value={settings.ldap_url} onChange={(event) => setSettings((prev) => ({ ...prev, ldap_url: event.target.value }))} />
label="LDAP URL" <TextField label="Bind DN" value={settings.ldap_bind_dn} onChange={(event) => setSettings((prev) => ({ ...prev, ldap_bind_dn: event.target.value }))} />
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 <TextField
label="Bind Password" label="Bind Password"
type="password" type="password"
@@ -211,28 +196,14 @@ export default function AdminAuthLdapPage() {
helperText="Use {username} placeholder." helperText="Use {username} placeholder."
/> />
<FormControlLabel <FormControlLabel
control={ control={<Checkbox checked={settings.ldap_tls_insecure_skip_verify} onChange={(event) => setSettings((prev) => ({ ...prev, ldap_tls_insecure_skip_verify: event.target.checked }))} />}
<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)" label="TLS insecure skip verify (testing/self-signed only)"
/> />
<Typography variant="subtitle2" sx={{ mt: 1 }}> <Typography variant="subtitle2" sx={{ mt: 1 }}>
Test (optional user bind) Test (optional user bind)
</Typography> </Typography>
<TextField <TextField label="Test Username" value={testUsername} onChange={(event) => setTestUsername(event.target.value)} />
label="Test Username" <TextField label="Test Password" type="password" value={testPassword} onChange={(event) => setTestPassword(event.target.value)} />
value={testUsername}
onChange={(event) => setTestUsername(event.target.value)}
/>
<TextField
label="Test Password"
type="password"
value={testPassword}
onChange={(event) => setTestPassword(event.target.value)}
/>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 1 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 1 }}>
<Button variant="outlined" onClick={handleTest} color={testing ? 'warning' : 'primary'}> <Button variant="outlined" onClick={handleTest} color={testing ? 'warning' : 'primary'}>
{testing ? 'Cancel Test' : 'Test Connection'} {testing ? 'Cancel Test' : 'Test Connection'}

View File

@@ -0,0 +1,888 @@
import AddIcon from '@mui/icons-material/Add'
import BlockIcon from '@mui/icons-material/Block'
import DeleteIcon from '@mui/icons-material/Delete'
import DownloadIcon from '@mui/icons-material/Download'
import EditIcon from '@mui/icons-material/Edit'
import VisibilityIcon from '@mui/icons-material/Visibility'
import Alert from '@mui/material/Alert'
import {
Box,
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
IconButton,
List,
ListItem,
ListItemText,
MenuItem,
Paper,
TextField,
Typography
} from '@mui/material'
import { useEffect, useState } from 'react'
import { api, PKICA, PKICADetail, PKICert, PKICertDetail } from '../api'
function fmt(ts: number): string {
if (!ts || ts <= 0) {
return '-'
}
return new Date(ts * 1000).toLocaleString()
}
function downloadText(filename: string, text: string) {
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
function downloadBinary(filename: string, data: ArrayBuffer, contentType: string) {
const blob = new Blob([data], { type: contentType })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
async function readFileText(file: File): Promise<string> {
return file.text()
}
export default function AdminPKIPage() {
const [cas, setCAs] = useState<PKICA[]>([])
const [certs, setCerts] = useState<PKICert[]>([])
const [selectedCA, setSelectedCA] = useState('')
const [error, setError] = useState<string | null>(null)
const [dialogError, setDialogError] = useState<string | null>(null)
const [rootOpen, setRootOpen] = useState(false)
const [interOpen, setInterOpen] = useState(false)
const [issueOpen, setIssueOpen] = useState(false)
const [importOpen, setImportOpen] = useState(false)
const [revokeID, setRevokeID] = useState('')
const [revokeReason, setRevokeReason] = useState('')
const [deleteID, setDeleteID] = useState('')
const [deleteCAID, setDeleteCAID] = useState('')
const [deleteCAName, setDeleteCAName] = useState('')
const [deleteCAConfirm, setDeleteCAConfirm] = useState('')
const [deleteCAForce, setDeleteCAForce] = useState(false)
const [busy, setBusy] = useState(false)
const [viewCA, setViewCA] = useState<PKICADetail | null>(null)
const [viewCert, setViewCert] = useState<PKICertDetail | null>(null)
const [editCAID, setEditCAID] = useState('')
const [editCAName, setEditCAName] = useState('')
const [rootName, setRootName] = useState('')
const [rootCN, setRootCN] = useState('')
const [rootDays, setRootDays] = useState('3650')
const [rootCertPEM, setRootCertPEM] = useState('')
const [rootKeyPEM, setRootKeyPEM] = useState('')
const [interName, setInterName] = useState('')
const [interParent, setInterParent] = useState('')
const [interCN, setInterCN] = useState('')
const [interDays, setInterDays] = useState('1825')
const [interCertPEM, setInterCertPEM] = useState('')
const [interKeyPEM, setInterKeyPEM] = useState('')
const [issueCA, setIssueCA] = useState('')
const [issueCN, setIssueCN] = useState('')
const [issueDNS, setIssueDNS] = useState('')
const [issueIPs, setIssueIPs] = useState('')
const [issueDays, setIssueDays] = useState('365')
const [issueIsCA, setIssueIsCA] = useState(false)
const [importCA, setImportCA] = useState('')
const [importCertPEM, setImportCertPEM] = useState('')
const [importKeyPEM, setImportKeyPEM] = useState('')
const loadTextFile = async (event: React.ChangeEvent<HTMLInputElement>, setter: (value: string) => void) => {
const file = event.target.files && event.target.files[0]
let text: string
if (!file) {
return
}
text = await readFileText(file)
setter(text)
event.target.value = ''
}
const load = async () => {
let listCAs: PKICA[]
let listCerts: PKICert[]
listCAs = await api.listPKICAs()
setCAs(Array.isArray(listCAs) ? listCAs : [])
listCerts = await api.listPKICerts(selectedCA || undefined)
setCerts(Array.isArray(listCerts) ? listCerts : [])
}
useEffect(() => {
load().catch((err) => setError(err instanceof Error ? err.message : 'Failed to load PKI data'))
}, [])
useEffect(() => {
api
.listPKICerts(selectedCA || undefined)
.then((list) => setCerts(Array.isArray(list) ? list : []))
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load certificates'))
}, [selectedCA])
const createRoot = async () => {
let days: number
if (!rootName.trim()) {
setDialogError('Name is required.')
return
}
days = Number(rootDays) || 3650
setBusy(true)
setError(null)
setDialogError(null)
try {
await api.createPKIRootCA({
name: rootName.trim(),
common_name: rootCN.trim(),
days: days,
cert_pem: rootCertPEM.trim() || undefined,
key_pem: rootKeyPEM.trim() || undefined
})
setRootOpen(false)
await load()
} catch (err) {
setDialogError(err instanceof Error ? err.message : 'Failed to create root CA')
} finally {
setBusy(false)
}
}
const createIntermediate = async () => {
let days: number
if (!interName.trim() || !interParent) {
setDialogError('Name and parent CA are required.')
return
}
days = Number(interDays) || 1825
setBusy(true)
setError(null)
setDialogError(null)
try {
await api.createPKIIntermediateCA({
name: interName.trim(),
parent_ca_id: interParent,
common_name: interCN.trim(),
days: days,
cert_pem: interCertPEM.trim() || undefined,
key_pem: interKeyPEM.trim() || undefined
})
setInterOpen(false)
setInterCertPEM('')
setInterKeyPEM('')
await load()
} catch (err) {
setDialogError(err instanceof Error ? err.message : 'Failed to create intermediate CA')
} finally {
setBusy(false)
}
}
const issueCert = async () => {
let days: number
let dns: string[]
let ips: string[]
if (!issueCA || !issueCN.trim()) {
setDialogError('Issuer CA and common name are required.')
return
}
days = Number(issueDays) || 365
dns = issueDNS
.split(',')
.map((v) => v.trim())
.filter((v) => v)
ips = issueIPs
.split(',')
.map((v) => v.trim())
.filter((v) => v)
setBusy(true)
setError(null)
setDialogError(null)
try {
await api.issuePKICert({
ca_id: issueCA,
common_name: issueCN.trim(),
san_dns: dns,
san_ips: ips,
days: days,
is_ca: issueIsCA
})
setIssueOpen(false)
await load()
} catch (err) {
setDialogError(err instanceof Error ? err.message : 'Failed to issue certificate')
} finally {
setBusy(false)
}
}
const importCert = async () => {
let payload: { ca_id?: string; cert_pem: string; key_pem: string }
if (!importCertPEM.trim() || !importKeyPEM.trim()) {
setDialogError('Certificate PEM and private key PEM are required.')
return
}
payload = { cert_pem: importCertPEM.trim(), key_pem: importKeyPEM.trim() }
if (importCA.trim() != "") {
payload.ca_id = importCA.trim()
}
setBusy(true)
setError(null)
setDialogError(null)
try {
await api.importPKICert(payload)
setImportOpen(false)
setImportCA('')
setImportCertPEM('')
setImportKeyPEM('')
await load()
} catch (err) {
setDialogError(err instanceof Error ? err.message : 'Failed to import certificate')
} finally {
setBusy(false)
}
}
const revokeCert = async () => {
setBusy(true)
setError(null)
setDialogError(null)
try {
await api.revokePKICert(revokeID, revokeReason)
setRevokeID('')
setRevokeReason('')
await load()
} catch (err) {
setDialogError(err instanceof Error ? err.message : 'Failed to revoke certificate')
} finally {
setBusy(false)
}
}
const deleteCert = async () => {
setBusy(true)
setError(null)
setDialogError(null)
try {
await api.deletePKICert(deleteID)
setDeleteID('')
await load()
} catch (err) {
setDialogError(err instanceof Error ? err.message : 'Failed to delete certificate')
} finally {
setBusy(false)
}
}
const deleteCA = async () => {
setBusy(true)
setError(null)
setDialogError(null)
try {
await api.deletePKICA(deleteCAID, deleteCAForce)
setDeleteCAID('')
setDeleteCAName('')
setDeleteCAConfirm('')
setDeleteCAForce(false)
await load()
} catch (err) {
setDialogError(err instanceof Error ? err.message : 'Failed to delete CA')
} finally {
setBusy(false)
}
}
const openCAView = async (id: string) => {
let detail: PKICADetail
setDialogError(null)
detail = await api.getPKICA(id)
setViewCA(detail)
}
const openCAEdit = async (id: string) => {
let detail: PKICADetail
setDialogError(null)
detail = await api.getPKICA(id)
setEditCAID(detail.id)
setEditCAName(detail.name)
}
const saveCAEdit = async () => {
setBusy(true)
setDialogError(null)
try {
await api.updatePKICA(editCAID, { name: editCAName.trim() })
setEditCAID('')
setEditCAName('')
await load()
} catch (err) {
setDialogError(err instanceof Error ? err.message : 'Failed to update CA')
} finally {
setBusy(false)
}
}
const openCertView = async (id: string) => {
let detail: PKICertDetail
setDialogError(null)
detail = await api.getPKICert(id)
setViewCert(detail)
}
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h5">Admin: PKI</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => { setDialogError(null); setRootOpen(true) }}>
New Root CA
</Button>
<Button
variant="outlined"
startIcon={<AddIcon />}
onClick={() => {
setDialogError(null)
setInterCertPEM('')
setInterKeyPEM('')
setInterOpen(true)
}}
>
New Intermediate CA
</Button>
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => { setDialogError(null); setIssueOpen(true) }}>
Issue Certificate
</Button>
<Button
variant="outlined"
startIcon={<AddIcon />}
onClick={() => {
setDialogError(null)
setImportCA('')
setImportCertPEM('')
setImportKeyPEM('')
setImportOpen(true)
}}
>
Import Certificate
</Button>
</Box>
</Box>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
<Paper sx={{ p: 2, mb: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>Certificate Authorities</Typography>
<List>
{cas.map((ca) => (
<ListItem
key={ca.id}
divider
secondaryAction={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Button
size="small"
onClick={async () => {
const data = await api.getPKICRL(ca.id)
const blob = new Blob([data.crl_pem], { type: 'application/x-pem-file' })
const url = URL.createObjectURL(blob)
window.open(url, '_blank')
}}
>
CRL
</Button>
<IconButton size="small" onClick={() => openCAView(ca.id)} title="View details">
<VisibilityIcon fontSize="small" />
</IconButton>
<IconButton size="small" onClick={() => openCAEdit(ca.id)} title="Edit CA">
<EditIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => {
setDialogError(null)
setDeleteCAID(ca.id)
setDeleteCAName(ca.name)
setDeleteCAConfirm('')
setDeleteCAForce(false)
}}
title="Delete CA"
>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
}
>
<ListItemText
primary={`${ca.name} (${ca.id})`}
secondary={`${ca.is_root ? 'root' : 'intermediate'} · status: ${ca.status} · parent: ${ca.parent_ca_id || '-'} · updated: ${fmt(ca.updated_at)}`}
/>
</ListItem>
))}
</List>
</Paper>
<Paper sx={{ p: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="h6">Issued Certificates</Typography>
<TextField
select
size="small"
label="CA"
value={selectedCA}
onChange={(event) => setSelectedCA(event.target.value)}
sx={{ minWidth: 280 }}
>
<MenuItem value="">(all)</MenuItem>
<MenuItem value="standalone">(standalone)</MenuItem>
{cas.map((ca) => (
<MenuItem key={ca.id} value={ca.id}>{ca.name}</MenuItem>
))}
</TextField>
</Box>
<List>
{certs.map((cert) => (
<ListItem
key={cert.id}
divider
secondaryAction={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<IconButton size="small" onClick={() => openCertView(cert.id)} title="View details">
<VisibilityIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="warning"
onClick={() => setRevokeID(cert.id)}
disabled={cert.status === 'revoked'}
title="Revoke"
>
<BlockIcon fontSize="small" />
</IconButton>
<IconButton size="small" color="error" onClick={() => setDeleteID(cert.id)} title="Delete">
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
}
>
<ListItemText
primary={`${cert.common_name} (${cert.id})`}
secondary={`serial: ${cert.serial_hex} · ca: ${cert.ca_id || 'standalone'} · status: ${cert.status} · valid: ${fmt(cert.not_before)} ~ ${fmt(cert.not_after)}${cert.revoked_at ? ` · revoked: ${fmt(cert.revoked_at)}` : ''}`}
/>
</ListItem>
))}
</List>
</Paper>
<Dialog open={rootOpen} onClose={() => setRootOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">New Root CA</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<DialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<TextField label="Name" value={rootName} onChange={(event) => setRootName(event.target.value)} />
<TextField label="Common Name" value={rootCN} onChange={(event) => setRootCN(event.target.value)} />
<TextField label="Validity Days" value={rootDays} onChange={(event) => setRootDays(event.target.value)} />
<TextField
label="Import Certificate PEM (optional)"
multiline
minRows={6}
value={rootCertPEM}
onChange={(event) => setRootCertPEM(event.target.value)}
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
<Box>
<Button variant="outlined" component="label" size="small">
Load Certificate File
<input hidden type="file" accept=".pem,.crt,.cer,.txt" onChange={(event) => loadTextFile(event, setRootCertPEM)} />
</Button>
</Box>
<TextField
label="Import Private Key PEM (optional)"
multiline
minRows={6}
value={rootKeyPEM}
onChange={(event) => setRootKeyPEM(event.target.value)}
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
<Box>
<Button variant="outlined" component="label" size="small">
Load Private Key File
<input hidden type="file" accept=".pem,.key,.txt" onChange={(event) => loadTextFile(event, setRootKeyPEM)} />
</Button>
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setRootOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={createRoot} disabled={busy}>{busy ? 'Saving...' : 'Create'}</Button>
</DialogActions>
</Dialog>
<Dialog open={interOpen} onClose={() => setInterOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">New Intermediate CA</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<DialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<TextField label="Name" value={interName} onChange={(event) => setInterName(event.target.value)} />
<TextField
select
label="Parent CA"
value={interParent}
onChange={(event) => setInterParent(event.target.value)}
>
{cas.map((ca) => (
<MenuItem key={ca.id} value={ca.id}>{ca.name}</MenuItem>
))}
</TextField>
<TextField label="Common Name" value={interCN} onChange={(event) => setInterCN(event.target.value)} />
<TextField label="Validity Days" value={interDays} onChange={(event) => setInterDays(event.target.value)} />
<TextField
label="Import Intermediate Certificate PEM (optional)"
multiline
minRows={6}
value={interCertPEM}
onChange={(event) => setInterCertPEM(event.target.value)}
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
<Box>
<Button variant="outlined" component="label" size="small">
Load Intermediate Certificate File
<input hidden type="file" accept=".pem,.crt,.cer,.txt" onChange={(event) => loadTextFile(event, setInterCertPEM)} />
</Button>
</Box>
<TextField
label="Import Intermediate Private Key PEM (optional)"
multiline
minRows={6}
value={interKeyPEM}
onChange={(event) => setInterKeyPEM(event.target.value)}
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
<Box>
<Button variant="outlined" component="label" size="small">
Load Intermediate Private Key File
<input hidden type="file" accept=".pem,.key,.txt" onChange={(event) => loadTextFile(event, setInterKeyPEM)} />
</Button>
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setInterOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={createIntermediate} disabled={busy}>{busy ? 'Saving...' : 'Create'}</Button>
</DialogActions>
</Dialog>
<Dialog open={issueOpen} onClose={() => setIssueOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Issue Certificate</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<DialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<TextField select label="Issuer CA" value={issueCA} onChange={(event) => setIssueCA(event.target.value)}>
{cas.map((ca) => (
<MenuItem key={ca.id} value={ca.id}>{ca.name}</MenuItem>
))}
</TextField>
<TextField label="Common Name" value={issueCN} onChange={(event) => setIssueCN(event.target.value)} />
<TextField label="SAN DNS (comma-separated)" value={issueDNS} onChange={(event) => setIssueDNS(event.target.value)} />
<TextField label="SAN IPs (comma-separated)" value={issueIPs} onChange={(event) => setIssueIPs(event.target.value)} />
<TextField label="Validity Days" value={issueDays} onChange={(event) => setIssueDays(event.target.value)} />
<FormControlLabel control={<Checkbox checked={issueIsCA} onChange={(event) => setIssueIsCA(event.target.checked)} />} label="Issue as CA certificate" />
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setIssueOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={issueCert} disabled={busy}>{busy ? 'Saving...' : 'Issue'}</Button>
</DialogActions>
</Dialog>
<Dialog open={importOpen} onClose={() => setImportOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Import Certificate</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<DialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<TextField select label="Issuer CA (optional)" value={importCA} onChange={(event) => setImportCA(event.target.value)}>
<MenuItem value="">(none, standalone)</MenuItem>
{cas.map((ca) => (
<MenuItem key={ca.id} value={ca.id}>{ca.name}</MenuItem>
))}
</TextField>
<TextField
label="Certificate PEM"
multiline
minRows={6}
value={importCertPEM}
onChange={(event) => setImportCertPEM(event.target.value)}
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
<Box>
<Button variant="outlined" component="label" size="small">
Load Certificate File
<input hidden type="file" accept=".pem,.crt,.cer,.txt" onChange={(event) => loadTextFile(event, setImportCertPEM)} />
</Button>
</Box>
<TextField
label="Private Key PEM"
multiline
minRows={6}
value={importKeyPEM}
onChange={(event) => setImportKeyPEM(event.target.value)}
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
<Box>
<Button variant="outlined" component="label" size="small">
Load Private Key File
<input hidden type="file" accept=".pem,.key,.txt" onChange={(event) => loadTextFile(event, setImportKeyPEM)} />
</Button>
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setImportOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={importCert} disabled={busy}>{busy ? 'Saving...' : 'Import'}</Button>
</DialogActions>
</Dialog>
<Dialog open={Boolean(revokeID)} onClose={() => setRevokeID('')} maxWidth="xs" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Revoke Certificate</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<DialogContent>
<TextField fullWidth label="Reason (optional)" value={revokeReason} onChange={(event) => setRevokeReason(event.target.value)} />
</DialogContent>
<DialogActions>
<Button onClick={() => setRevokeID('')}>Cancel</Button>
<Button color="warning" variant="contained" onClick={revokeCert} disabled={busy}>{busy ? 'Working...' : 'Revoke'}</Button>
</DialogActions>
</Dialog>
<Dialog open={Boolean(deleteID)} onClose={() => setDeleteID('')} maxWidth="xs" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Delete Certificate</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary">Delete certificate permanently?</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteID('')}>Cancel</Button>
<Button color="error" variant="contained" onClick={deleteCert} disabled={busy}>{busy ? 'Working...' : 'Delete'}</Button>
</DialogActions>
</Dialog>
<Dialog open={Boolean(deleteCAID)} onClose={() => setDeleteCAID('')} maxWidth="xs" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Delete Certificate Authority</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Type the CA name to confirm deletion.
</Typography>
<TextField fullWidth label="CA Name" value={deleteCAConfirm} onChange={(event) => setDeleteCAConfirm(event.target.value)} />
<FormControlLabel
control={<Checkbox checked={deleteCAForce} onChange={(event) => setDeleteCAForce(event.target.checked)} />}
label="Force delete (includes child CAs and issued certs)"
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteCAID('')}>Cancel</Button>
<Button
color="error"
variant="contained"
onClick={deleteCA}
disabled={busy || deleteCAConfirm !== deleteCAName}
>
{busy ? 'Working...' : 'Delete CA'}
</Button>
</DialogActions>
</Dialog>
<Dialog open={Boolean(viewCA)} onClose={() => setViewCA(null)} maxWidth="md" fullWidth>
<DialogTitle>CA Details</DialogTitle>
<DialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<TextField label="ID" value={viewCA?.id || ''} InputProps={{ readOnly: true }} />
<TextField label="Name" value={viewCA?.name || ''} InputProps={{ readOnly: true }} />
<TextField label="Parent CA" value={viewCA?.parent_ca_id || '-'} InputProps={{ readOnly: true }} />
<TextField label="Status" value={viewCA?.status || ''} InputProps={{ readOnly: true }} />
<TextField
label="Certificate PEM"
multiline
minRows={8}
value={viewCA?.cert_pem || ''}
InputProps={{ readOnly: true }}
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
<TextField
label="Private Key PEM"
multiline
minRows={8}
value={viewCA?.key_pem || ''}
InputProps={{ readOnly: true }}
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
</Box>
</DialogContent>
<DialogActions>
<Button
startIcon={<DownloadIcon />}
onClick={async () => {
if (!viewCA) return
const data = await api.downloadPKICABundle(viewCA.id)
downloadBinary(`${viewCA.name || viewCA.id}.ca.bundle.zip`, data, 'application/zip')
}}
>
Download Bundle
</Button>
<Button
startIcon={<DownloadIcon />}
onClick={() => {
if (!viewCA) return
downloadText(`${viewCA.name || viewCA.id}.ca.crt.pem`, viewCA.cert_pem || '')
}}
>
Download Cert
</Button>
<Button
startIcon={<DownloadIcon />}
onClick={() => {
if (!viewCA) return
downloadText(`${viewCA.name || viewCA.id}.ca.key.pem`, viewCA.key_pem || '')
}}
>
Download Key
</Button>
<Button onClick={() => setViewCA(null)}>Close</Button>
</DialogActions>
</Dialog>
<Dialog open={Boolean(editCAID)} onClose={() => setEditCAID('')} maxWidth="xs" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Edit CA</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<DialogContent>
<TextField
fullWidth
label="Name"
value={editCAName}
onChange={(event) => setEditCAName(event.target.value)}
sx={{ mt: 1 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setEditCAID('')}>Cancel</Button>
<Button variant="contained" onClick={saveCAEdit} disabled={busy || !editCAName.trim()}>
{busy ? 'Saving...' : 'Save'}
</Button>
</DialogActions>
</Dialog>
<Dialog open={Boolean(viewCert)} onClose={() => setViewCert(null)} maxWidth="md" fullWidth>
<DialogTitle>Certificate Details</DialogTitle>
<DialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<TextField label="ID" value={viewCert?.id || ''} InputProps={{ readOnly: true }} />
<TextField label="Issuer CA ID" value={viewCert?.ca_id || 'standalone'} InputProps={{ readOnly: true }} />
<TextField label="Serial" value={viewCert?.serial_hex || ''} InputProps={{ readOnly: true }} />
<TextField label="Common Name" value={viewCert?.common_name || ''} InputProps={{ readOnly: true }} />
<TextField label="SAN DNS" value={viewCert?.san_dns || ''} InputProps={{ readOnly: true }} />
<TextField label="SAN IPs" value={viewCert?.san_ips || ''} InputProps={{ readOnly: true }} />
<TextField label="Status" value={viewCert?.status || ''} InputProps={{ readOnly: true }} />
<TextField label="Not Before" value={fmt(viewCert?.not_before || 0)} InputProps={{ readOnly: true }} />
<TextField label="Not After" value={fmt(viewCert?.not_after || 0)} InputProps={{ readOnly: true }} />
<TextField
label="Certificate PEM"
multiline
minRows={8}
value={viewCert?.cert_pem || ''}
InputProps={{ readOnly: true }}
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
<TextField
label="Private Key PEM"
multiline
minRows={8}
value={viewCert?.key_pem || ''}
InputProps={{ readOnly: true }}
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
</Box>
</DialogContent>
<DialogActions>
<Button
startIcon={<DownloadIcon />}
onClick={async () => {
if (!viewCert) return
const data = await api.downloadPKICertBundle(viewCert.id)
downloadBinary(`${viewCert.common_name || viewCert.id}.bundle.zip`, data, 'application/zip')
}}
>
Download Bundle
</Button>
<Button
startIcon={<DownloadIcon />}
onClick={() => {
if (!viewCert) return
downloadText(`${viewCert.common_name || viewCert.id}.crt.pem`, viewCert.cert_pem || '')
}}
>
Download Cert
</Button>
<Button
startIcon={<DownloadIcon />}
onClick={() => {
if (!viewCert) return
downloadText(`${viewCert.common_name || viewCert.id}.key.pem`, viewCert.key_pem || '')
}}
>
Download Key
</Button>
<Button onClick={() => setViewCert(null)}>Close</Button>
</DialogActions>
</Dialog>
</Box>
)
}

View File

@@ -0,0 +1,488 @@
import Alert from '@mui/material/Alert'
import {
Box,
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
MenuItem,
Paper,
TextField,
Typography
} from '@mui/material'
import { useEffect, useState } from 'react'
import { api, CertPrincipalBinding, PKICert, PrincipalProjectRole, Project, ServicePrincipal } from '../api'
function fmt(ts: number): string {
if (!ts || ts <= 0) return '-'
return new Date(ts * 1000).toLocaleString()
}
export default function AdminServicePrincipalsPage() {
const [principals, setPrincipals] = useState<ServicePrincipal[]>([])
const [bindings, setBindings] = useState<CertPrincipalBinding[]>([])
const [pkiCerts, setPKICerts] = useState<PKICert[]>([])
const [projects, setProjects] = useState<Project[]>([])
const [principalRoles, setPrincipalRoles] = useState<Record<string, PrincipalProjectRole[]>>({})
const [error, setError] = useState<string | null>(null)
const [dialogError, setDialogError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [bindingOpen, setBindingOpen] = useState(false)
const [bindSource, setBindSource] = useState<'pki' | 'manual'>('pki')
const [bindPKICertID, setBindPKICertID] = useState('')
const [bindFingerprint, setBindFingerprint] = useState('')
const [bindPrincipalID, setBindPrincipalID] = useState('')
const [busy, setBusy] = useState(false)
const [rolePrincipalID, setRolePrincipalID] = useState('')
const [roleProjectID, setRoleProjectID] = useState('')
const [roleValue, setRoleValue] = useState<'viewer' | 'writer' | 'admin'>('writer')
const [rolePrincipalFilter, setRolePrincipalFilter] = useState('')
const [roleProjectFilter, setRoleProjectFilter] = useState('')
const [roleSearch, setRoleSearch] = useState('')
const load = async () => {
setLoading(true)
setError(null)
try {
const p = await api.listServicePrincipals()
const b = await api.listCertPrincipalBindings()
const c = await api.listPKICerts()
const allProjects = await api.listProjects(1000, 0, '')
setPrincipals(Array.isArray(p) ? p : [])
setBindings(Array.isArray(b) ? b : [])
setPKICerts(Array.isArray(c) ? c : [])
setProjects(Array.isArray(allProjects) ? allProjects : [])
const roleMap: Record<string, PrincipalProjectRole[]> = {}
let i: number
let roles: PrincipalProjectRole[]
for (i = 0; i < (Array.isArray(p) ? p.length : 0); i++) {
roles = await api.listPrincipalProjectRoles(p[i].id)
roleMap[p[i].id] = Array.isArray(roles) ? roles : []
}
setPrincipalRoles(roleMap)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load data')
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [])
const createPrincipal = async () => {
if (!name.trim()) {
setDialogError('Name is required.')
return
}
setBusy(true)
setDialogError(null)
try {
await api.createServicePrincipal({ name: name.trim(), description: description.trim(), disabled: false })
setCreateOpen(false)
setName('')
setDescription('')
await load()
} catch (err) {
setDialogError(err instanceof Error ? err.message : 'Failed to create principal')
} finally {
setBusy(false)
}
}
const togglePrincipal = async (item: ServicePrincipal) => {
setError(null)
try {
await api.updateServicePrincipal(item.id, { name: item.name, description: item.description, is_admin: item.is_admin, disabled: !item.disabled })
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update principal')
}
}
const deletePrincipal = async (item: ServicePrincipal) => {
if (!window.confirm(`Delete principal "${item.name}"?`)) return
setError(null)
try {
await api.deleteServicePrincipal(item.id)
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete principal')
}
}
const upsertBinding = async () => {
let fingerprint: string
let cert: PKICert | undefined
fingerprint = ''
if (bindSource === 'pki') {
cert = pkiCerts.find((item) => item.id === bindPKICertID)
fingerprint = (cert?.fingerprint || '').trim().toLowerCase()
} else {
fingerprint = bindFingerprint.trim().toLowerCase()
}
if (!fingerprint || !bindPrincipalID) {
setDialogError('Fingerprint and principal are required.')
return
}
setBusy(true)
setDialogError(null)
try {
await api.upsertCertPrincipalBinding({
fingerprint: fingerprint,
principal_id: bindPrincipalID,
enabled: true
})
setBindingOpen(false)
setBindSource('pki')
setBindPKICertID('')
setBindFingerprint('')
setBindPrincipalID('')
await load()
} catch (err) {
setDialogError(err instanceof Error ? err.message : 'Failed to save binding')
} finally {
setBusy(false)
}
}
const toggleBinding = async (item: CertPrincipalBinding) => {
setError(null)
try {
await api.upsertCertPrincipalBinding({ fingerprint: item.fingerprint, principal_id: item.principal_id, enabled: !item.enabled })
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update binding')
}
}
const deleteBinding = async (item: CertPrincipalBinding) => {
if (!window.confirm(`Delete binding for fingerprint ${item.fingerprint}?`)) return
setError(null)
try {
await api.deleteCertPrincipalBinding(item.fingerprint)
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete binding')
}
}
const principalName = (id: string): string => {
const p = principals.find((item) => item.id === id)
return p ? p.name : id
}
const togglePrincipalAdmin = async (item: ServicePrincipal) => {
setError(null)
try {
await api.updateServicePrincipal(item.id, { name: item.name, description: item.description, is_admin: !item.is_admin, disabled: item.disabled })
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update principal admin flag')
}
}
const upsertRole = async () => {
if (!rolePrincipalID || !roleProjectID) {
setDialogError('Principal and project are required for role assignment.')
return
}
setBusy(true)
setDialogError(null)
try {
await api.upsertPrincipalProjectRole(rolePrincipalID, { project_id: roleProjectID, role: roleValue })
setRolePrincipalID('')
setRoleProjectID('')
setRoleValue('writer')
await load()
} catch (err) {
setDialogError(err instanceof Error ? err.message : 'Failed to assign role')
} finally {
setBusy(false)
}
}
const deleteRole = async (principalID: string, projectID: string) => {
setError(null)
try {
await api.deletePrincipalProjectRole(principalID, projectID)
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete role assignment')
}
}
const projectName = (id: string): string => {
const p = projects.find((item) => item.id === id)
if (!p) return id
return `${p.name} (${p.slug})`
}
const pkiCertByFingerprint = (fingerprint: string): PKICert | undefined => {
return pkiCerts.find((item) => (item.fingerprint || '').toLowerCase() === (fingerprint || '').toLowerCase())
}
const filteredPrincipals = principals.filter((principal) => {
if (rolePrincipalFilter && principal.id !== rolePrincipalFilter) return false
const roles = principalRoles[principal.id] || []
if (roleProjectFilter && !roles.some((item) => item.project_id === roleProjectFilter)) return false
if (roleSearch.trim()) {
const q = roleSearch.trim().toLowerCase()
const hitPrincipal =
principal.name.toLowerCase().includes(q) ||
principal.id.toLowerCase().includes(q) ||
principal.description.toLowerCase().includes(q)
if (hitPrincipal) return true
return roles.some((item) => {
const project = projects.find((p) => p.id === item.project_id)
const projectText = project ? `${project.name} ${project.slug}`.toLowerCase() : ''
return item.role.toLowerCase().includes(q) || item.project_id.toLowerCase().includes(q) || projectText.includes(q)
})
}
return true
})
return (
<Box>
<Typography variant="h5" sx={{ mb: 2 }}>Admin: Service Principals</Typography>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
<Box sx={{ display: 'grid', gap: 1 }}>
<Paper sx={{ p: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="h6">Principals</Typography>
<Button variant="outlined" onClick={() => { setDialogError(null); setCreateOpen(true) }}>New Principal</Button>
</Box>
{loading && principals.length === 0 ? <Typography variant="body2" color="text.secondary">Loading...</Typography> : null}
<Box sx={{ display: 'grid', gap: 1 }}>
{principals.map((item) => (
<Paper key={item.id} variant="outlined" sx={{ p: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2">{item.name} ({item.id})</Typography>
<Chip size="small" color={item.disabled ? 'default' : 'success'} label={item.disabled ? 'Disabled' : 'Active'} />
{item.is_admin ? <Chip size="small" color="warning" label="Principal Admin" /> : null}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button size="small" color={item.is_admin ? 'warning' : 'primary'} onClick={() => togglePrincipalAdmin(item)}>
{item.is_admin ? 'Unset Admin' : 'Set Admin'}
</Button>
<Button size="small" color={item.disabled ? 'success' : 'warning'} onClick={() => togglePrincipal(item)}>
{item.disabled ? 'Enable' : 'Disable'}
</Button>
<Button size="small" color="error" onClick={() => deletePrincipal(item)}>Delete</Button>
</Box>
</Box>
<Typography variant="caption" color="text.secondary">
{item.description || '(no description)'} · updated: {fmt(item.updated_at)}
</Typography>
</Paper>
))}
</Box>
</Paper>
<Paper sx={{ p: 2 }}>
<Typography variant="h6" sx={{ mb: 0.5 }}>Project Role Assignments</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
Define what each principal can do per project.
</Typography>
{dialogError ? <Alert severity="error" sx={{ mb: 1 }}>{dialogError}</Alert> : null}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr auto', gap: 1, mb: 1 }}>
<TextField select label="Principal" value={rolePrincipalID} onChange={(event) => setRolePrincipalID(event.target.value)}>
{principals.map((item) => (
<MenuItem key={item.id} value={item.id}>{item.name}</MenuItem>
))}
</TextField>
<TextField select label="Project" value={roleProjectID} onChange={(event) => setRoleProjectID(event.target.value)}>
{projects.map((item) => (
<MenuItem key={item.id} value={item.id}>{item.name} ({item.slug})</MenuItem>
))}
</TextField>
<TextField select label="Role" value={roleValue} onChange={(event) => setRoleValue(event.target.value as 'viewer' | 'writer' | 'admin')}>
<MenuItem value="viewer">viewer</MenuItem>
<MenuItem value="writer">writer</MenuItem>
<MenuItem value="admin">admin</MenuItem>
</TextField>
<Button variant="outlined" onClick={upsertRole} disabled={busy}>Assign</Button>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr 2fr auto', gap: 1, mb: 1 }}>
<TextField
select
label="Filter Principal"
value={rolePrincipalFilter}
onChange={(event) => setRolePrincipalFilter(event.target.value)}
size="small"
>
<MenuItem value="">All</MenuItem>
{principals.map((item) => (
<MenuItem key={item.id} value={item.id}>{item.name}</MenuItem>
))}
</TextField>
<TextField
select
label="Filter Project"
value={roleProjectFilter}
onChange={(event) => setRoleProjectFilter(event.target.value)}
size="small"
>
<MenuItem value="">All</MenuItem>
{projects.map((item) => (
<MenuItem key={item.id} value={item.id}>{item.name} ({item.slug})</MenuItem>
))}
</TextField>
<TextField
label="Search"
value={roleSearch}
onChange={(event) => setRoleSearch(event.target.value)}
size="small"
placeholder="Principal/project/role..."
/>
<Button
variant="outlined"
size="small"
onClick={() => {
setRolePrincipalFilter('')
setRoleProjectFilter('')
setRoleSearch('')
}}
>
Reset
</Button>
</Box>
<Box sx={{ display: 'grid', gap: 1 }}>
{filteredPrincipals.map((principal) => (
<Paper key={principal.id} variant="outlined" sx={{ p: 1 }}>
<Typography variant="subtitle2">{principal.name}</Typography>
{(principalRoles[principal.id] || []).length === 0 ? (
<Typography variant="caption" color="text.secondary">No project roles</Typography>
) : (
(principalRoles[principal.id] || []).map((role) => (
<Box key={`${role.principal_id}:${role.project_id}`} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="caption" color="text.secondary">{projectName(role.project_id)} · {role.role}</Typography>
<Button size="small" color="error" onClick={() => deleteRole(role.principal_id, role.project_id)}>Remove</Button>
</Box>
))
)}
</Paper>
))}
</Box>
</Paper>
<Paper sx={{ p: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="h6">Cert Fingerprint Bindings</Typography>
<Button variant="outlined" onClick={() => { setDialogError(null); setBindSource('pki'); setBindPKICertID(''); setBindFingerprint(''); setBindPrincipalID(''); setBindingOpen(true) }}>Add Binding</Button>
</Box>
<Box sx={{ display: 'grid', gap: 1 }}>
{bindings.map((item) => (
<Paper key={item.fingerprint} variant="outlined" sx={{ p: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Box sx={{ display: 'grid' }}>
{pkiCertByFingerprint(item.fingerprint) ? (
<Typography variant="body2">
{pkiCertByFingerprint(item.fingerprint)?.common_name || pkiCertByFingerprint(item.fingerprint)?.serial_hex} ({pkiCertByFingerprint(item.fingerprint)?.id})
</Typography>
) : (
<Typography variant="body2">{item.fingerprint}</Typography>
)}
<Typography variant="caption" color="text.secondary">principal: {principalName(item.principal_id)} ({item.principal_id})</Typography>
{pkiCertByFingerprint(item.fingerprint) ? (
<Typography variant="caption" color="text.secondary">
source: pki cert · fingerprint: {item.fingerprint}
</Typography>
) : (
<Typography variant="caption" color="text.secondary">source: manual fingerprint</Typography>
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button size="small" color={item.enabled ? 'warning' : 'success'} onClick={() => toggleBinding(item)}>
{item.enabled ? 'Disable' : 'Enable'}
</Button>
<Button size="small" color="error" onClick={() => deleteBinding(item)}>Delete</Button>
</Box>
</Box>
</Paper>
))}
</Box>
</Paper>
</Box>
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">New Service Principal</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<DialogContent sx={{ display: 'grid', gap: 1, pt: '8px !important' }}>
<TextField label="Name" value={name} onChange={(event) => setName(event.target.value)} />
<TextField label="Description" value={description} onChange={(event) => setDescription(event.target.value)} />
</DialogContent>
<DialogActions>
<Button onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={createPrincipal} disabled={busy}>{busy ? 'Saving...' : 'Create'}</Button>
</DialogActions>
</Dialog>
<Dialog open={bindingOpen} onClose={() => setBindingOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Add Cert Binding</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<DialogContent sx={{ display: 'grid', gap: 1, pt: '8px !important' }}>
<TextField
select
label="Binding Source"
value={bindSource}
onChange={(event) => setBindSource(event.target.value as 'pki' | 'manual')}
>
<MenuItem value="pki">PKI Certificate</MenuItem>
<MenuItem value="manual">Manual Fingerprint</MenuItem>
</TextField>
{bindSource === 'pki' ? (
<TextField
select
label="PKI Certificate"
value={bindPKICertID}
onChange={(event) => setBindPKICertID(event.target.value)}
>
{pkiCerts.map((item) => (
<MenuItem key={item.id} value={item.id}>
{item.common_name || item.serial_hex} ({item.id.slice(0, 8)})
</MenuItem>
))}
</TextField>
) : null}
{bindSource === 'manual' ? (
<TextField
label="Fingerprint (sha256 hex)"
value={bindFingerprint}
onChange={(event) => setBindFingerprint(event.target.value)}
/>
) : null}
<TextField
select
label="Principal"
value={bindPrincipalID}
onChange={(event) => setBindPrincipalID(event.target.value)}
>
{principals.map((item) => (
<MenuItem key={item.id} value={item.id}>{item.name} ({item.id})</MenuItem>
))}
</TextField>
</DialogContent>
<DialogActions>
<Button onClick={() => setBindingOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={upsertBinding} disabled={busy}>{busy ? 'Saving...' : 'Save'}</Button>
</DialogActions>
</Dialog>
</Box>
)
}

View File

@@ -0,0 +1,712 @@
import Alert from '@mui/material/Alert'
import {
Box,
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
MenuItem,
Paper,
TextField,
Typography
} from '@mui/material'
import { useEffect, useMemo, useState } from 'react'
import { api, PKICA, PKICert, ServicePrincipal, TLSListener, TLSSettings } from '../api'
type ListenerForm = Omit<TLSListener, 'id' | 'created_at' | 'updated_at'>
const emptyListener = (): ListenerForm => ({
name: '',
enabled: false,
http_addrs: [],
https_addrs: [],
auth_policy: 'default',
apply_policy_api: true,
apply_policy_git: true,
apply_policy_rpm: true,
apply_policy_v2: true,
client_cert_allowlist: [],
tls_server_cert_source: 'pki',
tls_cert_file: '',
tls_key_file: '',
tls_pki_server_cert_id: '',
tls_client_auth: 'none',
tls_client_ca_file: '',
tls_pki_client_ca_id: '',
tls_min_version: '1.2'
})
export default function AdminTLSSettingsPage() {
const [settings, setSettings] = useState<TLSSettings>({
http_addrs: [':1080'],
https_addrs: [],
tls_server_cert_source: 'pki',
tls_cert_file: '',
tls_key_file: '',
tls_pki_server_cert_id: '',
tls_client_auth: 'none',
tls_client_ca_file: '',
tls_pki_client_ca_id: '',
tls_min_version: '1.2'
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [listeners, setListeners] = useState<TLSListener[]>([])
const [listenersLoading, setListenersLoading] = useState(false)
const [dialogOpen, setDialogOpen] = useState(false)
const [editingMain, setEditingMain] = useState(false)
const [editingID, setEditingID] = useState<string | null>(null)
const [listenerForm, setListenerForm] = useState<ListenerForm>(emptyListener())
const [listenerHTTPText, setListenerHTTPText] = useState('')
const [listenerHTTPSText, setListenerHTTPSText] = useState('')
const [listenerCertAllowText, setListenerCertAllowText] = useState('')
const [selectedAllowedCertIDs, setSelectedAllowedCertIDs] = useState<string[]>([])
const [listenerError, setListenerError] = useState<string | null>(null)
const [listenerSaving, setListenerSaving] = useState(false)
const [runtimeCounts, setRuntimeCounts] = useState<Record<string, number>>({})
const [pkiCAs, setPKICAs] = useState<PKICA[]>([])
const [pkiCerts, setPKICerts] = useState<PKICert[]>([])
const [principals, setPrincipals] = useState<ServicePrincipal[]>([])
const [bindPrincipalID, setBindPrincipalID] = useState('')
const [confirmOpen, setConfirmOpen] = useState(false)
const [confirmTitle, setConfirmTitle] = useState('')
const [confirmMessage, setConfirmMessage] = useState('')
const [confirmLabel, setConfirmLabel] = useState('Confirm')
const [confirmColor, setConfirmColor] = useState<'primary' | 'warning' | 'error'>('primary')
const [confirmAction, setConfirmAction] = useState<null | (() => Promise<void>)>(null)
const certIDByFingerprint = useMemo(() => {
const map: Record<string, string> = {}
pkiCerts.forEach((cert) => {
if (cert.fingerprint) {
map[cert.fingerprint.toLowerCase()] = cert.id
}
})
return map
}, [pkiCerts])
const loadMainSettings = async () => {
setLoading(true)
api
.getTLSSettings()
.then((data) => {
setSettings(data)
})
.catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load TLS settings'
setError(message)
})
.finally(() => setLoading(false))
}
const loadListeners = async () => {
setListenersLoading(true)
api
.listTLSListeners()
.then((data) => setListeners(Array.isArray(data) ? data : []))
.catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load extra listeners'
setError(message)
})
.finally(() => setListenersLoading(false))
}
const loadRuntimeStatus = async () => {
api
.getTLSListenerRuntimeStatus()
.then((data) => setRuntimeCounts(data || {}))
.catch(() => setRuntimeCounts({}))
}
const loadPKIOptions = async () => {
api
.listPKICAs()
.then((data) => setPKICAs(Array.isArray(data) ? data : []))
.catch(() => setPKICAs([]))
api
.listPKICerts()
.then((data) => setPKICerts(Array.isArray(data) ? data : []))
.catch(() => setPKICerts([]))
api
.listServicePrincipals()
.then((data) => setPrincipals(Array.isArray(data) ? data : []))
.catch(() => setPrincipals([]))
}
useEffect(() => {
loadMainSettings()
loadListeners()
loadRuntimeStatus()
loadPKIOptions()
}, [])
const splitAddrText = (text: string): string[] => {
return text
.split(/\n|,/)
.map((v) => v.trim())
.filter((v) => v !== '')
}
const openCreateDialog = () => {
setEditingMain(false)
setEditingID(null)
setListenerForm(emptyListener())
setListenerHTTPText('')
setListenerHTTPSText('')
setListenerCertAllowText('')
setSelectedAllowedCertIDs([])
setBindPrincipalID('')
setListenerError(null)
setDialogOpen(true)
}
const openMainEditDialog = () => {
setEditingMain(true)
setEditingID(null)
setListenerForm({
name: 'Main Listener',
enabled: true,
http_addrs: settings.http_addrs || [],
https_addrs: settings.https_addrs || [],
auth_policy: 'default',
apply_policy_api: true,
apply_policy_git: true,
apply_policy_rpm: true,
apply_policy_v2: true,
client_cert_allowlist: [],
tls_server_cert_source: 'pki',
tls_cert_file: '',
tls_key_file: '',
tls_pki_server_cert_id: settings.tls_pki_server_cert_id,
tls_client_auth: settings.tls_client_auth,
tls_client_ca_file: '',
tls_pki_client_ca_id: settings.tls_pki_client_ca_id,
tls_min_version: settings.tls_min_version
})
setListenerHTTPText((settings.http_addrs || []).join('\n'))
setListenerHTTPSText((settings.https_addrs || []).join('\n'))
setListenerCertAllowText('')
setSelectedAllowedCertIDs([])
setBindPrincipalID('')
setListenerError(null)
setDialogOpen(true)
}
const openEditDialog = (item: TLSListener) => {
const matchedIDs = (item.client_cert_allowlist || [])
.map((fp) => certIDByFingerprint[(fp || '').toLowerCase()] || '')
.filter((id) => id !== '')
const matchedFPSet = new Set(
matchedIDs
.map((id) => (pkiCerts.find((cert) => cert.id === id)?.fingerprint || '').toLowerCase())
.filter((fp) => fp !== '')
)
const manualOnlyFPs = (item.client_cert_allowlist || [])
.map((fp) => (fp || '').toLowerCase())
.filter((fp) => fp !== '' && !matchedFPSet.has(fp))
setEditingMain(false)
setEditingID(item.id)
setListenerForm({
name: item.name,
enabled: item.enabled,
http_addrs: item.http_addrs || [],
https_addrs: item.https_addrs || [],
auth_policy: item.auth_policy || 'default',
apply_policy_api: item.apply_policy_api,
apply_policy_git: item.apply_policy_git,
apply_policy_rpm: item.apply_policy_rpm,
apply_policy_v2: item.apply_policy_v2,
client_cert_allowlist: item.client_cert_allowlist || [],
tls_server_cert_source: 'pki',
tls_cert_file: '',
tls_key_file: '',
tls_pki_server_cert_id: item.tls_pki_server_cert_id,
tls_client_auth: item.tls_client_auth,
tls_client_ca_file: '',
tls_pki_client_ca_id: item.tls_pki_client_ca_id,
tls_min_version: item.tls_min_version
})
setListenerHTTPText((item.http_addrs || []).join('\n'))
setListenerHTTPSText((item.https_addrs || []).join('\n'))
setListenerCertAllowText(manualOnlyFPs.join('\n'))
setSelectedAllowedCertIDs(matchedIDs)
setBindPrincipalID('')
setListenerError(null)
setDialogOpen(true)
}
const handleSaveDialog = async () => {
const selectedFingerprints = selectedAllowedCertIDs
.map((id) => (pkiCerts.find((cert) => cert.id === id)?.fingerprint || '').toLowerCase())
.filter((fp) => fp !== '')
const mergedAllowlist = Array.from(new Set([...splitAddrText(listenerCertAllowText), ...selectedFingerprints]))
const payload: ListenerForm = {
...listenerForm,
name: listenerForm.name.trim(),
http_addrs: splitAddrText(listenerHTTPText),
https_addrs: splitAddrText(listenerHTTPSText),
client_cert_allowlist: mergedAllowlist
}
if (!editingMain && !payload.name) {
setListenerError('Listener name is required')
return
}
if (payload.http_addrs.length === 0 && payload.https_addrs.length === 0) {
setListenerError('Provide at least one HTTP or HTTPS address')
return
}
if (!editingMain && !payload.apply_policy_api && !payload.apply_policy_git && !payload.apply_policy_rpm && !payload.apply_policy_v2) {
setListenerError('Select at least one scope (API/Git/RPM/V2).')
return
}
if (!editingMain && (payload.auth_policy === 'read_open_write_cert' || payload.auth_policy === 'cert_only') && payload.client_cert_allowlist.length === 0) {
setListenerError('Client certificate fingerprint allowlist is required for this policy.')
return
}
if (payload.https_addrs.length > 0 && !payload.tls_pki_server_cert_id.trim()) {
setListenerError('TLS PKI Server Cert is required when HTTPS addresses are configured.')
return
}
if ((payload.tls_client_auth === 'require_and_verify' || payload.tls_client_auth === 'verify_if_given') && !payload.tls_pki_client_ca_id.trim()) {
setListenerError('TLS PKI Client CA is required for the selected TLS Client Auth mode.')
return
}
setListenerSaving(true)
setListenerError(null)
try {
if (editingMain) {
await api.updateTLSSettings({
http_addrs: payload.http_addrs,
https_addrs: payload.https_addrs,
tls_server_cert_source: 'pki',
tls_cert_file: '',
tls_key_file: '',
tls_pki_server_cert_id: payload.tls_pki_server_cert_id,
tls_client_auth: payload.tls_client_auth,
tls_client_ca_file: '',
tls_pki_client_ca_id: payload.tls_pki_client_ca_id,
tls_min_version: payload.tls_min_version
})
await loadMainSettings()
} else if (editingID) {
await api.updateTLSListener(editingID, payload)
} else {
await api.createTLSListener(payload)
}
setDialogOpen(false)
if (!editingMain) {
await loadListeners()
}
await loadRuntimeStatus()
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save listener'
setListenerError(message)
} finally {
setListenerSaving(false)
}
}
const bindSelectedCertsToPrincipal = async () => {
let i: number
let cert: PKICert | undefined
if (!bindPrincipalID) {
setListenerError('Choose a service principal for certificate bindings.')
return
}
if (selectedAllowedCertIDs.length === 0) {
setListenerError('Select at least one PKI certificate to bind.')
return
}
setListenerSaving(true)
setListenerError(null)
try {
for (i = 0; i < selectedAllowedCertIDs.length; i++) {
cert = pkiCerts.find((item) => item.id === selectedAllowedCertIDs[i])
if (!cert || !cert.fingerprint) {
continue
}
await api.upsertCertPrincipalBinding({
fingerprint: cert.fingerprint,
principal_id: bindPrincipalID,
enabled: true
})
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to bind selected certs to principal'
setListenerError(message)
return
} finally {
setListenerSaving(false)
}
setListenerError(null)
}
const handleToggleListener = async (item: TLSListener) => {
const nextEnabled = !item.enabled
const payload: ListenerForm = {
name: item.name,
enabled: nextEnabled,
http_addrs: item.http_addrs || [],
https_addrs: item.https_addrs || [],
auth_policy: item.auth_policy || 'default',
apply_policy_api: item.apply_policy_api,
apply_policy_git: item.apply_policy_git,
apply_policy_rpm: item.apply_policy_rpm,
apply_policy_v2: item.apply_policy_v2,
client_cert_allowlist: item.client_cert_allowlist || [],
tls_server_cert_source: 'pki',
tls_cert_file: '',
tls_key_file: '',
tls_pki_server_cert_id: item.tls_pki_server_cert_id,
tls_client_auth: item.tls_client_auth,
tls_client_ca_file: '',
tls_pki_client_ca_id: item.tls_pki_client_ca_id,
tls_min_version: item.tls_min_version
}
setConfirmTitle(nextEnabled ? 'Enable Listener' : 'Disable Listener')
setConfirmMessage(`Do you want to ${nextEnabled ? 'enable' : 'disable'} listener "${item.name}"?`)
setConfirmLabel(nextEnabled ? 'Enable' : 'Disable')
setConfirmColor(nextEnabled ? 'primary' : 'warning')
setConfirmAction(() => async () => {
setError(null)
try {
await api.updateTLSListener(item.id, payload)
await loadListeners()
await loadRuntimeStatus()
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update listener state'
setError(message)
}
})
setConfirmOpen(true)
}
const handleDeleteListener = async (item: TLSListener) => {
setConfirmTitle('Delete Listener')
setConfirmMessage(`Delete listener "${item.name}"?`)
setConfirmLabel('Delete')
setConfirmColor('error')
setConfirmAction(() => async () => {
setError(null)
try {
await api.deleteTLSListener(item.id)
await loadListeners()
await loadRuntimeStatus()
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete listener'
setError(message)
}
})
setConfirmOpen(true)
}
const handleConfirm = async () => {
if (!confirmAction) {
setConfirmOpen(false)
return
}
await confirmAction()
setConfirmOpen(false)
setConfirmAction(null)
}
return (
<Box>
<Typography variant="h5" sx={{ mb: 2 }}>
Admin: Site TLS
</Typography>
<Paper sx={{ p: 2, maxWidth: 980 }}>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
{(loading || listenersLoading) ? (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Loading...
</Typography>
) : null}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="h6">Listeners</Typography>
<Button variant="outlined" onClick={openCreateDialog}>
Add Listener
</Button>
</Box>
<Box sx={{ display: 'grid', gap: 1 }}>
<Paper variant="outlined" sx={{ p: 1, display: 'grid', gap: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle2">Main Listener</Typography>
<Chip size="small" color="info" label="Main" />
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button size="small" onClick={openMainEditDialog}>Edit</Button>
</Box>
</Box>
<Typography variant="caption" color="text.secondary">
HTTP: {(settings.http_addrs || []).join(', ') || '(none)'}
</Typography>
<Typography variant="caption" color="text.secondary">
HTTPS: {(settings.https_addrs || []).join(', ') || '(none)'}
</Typography>
</Paper>
{!listenersLoading && (listeners || []).length === 0 ? (
<Typography variant="body2" color="text.secondary">
No additional listeners configured.
</Typography>
) : null}
{(listeners || []).map((item) => (
<Paper key={item.id} variant="outlined" sx={{ p: 1, display: 'grid', gap: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle2">{item.name}</Typography>
{item.enabled ? (
(runtimeCounts[item.id] || 0) > 0 ? (
<Chip size="small" color="success" label={`Running (${runtimeCounts[item.id]} endpoint${runtimeCounts[item.id] > 1 ? 's' : ''})`} />
) : (
<Chip size="small" color="warning" label="Enabled (starting...)" />
)
) : (
<Chip size="small" label="Disabled" />
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button
size="small"
color={item.enabled ? 'warning' : 'success'}
onClick={() => handleToggleListener(item)}
>
{item.enabled ? 'Disable' : 'Enable'}
</Button>
<Button size="small" onClick={() => openEditDialog(item)}>
Edit
</Button>
<Button size="small" color="error" onClick={() => handleDeleteListener(item)}>
Delete
</Button>
</Box>
</Box>
<Typography variant="caption" color="text.secondary">
HTTP: {(item.http_addrs || []).join(', ') || '(none)'}
</Typography>
<Typography variant="caption" color="text.secondary">
HTTPS: {(item.https_addrs || []).join(', ') || '(none)'}
</Typography>
<Typography variant="caption" color="text.secondary">
Policy: {item.auth_policy || 'default'} · Scope: {item.apply_policy_api ? 'API ' : ''}{item.apply_policy_git ? 'Git ' : ''}{item.apply_policy_rpm ? 'RPM ' : ''}{item.apply_policy_v2 ? 'V2' : ''}
</Typography>
</Paper>
))}
</Box>
</Paper>
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">{editingMain ? 'Edit Main Listener' : editingID ? 'Edit Listener' : 'Add Listener'}</Typography>
{listenerError ? <Alert severity="error">{listenerError}</Alert> : null}
</Box>
</DialogTitle>
<DialogContent sx={{ display: 'grid', gap: 1, pt: '8px !important' }}>
{!editingMain ? (
<TextField
label="Name"
value={listenerForm.name}
onChange={(event) => setListenerForm((prev) => ({ ...prev, name: event.target.value }))}
/>
) : null}
<TextField
label="HTTP Addresses (one per line)"
multiline
minRows={2}
value={listenerHTTPText}
onChange={(event) => setListenerHTTPText(event.target.value)}
/>
<TextField
label="HTTPS Addresses (one per line)"
multiline
minRows={2}
value={listenerHTTPSText}
onChange={(event) => setListenerHTTPSText(event.target.value)}
/>
{!editingMain ? (
<TextField
select
label="Auth Policy"
value={listenerForm.auth_policy}
onChange={(event) =>
setListenerForm((prev) => ({
...prev,
auth_policy: event.target.value as ListenerForm['auth_policy']
}))
}
>
<MenuItem value="default">default</MenuItem>
<MenuItem value="read_open_write_cert">read_open_write_cert</MenuItem>
<MenuItem value="read_open_write_cert_or_auth">read_open_write_cert_or_auth</MenuItem>
<MenuItem value="cert_only">cert_only</MenuItem>
<MenuItem value="read_only_public">read_only_public</MenuItem>
</TextField>
) : null}
{!editingMain ? (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0,1fr))', gap: 1 }}>
<Button
variant={listenerForm.apply_policy_api ? 'contained' : 'outlined'}
size="small"
onClick={() => setListenerForm((prev) => ({ ...prev, apply_policy_api: !prev.apply_policy_api }))}
>
API
</Button>
<Button
variant={listenerForm.apply_policy_git ? 'contained' : 'outlined'}
size="small"
onClick={() => setListenerForm((prev) => ({ ...prev, apply_policy_git: !prev.apply_policy_git }))}
>
Git
</Button>
<Button
variant={listenerForm.apply_policy_rpm ? 'contained' : 'outlined'}
size="small"
onClick={() => setListenerForm((prev) => ({ ...prev, apply_policy_rpm: !prev.apply_policy_rpm }))}
>
RPM
</Button>
<Button
variant={listenerForm.apply_policy_v2 ? 'contained' : 'outlined'}
size="small"
onClick={() => setListenerForm((prev) => ({ ...prev, apply_policy_v2: !prev.apply_policy_v2 }))}
>
V2
</Button>
</Box>
) : null}
{!editingMain ? (
<TextField
select
SelectProps={{ multiple: true }}
label="Allowed PKI Client Certificates"
value={selectedAllowedCertIDs}
onChange={(event) => {
const value = event.target.value
setSelectedAllowedCertIDs(typeof value === 'string' ? value.split(',') : (value as string[]))
}}
helperText="Selected certs are added to the fingerprint allowlist automatically."
>
{pkiCerts.map((cert) => (
<MenuItem key={cert.id} value={cert.id}>
{cert.common_name || cert.serial_hex} ({cert.id.slice(0, 8)})
</MenuItem>
))}
</TextField>
) : null}
{!editingMain ? (
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 1, alignItems: 'center' }}>
<TextField
select
label="Bind Selected Certs To Principal"
value={bindPrincipalID}
onChange={(event) => setBindPrincipalID(event.target.value)}
>
{principals
.filter((item) => !item.disabled)
.map((item) => (
<MenuItem key={item.id} value={item.id}>{item.name} ({item.id.slice(0, 8)})</MenuItem>
))}
</TextField>
<Button variant="outlined" onClick={bindSelectedCertsToPrincipal} disabled={listenerSaving}>
Bind
</Button>
</Box>
) : null}
{!editingMain ? (
<TextField
label="Client Cert Fingerprints (SHA256, one per line)"
multiline
minRows={3}
value={listenerCertAllowText}
onChange={(event) => setListenerCertAllowText(event.target.value)}
helperText="Manual fingerprints only. Fingerprints for selected PKI certs are managed by the selector above."
/>
) : null}
<TextField
select
label="TLS PKI Server Cert"
value={listenerForm.tls_pki_server_cert_id}
onChange={(event) =>
setListenerForm((prev) => ({
...prev,
tls_pki_server_cert_id: event.target.value
}))
}
>
<MenuItem value="">(none)</MenuItem>
{pkiCerts.map((cert) => (
<MenuItem key={cert.id} value={cert.id}>
{cert.common_name || cert.serial_hex} ({cert.id.slice(0, 8)})
</MenuItem>
))}
</TextField>
<TextField
select
label="TLS Client Auth"
value={listenerForm.tls_client_auth}
onChange={(event) =>
setListenerForm((prev) => ({
...prev,
tls_client_auth: event.target.value as 'none' | 'request' | 'require' | 'verify_if_given' | 'require_and_verify'
}))
}
>
<MenuItem value="none">none</MenuItem>
<MenuItem value="request">request</MenuItem>
<MenuItem value="require">require</MenuItem>
<MenuItem value="verify_if_given">verify_if_given</MenuItem>
<MenuItem value="require_and_verify">require_and_verify</MenuItem>
</TextField>
<TextField
select
label="TLS PKI Client CA"
value={listenerForm.tls_pki_client_ca_id}
onChange={(event) => setListenerForm((prev) => ({ ...prev, tls_pki_client_ca_id: event.target.value }))}
>
<MenuItem value="">(none)</MenuItem>
{pkiCAs.map((ca) => (
<MenuItem key={ca.id} value={ca.id}>
{ca.name} ({ca.id.slice(0, 8)})
</MenuItem>
))}
</TextField>
<TextField
select
label="TLS Minimum Version"
value={listenerForm.tls_min_version}
onChange={(event) =>
setListenerForm((prev) => ({ ...prev, tls_min_version: event.target.value as '1.0' | '1.1' | '1.2' | '1.3' }))
}
>
<MenuItem value="1.0">1.0</MenuItem>
<MenuItem value="1.1">1.1</MenuItem>
<MenuItem value="1.2">1.2</MenuItem>
<MenuItem value="1.3">1.3</MenuItem>
</TextField>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={handleSaveDialog} disabled={listenerSaving}>
{listenerSaving ? 'Saving...' : editingMain || editingID ? 'Save' : 'Create'}
</Button>
</DialogActions>
</Dialog>
<Dialog open={confirmOpen} onClose={() => setConfirmOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle>{confirmTitle}</DialogTitle>
<DialogContent>
<Typography variant="body2">{confirmMessage}</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmOpen(false)}>Cancel</Button>
<Button variant="contained" color={confirmColor} onClick={handleConfirm}>
{confirmLabel}
</Button>
</DialogActions>
</Dialog>
</Box>
)
}