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"
|
import "strconv"
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
HTTPAddr string `json:"http_addr"`
|
HTTPAddrs []string `json:"http_addrs"`
|
||||||
|
HTTPSAddrs []string `json:"https_addrs"`
|
||||||
PublicBaseURL string `json:"public_base_url"`
|
PublicBaseURL string `json:"public_base_url"`
|
||||||
DataDir string `json:"data_dir"`
|
DataDir string `json:"data_dir"`
|
||||||
DBDriver string `json:"db_driver"`
|
DBDriver string `json:"db_driver"`
|
||||||
@@ -30,6 +31,14 @@ type Config struct {
|
|||||||
OIDCScopes string `json:"oidc_scopes"`
|
OIDCScopes string `json:"oidc_scopes"`
|
||||||
OIDCEnabled bool `json:"oidc_enabled"`
|
OIDCEnabled bool `json:"oidc_enabled"`
|
||||||
OIDCTLSInsecureSkipVerify bool `json:"oidc_tls_insecure_skip_verify"`
|
OIDCTLSInsecureSkipVerify bool `json:"oidc_tls_insecure_skip_verify"`
|
||||||
|
TLSServerCertSource string `json:"tls_server_cert_source"`
|
||||||
|
TLSCertFile string `json:"tls_cert_file"`
|
||||||
|
TLSKeyFile string `json:"tls_key_file"`
|
||||||
|
TLSPKIServerCertID string `json:"tls_pki_server_cert_id"`
|
||||||
|
TLSClientAuth string `json:"tls_client_auth"`
|
||||||
|
TLSClientCAFile string `json:"tls_client_ca_file"`
|
||||||
|
TLSPKIClientCAID string `json:"tls_pki_client_ca_id"`
|
||||||
|
TLSMinVersion string `json:"tls_min_version"`
|
||||||
GitHTTPPrefix string `json:"git_http_prefix"`
|
GitHTTPPrefix string `json:"git_http_prefix"`
|
||||||
RPMHTTPPrefix string `json:"rpm_http_prefix"`
|
RPMHTTPPrefix string `json:"rpm_http_prefix"`
|
||||||
}
|
}
|
||||||
@@ -39,7 +48,8 @@ func Load(path string) (Config, error) {
|
|||||||
var data []byte
|
var data []byte
|
||||||
var err error
|
var err error
|
||||||
cfg = Config{
|
cfg = Config{
|
||||||
HTTPAddr: ":1080",
|
HTTPAddrs: []string{":1080"},
|
||||||
|
HTTPSAddrs: []string{},
|
||||||
DataDir: "./codit-data",
|
DataDir: "./codit-data",
|
||||||
DBDriver: "sqlite",
|
DBDriver: "sqlite",
|
||||||
DBDSN: "file:./codit-data/codit.db?_pragma=foreign_keys(1)",
|
DBDSN: "file:./codit-data/codit.db?_pragma=foreign_keys(1)",
|
||||||
@@ -47,6 +57,9 @@ func Load(path string) (Config, error) {
|
|||||||
AuthMode: "db",
|
AuthMode: "db",
|
||||||
LDAPUserFilter: "(uid={username})",
|
LDAPUserFilter: "(uid={username})",
|
||||||
OIDCScopes: "openid profile email",
|
OIDCScopes: "openid profile email",
|
||||||
|
TLSServerCertSource: "files",
|
||||||
|
TLSClientAuth: "none",
|
||||||
|
TLSMinVersion: "1.2",
|
||||||
GitHTTPPrefix: "/git",
|
GitHTTPPrefix: "/git",
|
||||||
RPMHTTPPrefix: "/rpm",
|
RPMHTTPPrefix: "/rpm",
|
||||||
}
|
}
|
||||||
@@ -62,6 +75,13 @@ func Load(path string) (Config, error) {
|
|||||||
}
|
}
|
||||||
override(&cfg)
|
override(&cfg)
|
||||||
cfg.AuthMode = strings.ToLower(strings.TrimSpace(cfg.AuthMode))
|
cfg.AuthMode = strings.ToLower(strings.TrimSpace(cfg.AuthMode))
|
||||||
|
cfg.TLSServerCertSource = strings.ToLower(strings.TrimSpace(cfg.TLSServerCertSource))
|
||||||
|
cfg.TLSClientAuth = strings.ToLower(strings.TrimSpace(cfg.TLSClientAuth))
|
||||||
|
cfg.HTTPAddrs = normalizeHTTPAddrs(cfg.HTTPAddrs)
|
||||||
|
cfg.HTTPSAddrs = normalizeHTTPAddrs(cfg.HTTPSAddrs)
|
||||||
|
if len(cfg.HTTPAddrs) == 0 && len(cfg.HTTPSAddrs) == 0 {
|
||||||
|
return cfg, errors.New("http_addrs or https_addrs is required")
|
||||||
|
}
|
||||||
if cfg.DBDSN == "" {
|
if cfg.DBDSN == "" {
|
||||||
return cfg, errors.New("db dsn is required")
|
return cfg, errors.New("db dsn is required")
|
||||||
}
|
}
|
||||||
@@ -70,9 +90,13 @@ func Load(path string) (Config, error) {
|
|||||||
|
|
||||||
func override(cfg *Config) {
|
func override(cfg *Config) {
|
||||||
var v string
|
var v string
|
||||||
v = os.Getenv("CODIT_HTTP_ADDR")
|
v = os.Getenv("CODIT_HTTP_ADDRS")
|
||||||
if v != "" {
|
if v != "" {
|
||||||
cfg.HTTPAddr = v
|
cfg.HTTPAddrs = splitCSV(v)
|
||||||
|
}
|
||||||
|
v = os.Getenv("CODIT_HTTPS_ADDRS")
|
||||||
|
if v != "" {
|
||||||
|
cfg.HTTPSAddrs = splitCSV(v)
|
||||||
}
|
}
|
||||||
v = os.Getenv("CODIT_PUBLIC_BASE_URL")
|
v = os.Getenv("CODIT_PUBLIC_BASE_URL")
|
||||||
if v != "" {
|
if v != "" {
|
||||||
@@ -154,6 +178,38 @@ func override(cfg *Config) {
|
|||||||
if v != "" {
|
if v != "" {
|
||||||
cfg.OIDCTLSInsecureSkipVerify = parseEnvBool(v)
|
cfg.OIDCTLSInsecureSkipVerify = parseEnvBool(v)
|
||||||
}
|
}
|
||||||
|
v = os.Getenv("CODIT_TLS_SERVER_CERT_SOURCE")
|
||||||
|
if v != "" {
|
||||||
|
cfg.TLSServerCertSource = v
|
||||||
|
}
|
||||||
|
v = os.Getenv("CODIT_TLS_CERT_FILE")
|
||||||
|
if v != "" {
|
||||||
|
cfg.TLSCertFile = v
|
||||||
|
}
|
||||||
|
v = os.Getenv("CODIT_TLS_KEY_FILE")
|
||||||
|
if v != "" {
|
||||||
|
cfg.TLSKeyFile = v
|
||||||
|
}
|
||||||
|
v = os.Getenv("CODIT_TLS_PKI_SERVER_CERT_ID")
|
||||||
|
if v != "" {
|
||||||
|
cfg.TLSPKIServerCertID = v
|
||||||
|
}
|
||||||
|
v = os.Getenv("CODIT_TLS_CLIENT_AUTH")
|
||||||
|
if v != "" {
|
||||||
|
cfg.TLSClientAuth = v
|
||||||
|
}
|
||||||
|
v = os.Getenv("CODIT_TLS_CLIENT_CA_FILE")
|
||||||
|
if v != "" {
|
||||||
|
cfg.TLSClientCAFile = v
|
||||||
|
}
|
||||||
|
v = os.Getenv("CODIT_TLS_PKI_CLIENT_CA_ID")
|
||||||
|
if v != "" {
|
||||||
|
cfg.TLSPKIClientCAID = v
|
||||||
|
}
|
||||||
|
v = os.Getenv("CODIT_TLS_MIN_VERSION")
|
||||||
|
if v != "" {
|
||||||
|
cfg.TLSMinVersion = v
|
||||||
|
}
|
||||||
v = os.Getenv("CODIT_GIT_HTTP_PREFIX")
|
v = os.Getenv("CODIT_GIT_HTTP_PREFIX")
|
||||||
if v != "" {
|
if v != "" {
|
||||||
cfg.GitHTTPPrefix = v
|
cfg.GitHTTPPrefix = v
|
||||||
@@ -209,3 +265,33 @@ func parseEnvBool(v string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func splitCSV(v string) []string {
|
||||||
|
var parts []string
|
||||||
|
var out []string
|
||||||
|
var i int
|
||||||
|
var p string
|
||||||
|
parts = strings.Split(v, ",")
|
||||||
|
for i = 0; i < len(parts); i++ {
|
||||||
|
p = strings.TrimSpace(parts[i])
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeHTTPAddrs(values []string) []string {
|
||||||
|
var out []string
|
||||||
|
var i int
|
||||||
|
var v string
|
||||||
|
for i = 0; i < len(values); i++ {
|
||||||
|
v = strings.TrimSpace(values[i])
|
||||||
|
if v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
337
backend/internal/db/pki.go
Normal file
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetTLSSettings() (models.TLSSettings, error) {
|
||||||
|
var settings models.TLSSettings
|
||||||
|
var rows *sql.Rows
|
||||||
|
var err error
|
||||||
|
var key string
|
||||||
|
var value string
|
||||||
|
rows, err = s.DB.Query(`SELECT key, value FROM app_settings WHERE key IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
"tls.http_addrs",
|
||||||
|
"tls.https_addrs",
|
||||||
|
"tls.server_cert_source",
|
||||||
|
"tls.cert_file",
|
||||||
|
"tls.key_file",
|
||||||
|
"tls.pki_server_cert_id",
|
||||||
|
"tls.client_auth",
|
||||||
|
"tls.client_ca_file",
|
||||||
|
"tls.pki_client_ca_id",
|
||||||
|
"tls.min_version")
|
||||||
|
if err != nil {
|
||||||
|
return settings, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
err = rows.Scan(&key, &value)
|
||||||
|
if err != nil {
|
||||||
|
return settings, err
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "tls.http_addrs":
|
||||||
|
settings.HTTPAddrs = splitCSVValue(value)
|
||||||
|
case "tls.https_addrs":
|
||||||
|
settings.HTTPSAddrs = splitCSVValue(value)
|
||||||
|
case "tls.server_cert_source":
|
||||||
|
settings.TLSServerCertSource = value
|
||||||
|
case "tls.cert_file":
|
||||||
|
settings.TLSCertFile = value
|
||||||
|
case "tls.key_file":
|
||||||
|
settings.TLSKeyFile = value
|
||||||
|
case "tls.pki_server_cert_id":
|
||||||
|
settings.TLSPKIServerCertID = value
|
||||||
|
case "tls.client_auth":
|
||||||
|
settings.TLSClientAuth = value
|
||||||
|
case "tls.client_ca_file":
|
||||||
|
settings.TLSClientCAFile = value
|
||||||
|
case "tls.pki_client_ca_id":
|
||||||
|
settings.TLSPKIClientCAID = value
|
||||||
|
case "tls.min_version":
|
||||||
|
settings.TLSMinVersion = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
return settings, err
|
||||||
|
}
|
||||||
|
return settings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SetTLSSettings(settings models.TLSSettings) error {
|
||||||
|
var tx *sql.Tx
|
||||||
|
var err error
|
||||||
|
var now int64
|
||||||
|
tx, err = s.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now = time.Now().UTC().Unix()
|
||||||
|
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
|
||||||
|
"tls.http_addrs", strings.Join(settings.HTTPAddrs, ","), now)
|
||||||
|
if err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
|
||||||
|
"tls.https_addrs", strings.Join(settings.HTTPSAddrs, ","), now)
|
||||||
|
if err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
|
||||||
|
"tls.server_cert_source", settings.TLSServerCertSource, now)
|
||||||
|
if err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
|
||||||
|
"tls.cert_file", settings.TLSCertFile, now)
|
||||||
|
if err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
|
||||||
|
"tls.key_file", settings.TLSKeyFile, now)
|
||||||
|
if err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
|
||||||
|
"tls.pki_server_cert_id", settings.TLSPKIServerCertID, now)
|
||||||
|
if err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
|
||||||
|
"tls.client_auth", settings.TLSClientAuth, now)
|
||||||
|
if err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
|
||||||
|
"tls.client_ca_file", settings.TLSClientCAFile, now)
|
||||||
|
if err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
|
||||||
|
"tls.pki_client_ca_id", settings.TLSPKIClientCAID, now)
|
||||||
|
if err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
|
||||||
|
"tls.min_version", settings.TLSMinVersion, now)
|
||||||
|
if err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = tx.Commit()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitCSVValue(value string) []string {
|
||||||
|
var parts []string
|
||||||
|
var out []string
|
||||||
|
var i int
|
||||||
|
var p string
|
||||||
|
parts = strings.Split(value, ",")
|
||||||
|
for i = 0; i < len(parts); i++ {
|
||||||
|
p = strings.TrimSpace(parts[i])
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) SetUserDisabled(id string, disabled bool) error {
|
func (s *Store) SetUserDisabled(id string, disabled bool) error {
|
||||||
var err error
|
var err error
|
||||||
var now time.Time
|
var now time.Time
|
||||||
|
|||||||
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 desc ociDescriptor
|
||||||
var err error
|
var err error
|
||||||
var ok bool
|
var ok bool
|
||||||
|
var data []byte
|
||||||
if isDigestRef(reference) {
|
if isDigestRef(reference) {
|
||||||
ok, err = HasBlob(repoPath, reference)
|
ok, err = HasBlob(repoPath, reference)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -583,6 +584,11 @@ func resolveManifest(repoPath string, reference string) (ociDescriptor, error) {
|
|||||||
return desc, ErrNotFound
|
return desc, ErrNotFound
|
||||||
}
|
}
|
||||||
desc = ociDescriptor{Digest: reference}
|
desc = ociDescriptor{Digest: reference}
|
||||||
|
data, err = ReadBlob(repoPath, reference)
|
||||||
|
if err == nil {
|
||||||
|
desc.Size = int64(len(data))
|
||||||
|
desc.MediaType = detectManifestMediaType(data)
|
||||||
|
}
|
||||||
return desc, nil
|
return desc, nil
|
||||||
}
|
}
|
||||||
desc, err = resolveTag(repoPath, reference)
|
desc, err = resolveTag(repoPath, reference)
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ type API struct {
|
|||||||
DockerBase string
|
DockerBase string
|
||||||
Uploads storage.FileStore
|
Uploads storage.FileStore
|
||||||
Logger *util.Logger
|
Logger *util.Logger
|
||||||
|
OnTLSListenersChanged func()
|
||||||
|
OnTLSListenerRuntimeStatus func() map[string]int
|
||||||
}
|
}
|
||||||
|
|
||||||
type loginRequest struct {
|
type loginRequest struct {
|
||||||
@@ -85,6 +87,58 @@ type testLDAPSettingsRequest struct {
|
|||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type updateTLSSettingsRequest struct {
|
||||||
|
HTTPAddrs []string `json:"http_addrs"`
|
||||||
|
HTTPSAddrs []string `json:"https_addrs"`
|
||||||
|
TLSServerCertSource string `json:"tls_server_cert_source"`
|
||||||
|
TLSCertFile string `json:"tls_cert_file"`
|
||||||
|
TLSKeyFile string `json:"tls_key_file"`
|
||||||
|
TLSPKIServerCertID string `json:"tls_pki_server_cert_id"`
|
||||||
|
TLSClientAuth string `json:"tls_client_auth"`
|
||||||
|
TLSClientCAFile string `json:"tls_client_ca_file"`
|
||||||
|
TLSPKIClientCAID string `json:"tls_pki_client_ca_id"`
|
||||||
|
TLSMinVersion string `json:"tls_min_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tlsListenerRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
HTTPAddrs []string `json:"http_addrs"`
|
||||||
|
HTTPSAddrs []string `json:"https_addrs"`
|
||||||
|
AuthPolicy string `json:"auth_policy"`
|
||||||
|
ApplyPolicyAPI bool `json:"apply_policy_api"`
|
||||||
|
ApplyPolicyGit bool `json:"apply_policy_git"`
|
||||||
|
ApplyPolicyRPM bool `json:"apply_policy_rpm"`
|
||||||
|
ApplyPolicyV2 bool `json:"apply_policy_v2"`
|
||||||
|
ClientCertAllowlist []string `json:"client_cert_allowlist"`
|
||||||
|
TLSServerCertSource string `json:"tls_server_cert_source"`
|
||||||
|
TLSCertFile string `json:"tls_cert_file"`
|
||||||
|
TLSKeyFile string `json:"tls_key_file"`
|
||||||
|
TLSPKIServerCertID string `json:"tls_pki_server_cert_id"`
|
||||||
|
TLSClientAuth string `json:"tls_client_auth"`
|
||||||
|
TLSClientCAFile string `json:"tls_client_ca_file"`
|
||||||
|
TLSPKIClientCAID string `json:"tls_pki_client_ca_id"`
|
||||||
|
TLSMinVersion string `json:"tls_min_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type servicePrincipalRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
Disabled bool `json:"disabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type certPrincipalBindingRequest struct {
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
PrincipalID string `json:"principal_id"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type principalProjectRoleRequest struct {
|
||||||
|
ProjectID string `json:"project_id"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
type createProjectRequest struct {
|
type createProjectRequest struct {
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -711,6 +765,397 @@ func (api *API) TestLDAPSettings(w http.ResponseWriter, r *http.Request, _ map[s
|
|||||||
WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (api *API) GetTLSSettings(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
||||||
|
var settings models.TLSSettings
|
||||||
|
var err error
|
||||||
|
if !api.requireAdmin(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settings, err = api.getMergedTLSSettings()
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteJSON(w, http.StatusOK, settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) UpdateTLSSettings(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
||||||
|
var req updateTLSSettingsRequest
|
||||||
|
var settings models.TLSSettings
|
||||||
|
var err error
|
||||||
|
if !api.requireAdmin(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = DecodeJSON(r, &req)
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settings = models.TLSSettings{
|
||||||
|
HTTPAddrs: normalizeAddrList(req.HTTPAddrs),
|
||||||
|
HTTPSAddrs: normalizeAddrList(req.HTTPSAddrs),
|
||||||
|
TLSServerCertSource: "pki",
|
||||||
|
TLSCertFile: "",
|
||||||
|
TLSKeyFile: "",
|
||||||
|
TLSPKIServerCertID: strings.TrimSpace(req.TLSPKIServerCertID),
|
||||||
|
TLSClientAuth: normalizeTLSClientAuth(req.TLSClientAuth),
|
||||||
|
TLSClientCAFile: "",
|
||||||
|
TLSPKIClientCAID: strings.TrimSpace(req.TLSPKIClientCAID),
|
||||||
|
TLSMinVersion: normalizeTLSMinVersion(req.TLSMinVersion),
|
||||||
|
}
|
||||||
|
if len(settings.HTTPAddrs) == 0 && len(settings.HTTPSAddrs) == 0 {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "at least one of http_addrs or https_addrs is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(settings.HTTPSAddrs) > 0 && strings.TrimSpace(settings.TLSPKIServerCertID) == "" {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "tls_pki_server_cert_id is required when https_addrs is configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tlsClientAuthNeedsCA(settings.TLSClientAuth) && !tlsClientCAConfigured(settings.TLSPKIClientCAID) {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "tls_pki_client_ca_id is required for selected tls_client_auth"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = api.Store.SetTLSSettings(settings)
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteJSON(w, http.StatusOK, settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) ListTLSListeners(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
||||||
|
var items []models.TLSListener
|
||||||
|
var err error
|
||||||
|
if !api.requireAdmin(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err = api.Store.ListTLSListeners()
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteJSON(w, http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) CreateTLSListener(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
||||||
|
var req tlsListenerRequest
|
||||||
|
var item models.TLSListener
|
||||||
|
var created models.TLSListener
|
||||||
|
var err error
|
||||||
|
if !api.requireAdmin(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = DecodeJSON(r, &req)
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item = normalizeTLSListenerRequest(req)
|
||||||
|
if item.Name == "" {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(item.HTTPAddrs) == 0 && len(item.HTTPSAddrs) == 0 {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "at least one of http_addrs or https_addrs is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(item.HTTPSAddrs) > 0 && strings.TrimSpace(item.TLSPKIServerCertID) == "" {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "tls_pki_server_cert_id is required when https_addrs is configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tlsClientAuthNeedsCA(item.TLSClientAuth) && !tlsClientCAConfigured(item.TLSPKIClientCAID) {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "tls_pki_client_ca_id is required for selected tls_client_auth"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
created, err = api.Store.CreateTLSListener(item)
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if api.OnTLSListenersChanged != nil {
|
||||||
|
api.OnTLSListenersChanged()
|
||||||
|
}
|
||||||
|
WriteJSON(w, http.StatusCreated, created)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) UpdateTLSListener(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||||
|
var req tlsListenerRequest
|
||||||
|
var item models.TLSListener
|
||||||
|
var err error
|
||||||
|
if !api.requireAdmin(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err = api.Store.GetTLSListener(params["id"])
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "listener not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = DecodeJSON(r, &req)
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item = mergeTLSListener(item, normalizeTLSListenerRequest(req))
|
||||||
|
if item.Name == "" {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(item.HTTPAddrs) == 0 && len(item.HTTPSAddrs) == 0 {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "at least one of http_addrs or https_addrs is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(item.HTTPSAddrs) > 0 && strings.TrimSpace(item.TLSPKIServerCertID) == "" {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "tls_pki_server_cert_id is required when https_addrs is configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tlsClientAuthNeedsCA(item.TLSClientAuth) && !tlsClientCAConfigured(item.TLSPKIClientCAID) {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "tls_pki_client_ca_id is required for selected tls_client_auth"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = api.Store.UpdateTLSListener(item)
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if api.OnTLSListenersChanged != nil {
|
||||||
|
api.OnTLSListenersChanged()
|
||||||
|
}
|
||||||
|
WriteJSON(w, http.StatusOK, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) DeleteTLSListener(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||||
|
var err error
|
||||||
|
if !api.requireAdmin(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = api.Store.DeleteTLSListener(params["id"])
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if api.OnTLSListenersChanged != nil {
|
||||||
|
api.OnTLSListenersChanged()
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) GetTLSListenerRuntimeStatus(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
||||||
|
var status map[string]int
|
||||||
|
if !api.requireAdmin(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status = make(map[string]int)
|
||||||
|
if api.OnTLSListenerRuntimeStatus != nil {
|
||||||
|
status = api.OnTLSListenerRuntimeStatus()
|
||||||
|
}
|
||||||
|
WriteJSON(w, http.StatusOK, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) ListServicePrincipals(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
||||||
|
var items []models.ServicePrincipal
|
||||||
|
var err error
|
||||||
|
if !api.requireAdmin(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err = api.Store.ListServicePrincipals()
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteJSON(w, http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) CreateServicePrincipal(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
||||||
|
var req servicePrincipalRequest
|
||||||
|
var item models.ServicePrincipal
|
||||||
|
var created models.ServicePrincipal
|
||||||
|
var err error
|
||||||
|
if !api.requireAdmin(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = DecodeJSON(r, &req)
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item = models.ServicePrincipal{
|
||||||
|
Name: strings.TrimSpace(req.Name),
|
||||||
|
Description: strings.TrimSpace(req.Description),
|
||||||
|
IsAdmin: req.IsAdmin,
|
||||||
|
Disabled: req.Disabled,
|
||||||
|
}
|
||||||
|
if item.Name == "" {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
created, err = api.Store.CreateServicePrincipal(item)
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteJSON(w, http.StatusCreated, created)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) UpdateServicePrincipal(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||||
|
var req servicePrincipalRequest
|
||||||
|
var item models.ServicePrincipal
|
||||||
|
var err error
|
||||||
|
if !api.requireAdmin(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err = api.Store.GetServicePrincipal(params["id"])
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "principal not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = DecodeJSON(r, &req)
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item.Name = strings.TrimSpace(req.Name)
|
||||||
|
item.Description = strings.TrimSpace(req.Description)
|
||||||
|
item.IsAdmin = req.IsAdmin
|
||||||
|
item.Disabled = req.Disabled
|
||||||
|
if item.Name == "" {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = api.Store.UpdateServicePrincipal(item)
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteJSON(w, http.StatusOK, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) DeleteServicePrincipal(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||||
|
var err error
|
||||||
|
if !api.requireAdmin(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = api.Store.DeleteServicePrincipal(params["id"])
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) ListCertPrincipalBindings(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
||||||
|
var items []models.CertPrincipalBinding
|
||||||
|
var err error
|
||||||
|
if !api.requireAdmin(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err = api.Store.ListCertPrincipalBindings()
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteJSON(w, http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) UpsertCertPrincipalBinding(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
||||||
|
var req certPrincipalBindingRequest
|
||||||
|
var item models.CertPrincipalBinding
|
||||||
|
var updated models.CertPrincipalBinding
|
||||||
|
var err error
|
||||||
|
if !api.requireAdmin(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = DecodeJSON(r, &req)
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item = models.CertPrincipalBinding{
|
||||||
|
Fingerprint: strings.ToLower(strings.TrimSpace(req.Fingerprint)),
|
||||||
|
PrincipalID: strings.TrimSpace(req.PrincipalID),
|
||||||
|
Enabled: req.Enabled,
|
||||||
|
}
|
||||||
|
if item.Fingerprint == "" || item.PrincipalID == "" {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "fingerprint and principal_id are required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updated, err = api.Store.UpsertCertPrincipalBinding(item)
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteJSON(w, http.StatusOK, updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) DeleteCertPrincipalBinding(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||||
|
var err error
|
||||||
|
if !api.requireAdmin(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = api.Store.DeleteCertPrincipalBinding(params["fingerprint"])
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) ListPrincipalProjectRoles(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||||
|
var items []models.PrincipalProjectRole
|
||||||
|
var err error
|
||||||
|
if !api.requireAdmin(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err = api.Store.ListPrincipalProjectRoles(params["id"])
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteJSON(w, http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) UpsertPrincipalProjectRole(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||||
|
var req principalProjectRoleRequest
|
||||||
|
var item models.PrincipalProjectRole
|
||||||
|
var saved models.PrincipalProjectRole
|
||||||
|
var err error
|
||||||
|
if !api.requireAdmin(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = DecodeJSON(r, &req)
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item = models.PrincipalProjectRole{
|
||||||
|
PrincipalID: strings.TrimSpace(params["id"]),
|
||||||
|
ProjectID: strings.TrimSpace(req.ProjectID),
|
||||||
|
Role: normalizeRole(req.Role),
|
||||||
|
}
|
||||||
|
if item.PrincipalID == "" || item.ProjectID == "" {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "principal id and project id are required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saved, err = api.Store.UpsertPrincipalProjectRole(item)
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteJSON(w, http.StatusOK, saved)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) DeletePrincipalProjectRole(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||||
|
var err error
|
||||||
|
if !api.requireAdmin(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = api.Store.DeletePrincipalProjectRole(params["id"], params["projectId"])
|
||||||
|
if err != nil {
|
||||||
|
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
func (api *API) ListProjects(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
func (api *API) ListProjects(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
||||||
var projects []models.Project
|
var projects []models.Project
|
||||||
var err error
|
var err error
|
||||||
@@ -3518,33 +3963,51 @@ func (api *API) Health(w http.ResponseWriter, _ *http.Request, _ map[string]stri
|
|||||||
func (api *API) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
func (api *API) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||||||
var user models.User
|
var user models.User
|
||||||
var ok bool
|
var ok bool
|
||||||
|
var principal models.ServicePrincipal
|
||||||
user, ok = middleware.UserFromContext(r.Context())
|
user, ok = middleware.UserFromContext(r.Context())
|
||||||
if !ok || !user.IsAdmin {
|
if ok && user.IsAdmin {
|
||||||
w.WriteHeader(http.StatusForbidden)
|
return true
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
return true
|
principal, ok = middleware.PrincipalFromContext(r.Context())
|
||||||
|
if ok && principal.IsAdmin && !principal.Disabled {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) requireProjectRole(w http.ResponseWriter, r *http.Request, projectID, required string) bool {
|
func (api *API) requireProjectRole(w http.ResponseWriter, r *http.Request, projectID, required string) bool {
|
||||||
var user models.User
|
var user models.User
|
||||||
|
var principal models.ServicePrincipal
|
||||||
var ok bool
|
var ok bool
|
||||||
user, ok = middleware.UserFromContext(r.Context())
|
user, ok = middleware.UserFromContext(r.Context())
|
||||||
if !ok {
|
if ok && user.IsAdmin {
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if user.IsAdmin {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
var role string
|
var role string
|
||||||
var err error
|
var err error
|
||||||
role, err = api.Store.GetProjectMemberRole(projectID, user.ID)
|
if ok {
|
||||||
if err != nil {
|
role, err = api.Store.GetProjectMemberRole(projectID, user.ID)
|
||||||
w.WriteHeader(http.StatusForbidden)
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !roleAllows(role, required) {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
principal, ok = middleware.PrincipalFromContext(r.Context())
|
||||||
|
if !ok || principal.Disabled {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if !roleAllows(role, required) {
|
if principal.IsAdmin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
role, err = api.Store.GetPrincipalProjectRole(principal.ID, projectID)
|
||||||
|
if err != nil || !roleAllows(role, required) {
|
||||||
w.WriteHeader(http.StatusForbidden)
|
w.WriteHeader(http.StatusForbidden)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -3553,17 +4016,14 @@ func (api *API) requireProjectRole(w http.ResponseWriter, r *http.Request, proje
|
|||||||
|
|
||||||
func (api *API) requireRepoRole(w http.ResponseWriter, r *http.Request, repoID, required string) bool {
|
func (api *API) requireRepoRole(w http.ResponseWriter, r *http.Request, repoID, required string) bool {
|
||||||
var user models.User
|
var user models.User
|
||||||
|
var principal models.ServicePrincipal
|
||||||
var ok bool
|
var ok bool
|
||||||
var projectIDs []string
|
var projectIDs []string
|
||||||
var err error
|
var err error
|
||||||
var i int
|
var i int
|
||||||
var role string
|
var role string
|
||||||
user, ok = middleware.UserFromContext(r.Context())
|
user, ok = middleware.UserFromContext(r.Context())
|
||||||
if !ok {
|
if ok && user.IsAdmin {
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if user.IsAdmin {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
projectIDs, err = api.Store.GetRepoProjectIDs(repoID)
|
projectIDs, err = api.Store.GetRepoProjectIDs(repoID)
|
||||||
@@ -3571,8 +4031,29 @@ func (api *API) requireRepoRole(w http.ResponseWriter, r *http.Request, repoID,
|
|||||||
w.WriteHeader(http.StatusForbidden)
|
w.WriteHeader(http.StatusForbidden)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if ok {
|
||||||
|
for i = 0; i < len(projectIDs); i++ {
|
||||||
|
role, err = api.Store.GetProjectMemberRole(projectIDs[i], user.ID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if roleAllows(role, required) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
principal, ok = middleware.PrincipalFromContext(r.Context())
|
||||||
|
if !ok || principal.Disabled {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if principal.IsAdmin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
for i = 0; i < len(projectIDs); i++ {
|
for i = 0; i < len(projectIDs); i++ {
|
||||||
role, err = api.Store.GetProjectMemberRole(projectIDs[i], user.ID)
|
role, err = api.Store.GetPrincipalProjectRole(principal.ID, projectIDs[i])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -3720,6 +4201,51 @@ func (api *API) effectiveAuthConfig() (config.Config, error) {
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (api *API) getMergedTLSSettings() (models.TLSSettings, error) {
|
||||||
|
var settings models.TLSSettings
|
||||||
|
var saved models.TLSSettings
|
||||||
|
var err error
|
||||||
|
settings = models.TLSSettings{
|
||||||
|
HTTPAddrs: normalizeAddrList(api.Cfg.HTTPAddrs),
|
||||||
|
HTTPSAddrs: normalizeAddrList(api.Cfg.HTTPSAddrs),
|
||||||
|
TLSServerCertSource: "pki",
|
||||||
|
TLSCertFile: "",
|
||||||
|
TLSKeyFile: "",
|
||||||
|
TLSPKIServerCertID: api.Cfg.TLSPKIServerCertID,
|
||||||
|
TLSClientAuth: normalizeTLSClientAuth(api.Cfg.TLSClientAuth),
|
||||||
|
TLSClientCAFile: "",
|
||||||
|
TLSPKIClientCAID: api.Cfg.TLSPKIClientCAID,
|
||||||
|
TLSMinVersion: normalizeTLSMinVersion(api.Cfg.TLSMinVersion),
|
||||||
|
}
|
||||||
|
saved, err = api.Store.GetTLSSettings()
|
||||||
|
if err != nil {
|
||||||
|
return settings, err
|
||||||
|
}
|
||||||
|
if len(saved.HTTPAddrs) > 0 {
|
||||||
|
settings.HTTPAddrs = normalizeAddrList(saved.HTTPAddrs)
|
||||||
|
}
|
||||||
|
if len(saved.HTTPSAddrs) > 0 {
|
||||||
|
settings.HTTPSAddrs = normalizeAddrList(saved.HTTPSAddrs)
|
||||||
|
}
|
||||||
|
settings.TLSServerCertSource = "pki"
|
||||||
|
settings.TLSCertFile = ""
|
||||||
|
settings.TLSKeyFile = ""
|
||||||
|
if strings.TrimSpace(saved.TLSPKIServerCertID) != "" {
|
||||||
|
settings.TLSPKIServerCertID = saved.TLSPKIServerCertID
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(saved.TLSClientAuth) != "" {
|
||||||
|
settings.TLSClientAuth = normalizeTLSClientAuth(saved.TLSClientAuth)
|
||||||
|
}
|
||||||
|
settings.TLSClientCAFile = ""
|
||||||
|
if strings.TrimSpace(saved.TLSPKIClientCAID) != "" {
|
||||||
|
settings.TLSPKIClientCAID = saved.TLSPKIClientCAID
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(saved.TLSMinVersion) != "" {
|
||||||
|
settings.TLSMinVersion = normalizeTLSMinVersion(saved.TLSMinVersion)
|
||||||
|
}
|
||||||
|
return settings, nil
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeRepoType(value string) (string, bool) {
|
func normalizeRepoType(value string) (string, bool) {
|
||||||
var v string
|
var v string
|
||||||
v = strings.ToLower(strings.TrimSpace(value))
|
v = strings.ToLower(strings.TrimSpace(value))
|
||||||
@@ -3732,6 +4258,139 @@ func normalizeRepoType(value string) (string, bool) {
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeAddrList(values []string) []string {
|
||||||
|
var out []string
|
||||||
|
var i int
|
||||||
|
var v string
|
||||||
|
for i = 0; i < len(values); i++ {
|
||||||
|
v = strings.TrimSpace(values[i])
|
||||||
|
if v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTLSServerCertSource(value string) string {
|
||||||
|
_ = value
|
||||||
|
return "pki"
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTLSClientAuth(value string) string {
|
||||||
|
var v string
|
||||||
|
v = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
if v == "request" || v == "require" || v == "verify_if_given" || v == "require_and_verify" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTLSMinVersion(value string) string {
|
||||||
|
var v string
|
||||||
|
v = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
if v == "1.0" || v == "1.1" || v == "1.3" || v == "tls1.0" || v == "tls1.1" || v == "tls1.3" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return "1.2"
|
||||||
|
}
|
||||||
|
|
||||||
|
func tlsClientAuthNeedsCA(value string) bool {
|
||||||
|
var v string
|
||||||
|
v = normalizeTLSClientAuth(value)
|
||||||
|
return v == "require_and_verify" || v == "verify_if_given"
|
||||||
|
}
|
||||||
|
|
||||||
|
func tlsClientCAConfigured(pkiCAID string) bool {
|
||||||
|
return strings.TrimSpace(pkiCAID) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTLSListenerRequest(req tlsListenerRequest) models.TLSListener {
|
||||||
|
var item models.TLSListener
|
||||||
|
var applyAPI bool
|
||||||
|
var applyGit bool
|
||||||
|
var applyRPM bool
|
||||||
|
var applyV2 bool
|
||||||
|
applyAPI = req.ApplyPolicyAPI
|
||||||
|
applyGit = req.ApplyPolicyGit
|
||||||
|
applyRPM = req.ApplyPolicyRPM
|
||||||
|
applyV2 = req.ApplyPolicyV2
|
||||||
|
if !applyAPI && !applyGit && !applyRPM && !applyV2 {
|
||||||
|
applyAPI = true
|
||||||
|
applyGit = true
|
||||||
|
applyRPM = true
|
||||||
|
applyV2 = true
|
||||||
|
}
|
||||||
|
item = models.TLSListener{
|
||||||
|
Name: strings.TrimSpace(req.Name),
|
||||||
|
Enabled: req.Enabled,
|
||||||
|
HTTPAddrs: normalizeAddrList(req.HTTPAddrs),
|
||||||
|
HTTPSAddrs: normalizeAddrList(req.HTTPSAddrs),
|
||||||
|
AuthPolicy: normalizeTLSAuthPolicy(req.AuthPolicy),
|
||||||
|
ApplyPolicyAPI: applyAPI,
|
||||||
|
ApplyPolicyGit: applyGit,
|
||||||
|
ApplyPolicyRPM: applyRPM,
|
||||||
|
ApplyPolicyV2: applyV2,
|
||||||
|
ClientCertAllowlist: normalizeTLSCertAllowlist(req.ClientCertAllowlist),
|
||||||
|
TLSServerCertSource: "pki",
|
||||||
|
TLSCertFile: "",
|
||||||
|
TLSKeyFile: "",
|
||||||
|
TLSPKIServerCertID: strings.TrimSpace(req.TLSPKIServerCertID),
|
||||||
|
TLSClientAuth: normalizeTLSClientAuth(req.TLSClientAuth),
|
||||||
|
TLSClientCAFile: "",
|
||||||
|
TLSPKIClientCAID: strings.TrimSpace(req.TLSPKIClientCAID),
|
||||||
|
TLSMinVersion: normalizeTLSMinVersion(req.TLSMinVersion),
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeTLSListener(current models.TLSListener, updated models.TLSListener) models.TLSListener {
|
||||||
|
var out models.TLSListener
|
||||||
|
out = current
|
||||||
|
out.Name = updated.Name
|
||||||
|
out.Enabled = updated.Enabled
|
||||||
|
out.HTTPAddrs = updated.HTTPAddrs
|
||||||
|
out.HTTPSAddrs = updated.HTTPSAddrs
|
||||||
|
out.AuthPolicy = updated.AuthPolicy
|
||||||
|
out.ApplyPolicyAPI = updated.ApplyPolicyAPI
|
||||||
|
out.ApplyPolicyGit = updated.ApplyPolicyGit
|
||||||
|
out.ApplyPolicyRPM = updated.ApplyPolicyRPM
|
||||||
|
out.ApplyPolicyV2 = updated.ApplyPolicyV2
|
||||||
|
out.ClientCertAllowlist = updated.ClientCertAllowlist
|
||||||
|
out.TLSServerCertSource = updated.TLSServerCertSource
|
||||||
|
out.TLSCertFile = updated.TLSCertFile
|
||||||
|
out.TLSKeyFile = updated.TLSKeyFile
|
||||||
|
out.TLSPKIServerCertID = updated.TLSPKIServerCertID
|
||||||
|
out.TLSClientAuth = updated.TLSClientAuth
|
||||||
|
out.TLSClientCAFile = updated.TLSClientCAFile
|
||||||
|
out.TLSPKIClientCAID = updated.TLSPKIClientCAID
|
||||||
|
out.TLSMinVersion = updated.TLSMinVersion
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTLSAuthPolicy(value string) string {
|
||||||
|
var v string
|
||||||
|
v = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
if v == "read_open_write_cert" || v == "read_open_write_cert_or_auth" || v == "cert_only" || v == "read_only_public" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTLSCertAllowlist(values []string) []string {
|
||||||
|
var out []string
|
||||||
|
var i int
|
||||||
|
var v string
|
||||||
|
for i = 0; i < len(values); i++ {
|
||||||
|
v = strings.ToLower(strings.TrimSpace(values[i]))
|
||||||
|
if v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func roleAllows(actual, required string) bool {
|
func roleAllows(actual, required string) bool {
|
||||||
actual = normalizeRole(actual)
|
actual = normalizeRole(actual)
|
||||||
required = normalizeRole(required)
|
required = normalizeRole(required)
|
||||||
|
|||||||
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
|
type ctxKey string
|
||||||
|
|
||||||
const userKey ctxKey = "user"
|
const userKey ctxKey = "user"
|
||||||
|
const principalKey ctxKey = "principal"
|
||||||
|
|
||||||
func WithUser(store *db.Store, next http.Handler) http.Handler {
|
func WithUser(store *db.Store, next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -68,6 +69,19 @@ func UserFromContext(ctx context.Context) (models.User, bool) {
|
|||||||
return user, ok
|
return user, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WithPrincipal(r *http.Request, principal models.ServicePrincipal) *http.Request {
|
||||||
|
var ctx context.Context
|
||||||
|
ctx = context.WithValue(r.Context(), principalKey, principal)
|
||||||
|
return r.WithContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func PrincipalFromContext(ctx context.Context) (models.ServicePrincipal, bool) {
|
||||||
|
var principal models.ServicePrincipal
|
||||||
|
var ok bool
|
||||||
|
principal, ok = ctx.Value(principalKey).(models.ServicePrincipal)
|
||||||
|
return principal, ok
|
||||||
|
}
|
||||||
|
|
||||||
func RequireAuth(next http.Handler) http.Handler {
|
func RequireAuth(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
var ok bool
|
var ok bool
|
||||||
|
|||||||
@@ -128,3 +128,96 @@ type AuthSettings struct {
|
|||||||
OIDCScopes string `json:"oidc_scopes"`
|
OIDCScopes string `json:"oidc_scopes"`
|
||||||
OIDCTLSInsecureSkipVerify bool `json:"oidc_tls_insecure_skip_verify"`
|
OIDCTLSInsecureSkipVerify bool `json:"oidc_tls_insecure_skip_verify"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TLSSettings struct {
|
||||||
|
HTTPAddrs []string `json:"http_addrs"`
|
||||||
|
HTTPSAddrs []string `json:"https_addrs"`
|
||||||
|
TLSServerCertSource string `json:"tls_server_cert_source"`
|
||||||
|
TLSCertFile string `json:"tls_cert_file"`
|
||||||
|
TLSKeyFile string `json:"tls_key_file"`
|
||||||
|
TLSPKIServerCertID string `json:"tls_pki_server_cert_id"`
|
||||||
|
TLSClientAuth string `json:"tls_client_auth"`
|
||||||
|
TLSClientCAFile string `json:"tls_client_ca_file"`
|
||||||
|
TLSPKIClientCAID string `json:"tls_pki_client_ca_id"`
|
||||||
|
TLSMinVersion string `json:"tls_min_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TLSListener struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
HTTPAddrs []string `json:"http_addrs"`
|
||||||
|
HTTPSAddrs []string `json:"https_addrs"`
|
||||||
|
AuthPolicy string `json:"auth_policy"`
|
||||||
|
ApplyPolicyAPI bool `json:"apply_policy_api"`
|
||||||
|
ApplyPolicyGit bool `json:"apply_policy_git"`
|
||||||
|
ApplyPolicyRPM bool `json:"apply_policy_rpm"`
|
||||||
|
ApplyPolicyV2 bool `json:"apply_policy_v2"`
|
||||||
|
ClientCertAllowlist []string `json:"client_cert_allowlist"`
|
||||||
|
TLSServerCertSource string `json:"tls_server_cert_source"`
|
||||||
|
TLSCertFile string `json:"tls_cert_file"`
|
||||||
|
TLSKeyFile string `json:"tls_key_file"`
|
||||||
|
TLSPKIServerCertID string `json:"tls_pki_server_cert_id"`
|
||||||
|
TLSClientAuth string `json:"tls_client_auth"`
|
||||||
|
TLSClientCAFile string `json:"tls_client_ca_file"`
|
||||||
|
TLSPKIClientCAID string `json:"tls_pki_client_ca_id"`
|
||||||
|
TLSMinVersion string `json:"tls_min_version"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PKICA struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ParentCAID string `json:"parent_ca_id"`
|
||||||
|
IsRoot bool `json:"is_root"`
|
||||||
|
CertPEM string `json:"cert_pem"`
|
||||||
|
KeyPEM string `json:"key_pem"`
|
||||||
|
SerialCounter int64 `json:"serial_counter"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PKICert struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CAID string `json:"ca_id"`
|
||||||
|
SerialHex string `json:"serial_hex"`
|
||||||
|
CommonName string `json:"common_name"`
|
||||||
|
SANDNS string `json:"san_dns"`
|
||||||
|
SANIPs string `json:"san_ips"`
|
||||||
|
IsCA bool `json:"is_ca"`
|
||||||
|
CertPEM string `json:"cert_pem"`
|
||||||
|
KeyPEM string `json:"key_pem"`
|
||||||
|
NotBefore int64 `json:"not_before"`
|
||||||
|
NotAfter int64 `json:"not_after"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
RevokedAt int64 `json:"revoked_at"`
|
||||||
|
RevocationReason string `json:"revocation_reason"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServicePrincipal struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
Disabled bool `json:"disabled"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CertPrincipalBinding struct {
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
PrincipalID string `json:"principal_id"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PrincipalProjectRole struct {
|
||||||
|
PrincipalID string `json:"principal_id"`
|
||||||
|
ProjectID string `json:"project_id"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
}
|
||||||
|
|||||||
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
|
email: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServicePrincipal {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
is_admin: boolean
|
||||||
|
disabled: boolean
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CertPrincipalBinding {
|
||||||
|
fingerprint: string
|
||||||
|
principal_id: string
|
||||||
|
enabled: boolean
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrincipalProjectRole {
|
||||||
|
principal_id: string
|
||||||
|
project_id: string
|
||||||
|
role: 'viewer' | 'writer' | 'admin'
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthSettings {
|
export interface AuthSettings {
|
||||||
auth_mode: 'db' | 'ldap' | 'hybrid'
|
auth_mode: 'db' | 'ldap' | 'hybrid'
|
||||||
oidc_enabled: boolean
|
oidc_enabled: boolean
|
||||||
@@ -212,12 +237,87 @@ export interface AuthSettings {
|
|||||||
oidc_tls_insecure_skip_verify: boolean
|
oidc_tls_insecure_skip_verify: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TLSSettings {
|
||||||
|
http_addrs: string[]
|
||||||
|
https_addrs: string[]
|
||||||
|
tls_server_cert_source: 'pki'
|
||||||
|
tls_cert_file: string
|
||||||
|
tls_key_file: string
|
||||||
|
tls_pki_server_cert_id: string
|
||||||
|
tls_client_auth: 'none' | 'request' | 'require' | 'verify_if_given' | 'require_and_verify'
|
||||||
|
tls_client_ca_file: string
|
||||||
|
tls_pki_client_ca_id: string
|
||||||
|
tls_min_version: '1.0' | '1.1' | '1.2' | '1.3'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TLSListener {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
enabled: boolean
|
||||||
|
http_addrs: string[]
|
||||||
|
https_addrs: string[]
|
||||||
|
auth_policy: 'default' | 'read_open_write_cert' | 'read_open_write_cert_or_auth' | 'cert_only' | 'read_only_public'
|
||||||
|
apply_policy_api: boolean
|
||||||
|
apply_policy_git: boolean
|
||||||
|
apply_policy_rpm: boolean
|
||||||
|
apply_policy_v2: boolean
|
||||||
|
client_cert_allowlist: string[]
|
||||||
|
tls_server_cert_source: 'pki'
|
||||||
|
tls_cert_file: string
|
||||||
|
tls_key_file: string
|
||||||
|
tls_pki_server_cert_id: string
|
||||||
|
tls_client_auth: 'none' | 'request' | 'require' | 'verify_if_given' | 'require_and_verify'
|
||||||
|
tls_client_ca_file: string
|
||||||
|
tls_pki_client_ca_id: string
|
||||||
|
tls_min_version: '1.0' | '1.1' | '1.2' | '1.3'
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface OIDCStatus {
|
export interface OIDCStatus {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
configured?: boolean
|
configured?: boolean
|
||||||
auth_mode?: string
|
auth_mode?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PKICA {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
parent_ca_id: string
|
||||||
|
is_root: boolean
|
||||||
|
status: string
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PKICADetail extends PKICA {
|
||||||
|
cert_pem: string
|
||||||
|
key_pem: string
|
||||||
|
serial_counter: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PKICert {
|
||||||
|
id: string
|
||||||
|
ca_id: string
|
||||||
|
serial_hex: string
|
||||||
|
common_name: string
|
||||||
|
fingerprint?: string
|
||||||
|
san_dns: string
|
||||||
|
san_ips: string
|
||||||
|
is_ca: boolean
|
||||||
|
not_before: number
|
||||||
|
not_after: number
|
||||||
|
status: string
|
||||||
|
revoked_at: number
|
||||||
|
revocation_reason: string
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PKICertDetail extends PKICert {
|
||||||
|
cert_pem: string
|
||||||
|
key_pem: string
|
||||||
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
@@ -287,6 +387,22 @@ export const api = {
|
|||||||
deleteAdminAPIKey: (id: string) => request<void>(`/api/admin/api-keys/${id}`, { method: 'DELETE' }),
|
deleteAdminAPIKey: (id: string) => request<void>(`/api/admin/api-keys/${id}`, { method: 'DELETE' }),
|
||||||
disableAdminAPIKey: (id: string) => request<void>(`/api/admin/api-keys/${id}/disable`, { method: 'POST' }),
|
disableAdminAPIKey: (id: string) => request<void>(`/api/admin/api-keys/${id}/disable`, { method: 'POST' }),
|
||||||
enableAdminAPIKey: (id: string) => request<void>(`/api/admin/api-keys/${id}/enable`, { method: 'POST' }),
|
enableAdminAPIKey: (id: string) => request<void>(`/api/admin/api-keys/${id}/enable`, { method: 'POST' }),
|
||||||
|
listServicePrincipals: () => request<ServicePrincipal[]>('/api/admin/service-principals'),
|
||||||
|
createServicePrincipal: (payload: { name: string; description?: string; is_admin?: boolean; disabled?: boolean }) =>
|
||||||
|
request<ServicePrincipal>('/api/admin/service-principals', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
|
updateServicePrincipal: (id: string, payload: { name: string; description?: string; is_admin: boolean; disabled: boolean }) =>
|
||||||
|
request<ServicePrincipal>(`/api/admin/service-principals/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }),
|
||||||
|
deleteServicePrincipal: (id: string) => request<void>(`/api/admin/service-principals/${id}`, { method: 'DELETE' }),
|
||||||
|
listPrincipalProjectRoles: (principalID: string) => request<PrincipalProjectRole[]>(`/api/admin/service-principals/${principalID}/roles`),
|
||||||
|
upsertPrincipalProjectRole: (principalID: string, payload: { project_id: string; role: 'viewer' | 'writer' | 'admin' }) =>
|
||||||
|
request<PrincipalProjectRole>(`/api/admin/service-principals/${principalID}/roles`, { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
|
deletePrincipalProjectRole: (principalID: string, projectID: string) =>
|
||||||
|
request<void>(`/api/admin/service-principals/${principalID}/roles/${projectID}`, { method: 'DELETE' }),
|
||||||
|
listCertPrincipalBindings: () => request<CertPrincipalBinding[]>('/api/admin/cert-principal-bindings'),
|
||||||
|
upsertCertPrincipalBinding: (payload: { fingerprint: string; principal_id: string; enabled: boolean }) =>
|
||||||
|
request<CertPrincipalBinding>('/api/admin/cert-principal-bindings', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
|
deleteCertPrincipalBinding: (fingerprint: string) =>
|
||||||
|
request<void>(`/api/admin/cert-principal-bindings/${encodeURIComponent(fingerprint)}`, { method: 'DELETE' }),
|
||||||
getAuthSettings: () => request<AuthSettings>('/api/admin/auth'),
|
getAuthSettings: () => request<AuthSettings>('/api/admin/auth'),
|
||||||
updateAuthSettings: (payload: AuthSettings) =>
|
updateAuthSettings: (payload: AuthSettings) =>
|
||||||
request<AuthSettings>('/api/admin/auth', { method: 'PATCH', body: JSON.stringify(payload) }),
|
request<AuthSettings>('/api/admin/auth', { method: 'PATCH', body: JSON.stringify(payload) }),
|
||||||
@@ -299,6 +415,49 @@ export const api = {
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
signal
|
signal
|
||||||
}),
|
}),
|
||||||
|
getTLSSettings: () => request<TLSSettings>('/api/admin/tls'),
|
||||||
|
updateTLSSettings: (payload: TLSSettings) =>
|
||||||
|
request<TLSSettings>('/api/admin/tls', { method: 'PATCH', body: JSON.stringify(payload) }),
|
||||||
|
listTLSListeners: () => request<TLSListener[]>('/api/admin/tls/listeners'),
|
||||||
|
getTLSListenerRuntimeStatus: () => request<Record<string, number>>('/api/admin/tls/listeners/runtime'),
|
||||||
|
createTLSListener: (payload: Omit<TLSListener, 'id' | 'created_at' | 'updated_at'>) =>
|
||||||
|
request<TLSListener>('/api/admin/tls/listeners', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
|
updateTLSListener: (id: string, payload: Omit<TLSListener, 'id' | 'created_at' | 'updated_at'>) =>
|
||||||
|
request<TLSListener>(`/api/admin/tls/listeners/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }),
|
||||||
|
deleteTLSListener: (id: string) => request<void>(`/api/admin/tls/listeners/${id}`, { method: 'DELETE' }),
|
||||||
|
listPKICAs: () => request<PKICA[]>('/api/admin/pki/cas'),
|
||||||
|
getPKICA: (id: string) => request<PKICADetail>(`/api/admin/pki/cas/${id}`),
|
||||||
|
updatePKICA: (id: string, payload: { name: string }) =>
|
||||||
|
request<PKICADetail>(`/api/admin/pki/cas/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }),
|
||||||
|
downloadPKICABundle: (id: string) => requestBinary(`/api/admin/pki/cas/${id}/bundle`),
|
||||||
|
createPKIRootCA: (payload: { name: string; common_name: string; days: number; cert_pem?: string; key_pem?: string }) =>
|
||||||
|
request<PKICA>('/api/admin/pki/cas/root', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
|
createPKIIntermediateCA: (payload: { name: string; parent_ca_id: string; common_name: string; days: number; cert_pem?: string; key_pem?: string }) =>
|
||||||
|
request<PKICA>('/api/admin/pki/cas/intermediate', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
|
deletePKICA: (id: string, force?: boolean) =>
|
||||||
|
request<void>(`/api/admin/pki/cas/${id}${force ? '?force=1' : ''}`, { method: 'DELETE' }),
|
||||||
|
listPKICerts: (ca_id?: string) => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (ca_id) params.set('ca_id', ca_id)
|
||||||
|
const qs = params.toString()
|
||||||
|
return request<PKICert[]>(`/api/admin/pki/certs${qs ? `?${qs}` : ''}`)
|
||||||
|
},
|
||||||
|
getPKICert: (id: string) => request<PKICertDetail>(`/api/admin/pki/certs/${id}`),
|
||||||
|
downloadPKICertBundle: (id: string) => requestBinary(`/api/admin/pki/certs/${id}/bundle`),
|
||||||
|
issuePKICert: (payload: {
|
||||||
|
ca_id: string
|
||||||
|
common_name: string
|
||||||
|
san_dns: string[]
|
||||||
|
san_ips: string[]
|
||||||
|
days: number
|
||||||
|
is_ca: boolean
|
||||||
|
}) => request<PKICert>('/api/admin/pki/certs', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
|
importPKICert: (payload: { ca_id?: string; cert_pem: string; key_pem: string }) =>
|
||||||
|
request<PKICert>('/api/admin/pki/certs/import', { method: 'POST', body: JSON.stringify(payload) }),
|
||||||
|
revokePKICert: (id: string, reason: string) =>
|
||||||
|
request<{ status: string }>(`/api/admin/pki/certs/${id}/revoke`, { method: 'POST', body: JSON.stringify({ reason }) }),
|
||||||
|
deletePKICert: (id: string) => request<void>(`/api/admin/pki/certs/${id}`, { method: 'DELETE' }),
|
||||||
|
getPKICRL: (id: string) => request<{ crl_pem: string }>(`/api/admin/pki/cas/${id}/crl`),
|
||||||
|
|
||||||
listUsers: () => request<User[]>('/api/users'),
|
listUsers: () => request<User[]>('/api/users'),
|
||||||
createUser: (payload: { username: string; display_name: string; email: string; password: string; is_admin: boolean }) =>
|
createUser: (payload: { username: string; display_name: string; email: string; password: string; is_admin: boolean }) =>
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ import KeyIcon from '@mui/icons-material/Key'
|
|||||||
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'
|
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'
|
||||||
import PersonIcon from '@mui/icons-material/Person'
|
import PersonIcon from '@mui/icons-material/Person'
|
||||||
import BadgeIcon from '@mui/icons-material/Badge'
|
import BadgeIcon from '@mui/icons-material/Badge'
|
||||||
|
import SecurityIcon from '@mui/icons-material/Security'
|
||||||
|
import HttpsIcon from '@mui/icons-material/Https'
|
||||||
|
import VpnKeyIcon from '@mui/icons-material/VpnKey'
|
||||||
import DarkModeIcon from '@mui/icons-material/DarkMode'
|
import DarkModeIcon from '@mui/icons-material/DarkMode'
|
||||||
import LightModeIcon from '@mui/icons-material/LightMode'
|
import LightModeIcon from '@mui/icons-material/LightMode'
|
||||||
import { ThemeModeContext } from './ThemeModeContext'
|
import { ThemeModeContext } from './ThemeModeContext'
|
||||||
@@ -58,7 +61,10 @@ export default function Layout() {
|
|||||||
if (user?.is_admin) {
|
if (user?.is_admin) {
|
||||||
items.push({ label: 'Admin Users', path: '/admin/users', icon: <PeopleIcon fontSize="small" /> })
|
items.push({ label: 'Admin Users', path: '/admin/users', icon: <PeopleIcon fontSize="small" /> })
|
||||||
items.push({ label: 'Admin API Keys', path: '/admin/api-keys', icon: <AdminPanelSettingsIcon fontSize="small" /> })
|
items.push({ label: 'Admin API Keys', path: '/admin/api-keys', icon: <AdminPanelSettingsIcon fontSize="small" /> })
|
||||||
|
items.push({ label: 'Admin PKI', path: '/admin/pki', icon: <SecurityIcon fontSize="small" /> })
|
||||||
|
items.push({ label: 'Service Principals', path: '/admin/principals', icon: <VpnKeyIcon fontSize="small" /> })
|
||||||
items.push({ label: 'Site Auth', path: '/admin/auth', icon: <BadgeIcon fontSize="small" /> })
|
items.push({ label: 'Site Auth', path: '/admin/auth', icon: <BadgeIcon fontSize="small" /> })
|
||||||
|
items.push({ label: 'Site TLS', path: '/admin/tls', icon: <HttpsIcon fontSize="small" /> })
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
}, [user])
|
}, [user])
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import FilesPage from '../pages/FilesPage'
|
|||||||
import AdminUsersPage from '../pages/AdminUsersPage'
|
import AdminUsersPage from '../pages/AdminUsersPage'
|
||||||
import AdminApiKeysPage from '../pages/AdminApiKeysPage'
|
import AdminApiKeysPage from '../pages/AdminApiKeysPage'
|
||||||
import AdminAuthLdapPage from '../pages/AdminAuthLdapPage'
|
import AdminAuthLdapPage from '../pages/AdminAuthLdapPage'
|
||||||
|
import AdminPKIPage from '../pages/AdminPKIPage'
|
||||||
|
import AdminTLSSettingsPage from '../pages/AdminTLSSettingsPage'
|
||||||
|
import AdminServicePrincipalsPage from '../pages/AdminServicePrincipalsPage'
|
||||||
import ApiKeysPage from '../pages/ApiKeysPage'
|
import ApiKeysPage from '../pages/ApiKeysPage'
|
||||||
import AccountPage from '../pages/AccountPage'
|
import AccountPage from '../pages/AccountPage'
|
||||||
import NotFoundPage from '../pages/NotFoundPage'
|
import NotFoundPage from '../pages/NotFoundPage'
|
||||||
@@ -44,7 +47,10 @@ export const routes: RouteObject[] = [
|
|||||||
{ path: 'projects/:projectId/files', element: <FilesPage /> },
|
{ path: 'projects/:projectId/files', element: <FilesPage /> },
|
||||||
{ path: 'admin/users', element: <AdminUsersPage /> },
|
{ path: 'admin/users', element: <AdminUsersPage /> },
|
||||||
{ path: 'admin/api-keys', element: <AdminApiKeysPage /> },
|
{ path: 'admin/api-keys', element: <AdminApiKeysPage /> },
|
||||||
|
{ path: 'admin/pki', element: <AdminPKIPage /> },
|
||||||
|
{ path: 'admin/principals', element: <AdminServicePrincipalsPage /> },
|
||||||
{ path: 'admin/auth', element: <AdminAuthLdapPage /> },
|
{ path: 'admin/auth', element: <AdminAuthLdapPage /> },
|
||||||
|
{ path: 'admin/tls', element: <AdminTLSSettingsPage /> },
|
||||||
{ path: 'admin/auth/ldap', element: <AdminAuthLdapPage /> }
|
{ path: 'admin/auth/ldap', element: <AdminAuthLdapPage /> }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default function AdminAuthLdapPage() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
api
|
api
|
||||||
.getAuthSettings()
|
.getAuthSettings()
|
||||||
.then((data) => setSettings(data))
|
.then((data) => setSettings(data))
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
const message = err instanceof Error ? err.message : 'Failed to load authentication settings'
|
const message = err instanceof Error ? err.message : 'Failed to load authentication settings'
|
||||||
setError(message)
|
setError(message)
|
||||||
@@ -74,11 +74,14 @@ export default function AdminAuthLdapPage() {
|
|||||||
setError(null)
|
setError(null)
|
||||||
setTestResult(null)
|
setTestResult(null)
|
||||||
try {
|
try {
|
||||||
const result = await api.testAuthSettings({
|
const result = await api.testAuthSettings(
|
||||||
...settings,
|
{
|
||||||
username: testUsername.trim() || undefined,
|
...settings,
|
||||||
password: testPassword || undefined
|
username: testUsername.trim() || undefined,
|
||||||
}, controller.signal)
|
password: testPassword || undefined
|
||||||
|
},
|
||||||
|
controller.signal
|
||||||
|
)
|
||||||
setTestResult(result.user ? `Connection ok. User test ok: ${result.user}` : 'Connection ok.')
|
setTestResult(result.user ? `Connection ok. User test ok: ${result.user}` : 'Connection ok.')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error && err.name === 'AbortError') {
|
if (err instanceof Error && err.name === 'AbortError') {
|
||||||
@@ -107,7 +110,11 @@ export default function AdminAuthLdapPage() {
|
|||||||
Admin: Site Authentication
|
Admin: Site Authentication
|
||||||
</Typography>
|
</Typography>
|
||||||
<Paper sx={{ p: 2, maxWidth: 820 }}>
|
<Paper sx={{ p: 2, maxWidth: 820 }}>
|
||||||
{loading ? <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>Loading...</Typography> : null}
|
{loading ? (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
Loading...
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
|
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
|
||||||
{saved ? <Alert severity="success" sx={{ mb: 1 }}>Saved.</Alert> : null}
|
{saved ? <Alert severity="success" sx={{ mb: 1 }}>Saved.</Alert> : null}
|
||||||
{testResult ? <Alert severity="success" sx={{ mb: 1 }}>{testResult}</Alert> : null}
|
{testResult ? <Alert severity="success" sx={{ mb: 1 }}>{testResult}</Alert> : null}
|
||||||
@@ -126,19 +133,10 @@ export default function AdminAuthLdapPage() {
|
|||||||
OIDC
|
OIDC
|
||||||
</Typography>
|
</Typography>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={<Checkbox checked={settings.oidc_enabled} onChange={(event) => setSettings((prev) => ({ ...prev, oidc_enabled: event.target.checked }))} />}
|
||||||
<Checkbox
|
|
||||||
checked={settings.oidc_enabled}
|
|
||||||
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_enabled: event.target.checked }))}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Enable OIDC login"
|
label="Enable OIDC login"
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField label="Client ID" value={settings.oidc_client_id} onChange={(event) => setSettings((prev) => ({ ...prev, oidc_client_id: event.target.value }))} />
|
||||||
label="Client ID"
|
|
||||||
value={settings.oidc_client_id}
|
|
||||||
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_client_id: event.target.value }))}
|
|
||||||
/>
|
|
||||||
<TextField
|
<TextField
|
||||||
label="Client Secret"
|
label="Client Secret"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -172,27 +170,14 @@ export default function AdminAuthLdapPage() {
|
|||||||
helperText="Example: openid profile email"
|
helperText="Example: openid profile email"
|
||||||
/>
|
/>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={<Checkbox checked={settings.oidc_tls_insecure_skip_verify} onChange={(event) => setSettings((prev) => ({ ...prev, oidc_tls_insecure_skip_verify: event.target.checked }))} />}
|
||||||
<Checkbox
|
|
||||||
checked={settings.oidc_tls_insecure_skip_verify}
|
|
||||||
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_tls_insecure_skip_verify: event.target.checked }))}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="OIDC TLS insecure skip verify (testing/self-signed only)"
|
label="OIDC TLS insecure skip verify (testing/self-signed only)"
|
||||||
/>
|
/>
|
||||||
<Typography variant="subtitle2" sx={{ mt: 1 }}>
|
<Typography variant="subtitle2" sx={{ mt: 1 }}>
|
||||||
LDAP
|
LDAP
|
||||||
</Typography>
|
</Typography>
|
||||||
<TextField
|
<TextField label="LDAP URL" value={settings.ldap_url} onChange={(event) => setSettings((prev) => ({ ...prev, ldap_url: event.target.value }))} />
|
||||||
label="LDAP URL"
|
<TextField label="Bind DN" value={settings.ldap_bind_dn} onChange={(event) => setSettings((prev) => ({ ...prev, ldap_bind_dn: event.target.value }))} />
|
||||||
value={settings.ldap_url}
|
|
||||||
onChange={(event) => setSettings((prev) => ({ ...prev, ldap_url: event.target.value }))}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Bind DN"
|
|
||||||
value={settings.ldap_bind_dn}
|
|
||||||
onChange={(event) => setSettings((prev) => ({ ...prev, ldap_bind_dn: event.target.value }))}
|
|
||||||
/>
|
|
||||||
<TextField
|
<TextField
|
||||||
label="Bind Password"
|
label="Bind Password"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -211,28 +196,14 @@ export default function AdminAuthLdapPage() {
|
|||||||
helperText="Use {username} placeholder."
|
helperText="Use {username} placeholder."
|
||||||
/>
|
/>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={<Checkbox checked={settings.ldap_tls_insecure_skip_verify} onChange={(event) => setSettings((prev) => ({ ...prev, ldap_tls_insecure_skip_verify: event.target.checked }))} />}
|
||||||
<Checkbox
|
|
||||||
checked={settings.ldap_tls_insecure_skip_verify}
|
|
||||||
onChange={(event) => setSettings((prev) => ({ ...prev, ldap_tls_insecure_skip_verify: event.target.checked }))}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="TLS insecure skip verify (testing/self-signed only)"
|
label="TLS insecure skip verify (testing/self-signed only)"
|
||||||
/>
|
/>
|
||||||
<Typography variant="subtitle2" sx={{ mt: 1 }}>
|
<Typography variant="subtitle2" sx={{ mt: 1 }}>
|
||||||
Test (optional user bind)
|
Test (optional user bind)
|
||||||
</Typography>
|
</Typography>
|
||||||
<TextField
|
<TextField label="Test Username" value={testUsername} onChange={(event) => setTestUsername(event.target.value)} />
|
||||||
label="Test Username"
|
<TextField label="Test Password" type="password" value={testPassword} onChange={(event) => setTestPassword(event.target.value)} />
|
||||||
value={testUsername}
|
|
||||||
onChange={(event) => setTestUsername(event.target.value)}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Test Password"
|
|
||||||
type="password"
|
|
||||||
value={testPassword}
|
|
||||||
onChange={(event) => setTestPassword(event.target.value)}
|
|
||||||
/>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 1 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 1 }}>
|
||||||
<Button variant="outlined" onClick={handleTest} color={testing ? 'warning' : 'primary'}>
|
<Button variant="outlined" onClick={handleTest} color={testing ? 'warning' : 'primary'}>
|
||||||
{testing ? 'Cancel Test' : 'Test Connection'}
|
{testing ? 'Cancel Test' : 'Test Connection'}
|
||||||
|
|||||||
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