Compare commits
5 Commits
fab83b3e68
...
bf80ad9d61
| Author | SHA1 | Date | |
|---|---|---|---|
| bf80ad9d61 | |||
| ad12690d33 | |||
| 007987869d | |||
| 484e96f407 | |||
| 7a84045f33 |
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,8 @@ import "time"
|
||||
import "strconv"
|
||||
|
||||
type Config struct {
|
||||
HTTPAddr string `json:"http_addr"`
|
||||
HTTPAddrs []string `json:"http_addrs"`
|
||||
HTTPSAddrs []string `json:"https_addrs"`
|
||||
PublicBaseURL string `json:"public_base_url"`
|
||||
DataDir string `json:"data_dir"`
|
||||
DBDriver string `json:"db_driver"`
|
||||
@@ -30,6 +31,14 @@ type Config struct {
|
||||
OIDCScopes string `json:"oidc_scopes"`
|
||||
OIDCEnabled bool `json:"oidc_enabled"`
|
||||
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"`
|
||||
RPMHTTPPrefix string `json:"rpm_http_prefix"`
|
||||
}
|
||||
@@ -39,7 +48,8 @@ func Load(path string) (Config, error) {
|
||||
var data []byte
|
||||
var err error
|
||||
cfg = Config{
|
||||
HTTPAddr: ":1080",
|
||||
HTTPAddrs: []string{":1080"},
|
||||
HTTPSAddrs: []string{},
|
||||
DataDir: "./codit-data",
|
||||
DBDriver: "sqlite",
|
||||
DBDSN: "file:./codit-data/codit.db?_pragma=foreign_keys(1)",
|
||||
@@ -47,6 +57,9 @@ func Load(path string) (Config, error) {
|
||||
AuthMode: "db",
|
||||
LDAPUserFilter: "(uid={username})",
|
||||
OIDCScopes: "openid profile email",
|
||||
TLSServerCertSource: "files",
|
||||
TLSClientAuth: "none",
|
||||
TLSMinVersion: "1.2",
|
||||
GitHTTPPrefix: "/git",
|
||||
RPMHTTPPrefix: "/rpm",
|
||||
}
|
||||
@@ -62,6 +75,13 @@ func Load(path string) (Config, error) {
|
||||
}
|
||||
override(&cfg)
|
||||
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 == "" {
|
||||
return cfg, errors.New("db dsn is required")
|
||||
}
|
||||
@@ -70,9 +90,13 @@ func Load(path string) (Config, error) {
|
||||
|
||||
func override(cfg *Config) {
|
||||
var v string
|
||||
v = os.Getenv("CODIT_HTTP_ADDR")
|
||||
v = os.Getenv("CODIT_HTTP_ADDRS")
|
||||
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")
|
||||
if v != "" {
|
||||
@@ -154,6 +178,38 @@ func override(cfg *Config) {
|
||||
if 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")
|
||||
if v != "" {
|
||||
cfg.GitHTTPPrefix = v
|
||||
@@ -209,3 +265,33 @@ func parseEnvBool(v string) bool {
|
||||
}
|
||||
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
337
backend/internal/db/pki.go
Normal 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
|
||||
}
|
||||
203
backend/internal/db/principals.go
Normal file
203
backend/internal/db/principals.go
Normal 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
|
||||
}
|
||||
@@ -358,6 +358,164 @@ func (s *Store) SetAuthSettings(settings models.AuthSettings) error {
|
||||
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 {
|
||||
var err error
|
||||
var now time.Time
|
||||
|
||||
94
backend/internal/db/tls_listeners.go
Normal file
94
backend/internal/db/tls_listeners.go
Normal 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
|
||||
}
|
||||
@@ -574,6 +574,7 @@ func resolveManifest(repoPath string, reference string) (ociDescriptor, error) {
|
||||
var desc ociDescriptor
|
||||
var err error
|
||||
var ok bool
|
||||
var data []byte
|
||||
if isDigestRef(reference) {
|
||||
ok, err = HasBlob(repoPath, reference)
|
||||
if err != nil {
|
||||
@@ -583,6 +584,11 @@ func resolveManifest(repoPath string, reference string) (ociDescriptor, error) {
|
||||
return desc, ErrNotFound
|
||||
}
|
||||
desc = ociDescriptor{Digest: reference}
|
||||
data, err = ReadBlob(repoPath, reference)
|
||||
if err == nil {
|
||||
desc.Size = int64(len(data))
|
||||
desc.MediaType = detectManifestMediaType(data)
|
||||
}
|
||||
return desc, nil
|
||||
}
|
||||
desc, err = resolveTag(repoPath, reference)
|
||||
|
||||
@@ -33,6 +33,8 @@ type API struct {
|
||||
DockerBase string
|
||||
Uploads storage.FileStore
|
||||
Logger *util.Logger
|
||||
OnTLSListenersChanged func()
|
||||
OnTLSListenerRuntimeStatus func() map[string]int
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
@@ -85,6 +87,58 @@ type testLDAPSettingsRequest struct {
|
||||
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 {
|
||||
Slug string `json:"slug"`
|
||||
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"})
|
||||
}
|
||||
|
||||
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) {
|
||||
var projects []models.Project
|
||||
var err error
|
||||
@@ -3518,27 +3963,30 @@ func (api *API) Health(w http.ResponseWriter, _ *http.Request, _ map[string]stri
|
||||
func (api *API) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||||
var user models.User
|
||||
var ok bool
|
||||
var principal models.ServicePrincipal
|
||||
user, ok = middleware.UserFromContext(r.Context())
|
||||
if !ok || !user.IsAdmin {
|
||||
if ok && user.IsAdmin {
|
||||
return true
|
||||
}
|
||||
principal, ok = middleware.PrincipalFromContext(r.Context())
|
||||
if ok && principal.IsAdmin && !principal.Disabled {
|
||||
return true
|
||||
}
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (api *API) requireProjectRole(w http.ResponseWriter, r *http.Request, projectID, required string) bool {
|
||||
var user models.User
|
||||
var principal models.ServicePrincipal
|
||||
var ok bool
|
||||
user, ok = middleware.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
if user.IsAdmin {
|
||||
if ok && user.IsAdmin {
|
||||
return true
|
||||
}
|
||||
var role string
|
||||
var err error
|
||||
if ok {
|
||||
role, err = api.Store.GetProjectMemberRole(projectID, user.ID)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
@@ -3549,21 +3997,33 @@ func (api *API) requireProjectRole(w http.ResponseWriter, r *http.Request, proje
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
principal, ok = middleware.PrincipalFromContext(r.Context())
|
||||
if !ok || principal.Disabled {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
if principal.IsAdmin {
|
||||
return true
|
||||
}
|
||||
role, err = api.Store.GetPrincipalProjectRole(principal.ID, projectID)
|
||||
if err != nil || !roleAllows(role, required) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (api *API) requireRepoRole(w http.ResponseWriter, r *http.Request, repoID, required string) bool {
|
||||
var user models.User
|
||||
var principal models.ServicePrincipal
|
||||
var ok bool
|
||||
var projectIDs []string
|
||||
var err error
|
||||
var i int
|
||||
var role string
|
||||
user, ok = middleware.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
if user.IsAdmin {
|
||||
if ok && user.IsAdmin {
|
||||
return true
|
||||
}
|
||||
projectIDs, err = api.Store.GetRepoProjectIDs(repoID)
|
||||
@@ -3571,6 +4031,7 @@ func (api *API) requireRepoRole(w http.ResponseWriter, r *http.Request, repoID,
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
if ok {
|
||||
for i = 0; i < len(projectIDs); i++ {
|
||||
role, err = api.Store.GetProjectMemberRole(projectIDs[i], user.ID)
|
||||
if err != nil {
|
||||
@@ -3582,6 +4043,26 @@ func (api *API) requireRepoRole(w http.ResponseWriter, r *http.Request, repoID,
|
||||
}
|
||||
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++ {
|
||||
role, err = api.Store.GetPrincipalProjectRole(principal.ID, projectIDs[i])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if roleAllows(role, required) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeRole(role string) string {
|
||||
@@ -3720,6 +4201,51 @@ func (api *API) effectiveAuthConfig() (config.Config, error) {
|
||||
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) {
|
||||
var v string
|
||||
v = strings.ToLower(strings.TrimSpace(value))
|
||||
@@ -3732,6 +4258,139 @@ func normalizeRepoType(value string) (string, bool) {
|
||||
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 {
|
||||
actual = normalizeRole(actual)
|
||||
required = normalizeRole(required)
|
||||
|
||||
1056
backend/internal/handlers/pki.go
Normal file
1056
backend/internal/handlers/pki.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ import "codit/internal/util"
|
||||
type ctxKey string
|
||||
|
||||
const userKey ctxKey = "user"
|
||||
const principalKey ctxKey = "principal"
|
||||
|
||||
func WithUser(store *db.Store, next http.Handler) http.Handler {
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var ok bool
|
||||
|
||||
@@ -128,3 +128,96 @@ type AuthSettings struct {
|
||||
OIDCScopes string `json:"oidc_scopes"`
|
||||
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"`
|
||||
}
|
||||
|
||||
33
backend/migrations/011_pki.sql
Normal file
33
backend/migrations/011_pki.sql
Normal 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)
|
||||
);
|
||||
17
backend/migrations/012_tls_listeners.sql
Normal file
17
backend/migrations/012_tls_listeners.sql
Normal 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
|
||||
);
|
||||
6
backend/migrations/013_tls_listener_auth_policy.sql
Normal file
6
backend/migrations/013_tls_listener_auth_policy.sql
Normal 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 '';
|
||||
17
backend/migrations/014_service_principals.sql
Normal file
17
backend/migrations/014_service_principals.sql
Normal 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
|
||||
);
|
||||
11
backend/migrations/015_service_principal_roles.sql
Normal file
11
backend/migrations/015_service_principal_roles.sql
Normal 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
|
||||
);
|
||||
31
backend/migrations/016_pki_cert_optional_ca.sql
Normal file
31
backend/migrations/016_pki_cert_optional_ca.sql
Normal 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;
|
||||
@@ -193,6 +193,31 @@ export interface AdminAPIKey extends APIKey {
|
||||
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 {
|
||||
auth_mode: 'db' | 'ldap' | 'hybrid'
|
||||
oidc_enabled: boolean
|
||||
@@ -212,12 +237,87 @@ export interface AuthSettings {
|
||||
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 {
|
||||
enabled: boolean
|
||||
configured?: boolean
|
||||
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> {
|
||||
const res = await fetch(path, {
|
||||
credentials: 'include',
|
||||
@@ -287,6 +387,22 @@ export const api = {
|
||||
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' }),
|
||||
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'),
|
||||
updateAuthSettings: (payload: AuthSettings) =>
|
||||
request<AuthSettings>('/api/admin/auth', { method: 'PATCH', body: JSON.stringify(payload) }),
|
||||
@@ -299,6 +415,49 @@ export const api = {
|
||||
body: JSON.stringify(payload),
|
||||
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'),
|
||||
createUser: (payload: { username: string; display_name: string; email: string; password: string; is_admin: boolean }) =>
|
||||
|
||||
@@ -24,6 +24,9 @@ import KeyIcon from '@mui/icons-material/Key'
|
||||
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'
|
||||
import PersonIcon from '@mui/icons-material/Person'
|
||||
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 LightModeIcon from '@mui/icons-material/LightMode'
|
||||
import { ThemeModeContext } from './ThemeModeContext'
|
||||
@@ -58,7 +61,10 @@ export default function Layout() {
|
||||
if (user?.is_admin) {
|
||||
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 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 TLS', path: '/admin/tls', icon: <HttpsIcon fontSize="small" /> })
|
||||
}
|
||||
return items
|
||||
}, [user])
|
||||
|
||||
@@ -17,6 +17,9 @@ import FilesPage from '../pages/FilesPage'
|
||||
import AdminUsersPage from '../pages/AdminUsersPage'
|
||||
import AdminApiKeysPage from '../pages/AdminApiKeysPage'
|
||||
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 AccountPage from '../pages/AccountPage'
|
||||
import NotFoundPage from '../pages/NotFoundPage'
|
||||
@@ -44,7 +47,10 @@ export const routes: RouteObject[] = [
|
||||
{ path: 'projects/:projectId/files', element: <FilesPage /> },
|
||||
{ path: 'admin/users', element: <AdminUsersPage /> },
|
||||
{ path: 'admin/api-keys', element: <AdminApiKeysPage /> },
|
||||
{ path: 'admin/pki', element: <AdminPKIPage /> },
|
||||
{ path: 'admin/principals', element: <AdminServicePrincipalsPage /> },
|
||||
{ path: 'admin/auth', element: <AdminAuthLdapPage /> },
|
||||
{ path: 'admin/tls', element: <AdminTLSSettingsPage /> },
|
||||
{ path: 'admin/auth/ldap', element: <AdminAuthLdapPage /> }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -74,11 +74,14 @@ export default function AdminAuthLdapPage() {
|
||||
setError(null)
|
||||
setTestResult(null)
|
||||
try {
|
||||
const result = await api.testAuthSettings({
|
||||
const result = await api.testAuthSettings(
|
||||
{
|
||||
...settings,
|
||||
username: testUsername.trim() || undefined,
|
||||
password: testPassword || undefined
|
||||
}, controller.signal)
|
||||
},
|
||||
controller.signal
|
||||
)
|
||||
setTestResult(result.user ? `Connection ok. User test ok: ${result.user}` : 'Connection ok.')
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
@@ -107,7 +110,11 @@ export default function AdminAuthLdapPage() {
|
||||
Admin: Site Authentication
|
||||
</Typography>
|
||||
<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}
|
||||
{saved ? <Alert severity="success" sx={{ mb: 1 }}>Saved.</Alert> : null}
|
||||
{testResult ? <Alert severity="success" sx={{ mb: 1 }}>{testResult}</Alert> : null}
|
||||
@@ -126,19 +133,10 @@ export default function AdminAuthLdapPage() {
|
||||
OIDC
|
||||
</Typography>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={settings.oidc_enabled}
|
||||
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_enabled: event.target.checked }))}
|
||||
/>
|
||||
}
|
||||
control={<Checkbox checked={settings.oidc_enabled} onChange={(event) => setSettings((prev) => ({ ...prev, oidc_enabled: event.target.checked }))} />}
|
||||
label="Enable OIDC login"
|
||||
/>
|
||||
<TextField
|
||||
label="Client ID"
|
||||
value={settings.oidc_client_id}
|
||||
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_client_id: event.target.value }))}
|
||||
/>
|
||||
<TextField label="Client ID" value={settings.oidc_client_id} onChange={(event) => setSettings((prev) => ({ ...prev, oidc_client_id: event.target.value }))} />
|
||||
<TextField
|
||||
label="Client Secret"
|
||||
type="password"
|
||||
@@ -172,27 +170,14 @@ export default function AdminAuthLdapPage() {
|
||||
helperText="Example: openid profile email"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={settings.oidc_tls_insecure_skip_verify}
|
||||
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_tls_insecure_skip_verify: event.target.checked }))}
|
||||
/>
|
||||
}
|
||||
control={<Checkbox checked={settings.oidc_tls_insecure_skip_verify} onChange={(event) => setSettings((prev) => ({ ...prev, oidc_tls_insecure_skip_verify: event.target.checked }))} />}
|
||||
label="OIDC TLS insecure skip verify (testing/self-signed only)"
|
||||
/>
|
||||
<Typography variant="subtitle2" sx={{ mt: 1 }}>
|
||||
LDAP
|
||||
</Typography>
|
||||
<TextField
|
||||
label="LDAP URL"
|
||||
value={settings.ldap_url}
|
||||
onChange={(event) => setSettings((prev) => ({ ...prev, ldap_url: event.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Bind DN"
|
||||
value={settings.ldap_bind_dn}
|
||||
onChange={(event) => setSettings((prev) => ({ ...prev, ldap_bind_dn: event.target.value }))}
|
||||
/>
|
||||
<TextField label="LDAP URL" value={settings.ldap_url} onChange={(event) => setSettings((prev) => ({ ...prev, ldap_url: event.target.value }))} />
|
||||
<TextField label="Bind DN" value={settings.ldap_bind_dn} onChange={(event) => setSettings((prev) => ({ ...prev, ldap_bind_dn: event.target.value }))} />
|
||||
<TextField
|
||||
label="Bind Password"
|
||||
type="password"
|
||||
@@ -211,28 +196,14 @@ export default function AdminAuthLdapPage() {
|
||||
helperText="Use {username} placeholder."
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={settings.ldap_tls_insecure_skip_verify}
|
||||
onChange={(event) => setSettings((prev) => ({ ...prev, ldap_tls_insecure_skip_verify: event.target.checked }))}
|
||||
/>
|
||||
}
|
||||
control={<Checkbox checked={settings.ldap_tls_insecure_skip_verify} onChange={(event) => setSettings((prev) => ({ ...prev, ldap_tls_insecure_skip_verify: event.target.checked }))} />}
|
||||
label="TLS insecure skip verify (testing/self-signed only)"
|
||||
/>
|
||||
<Typography variant="subtitle2" sx={{ mt: 1 }}>
|
||||
Test (optional user bind)
|
||||
</Typography>
|
||||
<TextField
|
||||
label="Test Username"
|
||||
value={testUsername}
|
||||
onChange={(event) => setTestUsername(event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Test Password"
|
||||
type="password"
|
||||
value={testPassword}
|
||||
onChange={(event) => setTestPassword(event.target.value)}
|
||||
/>
|
||||
<TextField label="Test Username" value={testUsername} onChange={(event) => setTestUsername(event.target.value)} />
|
||||
<TextField label="Test Password" type="password" value={testPassword} onChange={(event) => setTestPassword(event.target.value)} />
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 1 }}>
|
||||
<Button variant="outlined" onClick={handleTest} color={testing ? 'warning' : 'primary'}>
|
||||
{testing ? 'Cancel Test' : 'Test Connection'}
|
||||
|
||||
888
frontend/src/pages/AdminPKIPage.tsx
Normal file
888
frontend/src/pages/AdminPKIPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
488
frontend/src/pages/AdminServicePrincipalsPage.tsx
Normal file
488
frontend/src/pages/AdminServicePrincipalsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
712
frontend/src/pages/AdminTLSSettingsPage.tsx
Normal file
712
frontend/src/pages/AdminTLSSettingsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user