diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 35d4619..464b118 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,14 +2,11 @@ name: build run-name: Check vault and web builds on: - workflow_run: - workflows: - - vault - - web - types: - - completed + push: branches: - - main + - "**" + tags: + - "**" workflow_dispatch: inputs: sha: @@ -30,14 +27,14 @@ jobs: env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} - TRIGGER_SHA: ${{ github.event.workflow_run.head_sha }} INPUT_SHA: ${{ inputs.sha }} + CURRENT_SHA: ${{ github.sha }} run: | set -euo pipefail sha="$INPUT_SHA" if [ -z "$sha" ]; then - sha="$TRIGGER_SHA" + sha="$CURRENT_SHA" fi if [ -z "$sha" ]; then sha="$(gh api "repos/$REPO/branches/main" --jq '.commit.sha')" @@ -51,7 +48,6 @@ jobs: for attempt in $(seq 1 60); do result="$( gh api --method GET "repos/$REPO/actions/workflows/$workflow/runs" \ - -f branch=main \ -f head_sha="$sha" \ -f per_page=1 \ --jq '(.workflow_runs[0] // empty) | [.status, (.conclusion // ""), .html_url] | @tsv' diff --git a/vault/api/api.go b/vault/api/api.go index 9de98cb..0aad5e5 100644 --- a/vault/api/api.go +++ b/vault/api/api.go @@ -44,6 +44,7 @@ func InitializeRoutes(router *gin.Engine) { router.GET("/ping", Ping) router.POST("/integrations/github/actions/env", ExportGitHubActionsEnv) + router.POST("/integrations/kubernetes/secrets", ExportKubernetesSecrets) router.POST("/auth/login", LoginWithSentinel) router.GET("/auth/session", GetSession) @@ -57,6 +58,16 @@ func InitializeRoutes(router *gin.Engine) { router.PUT("/integrations/github/actions/rules/:id", UpdateGitHubActionsRule) router.DELETE("/integrations/github/actions/rules/:id", DeleteGitHubActionsRule) + router.GET("/integrations/kubernetes/clusters", ListKubernetesClusters) + router.POST("/integrations/kubernetes/clusters/verify", VerifyKubernetesCluster) + router.POST("/integrations/kubernetes/clusters", CreateKubernetesCluster) + router.PUT("/integrations/kubernetes/clusters/:id", UpdateKubernetesCluster) + router.DELETE("/integrations/kubernetes/clusters/:id", DeleteKubernetesCluster) + router.GET("/integrations/kubernetes/rules", ListKubernetesSecretRules) + router.POST("/integrations/kubernetes/rules", CreateKubernetesSecretRule) + router.PUT("/integrations/kubernetes/rules/:id", UpdateKubernetesSecretRule) + router.DELETE("/integrations/kubernetes/rules/:id", DeleteKubernetesSecretRule) + router.POST("/secrets/totp/qr", DecodeTOTPRegistrationQRCode) router.GET("/app-secrets", ListApplications) @@ -116,7 +127,8 @@ func authRouteSkipsTokenValidation(path string) bool { return path == "/auth/login" || path == "/auth/refresh" || path == "/auth/logout" || - path == "/integrations/github/actions/env" + path == "/integrations/github/actions/env" || + path == "/integrations/kubernetes/secrets" } func UnauthorizedPanicHandler() gin.HandlerFunc { @@ -243,6 +255,10 @@ func RequestTokenCanManageGitHubActionsRules(c *gin.Context) bool { return RequestTokenCanManageSettings(c) || RequestTokenHasGroupName(c, "DevopsMembers") } +func RequestTokenCanManageKubernetesSecretRules(c *gin.Context) bool { + return RequestTokenCanManageSettings(c) || RequestTokenHasGroupName(c, "DevopsMembers") +} + func GetRequestToken(c *gin.Context) string { token, _ := c.Get("Auth-Token") return contextString(token) diff --git a/vault/api/kubernetes.go b/vault/api/kubernetes.go new file mode 100644 index 0000000..6d83b73 --- /dev/null +++ b/vault/api/kubernetes.go @@ -0,0 +1,313 @@ +package api + +import ( + "errors" + "net/http" + + "github.com/gaucho-racing/vault/vault/model" + "github.com/gaucho-racing/vault/vault/pkg/kubernetes" + "github.com/gaucho-racing/vault/vault/service" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type kubernetesAppSecretsRequest struct { + Token string `json:"token" binding:"required"` + Secrets map[string]string `json:"secrets" binding:"required"` +} + +type kubernetesAppSecretsResponse struct { + Secrets map[string]string `json:"secrets"` +} + +type kubernetesClusterRequest struct { + Name string `json:"name" binding:"required"` + Issuer string `json:"issuer" binding:"required"` + Audience string `json:"audience"` + Enabled bool `json:"enabled"` +} + +type kubernetesSecretRuleRequest struct { + Name string `json:"name" binding:"required"` + ClusterIDs []string `json:"cluster_ids"` + NamespacePatterns []string `json:"namespace_patterns"` + ServiceAccountPatterns []string `json:"service_account_patterns"` + SecretSelectors []string `json:"secret_selectors"` + Enabled bool `json:"enabled"` +} + +func ExportKubernetesSecrets(c *gin.Context) { + var req kubernetesAppSecretsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + identity, err := service.VerifyKubernetesToken(c.Request.Context(), req.Token) + if err != nil { + if errors.Is(err, kubernetes.ErrInvalidToken) { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, service.ErrKubernetesClusterNotTrusted) { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + + secrets, err := service.BuildKubernetesSecretData(service.KubernetesExportRequest{ + Secrets: req.Secrets, + Claims: identity.Claims, + Cluster: identity.Cluster, + }) + if err != nil { + handleKubernetesSecretExportError(c, err) + return + } + c.JSON(http.StatusOK, kubernetesAppSecretsResponse{Secrets: secrets}) +} + +func ListKubernetesClusters(c *gin.Context) { + Require(c, RequestTokenCanManageKubernetesSecretRules(c)) + clusters, err := service.GetAllKubernetesClusters() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, clusters) +} + +func CreateKubernetesCluster(c *gin.Context) { + Require(c, RequestTokenCanManageKubernetesSecretRules(c)) + var req kubernetesClusterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + cluster, err := service.CreateKubernetesCluster(c.Request.Context(), modelKubernetesCluster(req, GetRequestEntityID(c), GetRequestEntityID(c))) + if err != nil { + handleKubernetesClusterError(c, err) + return + } + c.JSON(http.StatusOK, cluster) +} + +func VerifyKubernetesCluster(c *gin.Context) { + Require(c, RequestTokenCanManageKubernetesSecretRules(c)) + var req kubernetesClusterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + cluster := modelKubernetesCluster(req, GetRequestEntityID(c), GetRequestEntityID(c)) + if err := service.VerifyKubernetesClusterConfig(c.Request.Context(), &cluster); err != nil { + handleKubernetesClusterError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"valid": true}) +} + +func UpdateKubernetesCluster(c *gin.Context) { + Require(c, RequestTokenCanManageKubernetesSecretRules(c)) + cluster, err := service.GetKubernetesClusterByID(c.Param("id")) + if err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "kubernetes cluster not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var req kubernetesClusterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + cluster.Name = req.Name + cluster.Issuer = req.Issuer + cluster.Audience = req.Audience + cluster.Enabled = req.Enabled + cluster.UpdatedByEntityID = GetRequestEntityID(c) + + updated, err := service.UpdateKubernetesCluster(c.Request.Context(), cluster) + if err != nil { + handleKubernetesClusterError(c, err) + return + } + c.JSON(http.StatusOK, updated) +} + +func DeleteKubernetesCluster(c *gin.Context) { + Require(c, RequestTokenCanManageKubernetesSecretRules(c)) + if err := service.DeleteKubernetesCluster(c.Param("id")); err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "kubernetes cluster not found"}) + return + } + if errors.Is(err, service.ErrKubernetesClusterInUse) { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "kubernetes cluster deleted"}) +} + +func ListKubernetesSecretRules(c *gin.Context) { + Require(c, RequestTokenCanManageKubernetesSecretRules(c)) + rules, err := service.GetAllKubernetesSecretRules() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, rules) +} + +func CreateKubernetesSecretRule(c *gin.Context) { + Require(c, RequestTokenCanManageKubernetesSecretRules(c)) + var req kubernetesSecretRuleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + rule, err := service.CreateKubernetesSecretRule(modelKubernetesSecretRule(req, GetRequestEntityID(c), GetRequestEntityID(c))) + if err != nil { + handleKubernetesSecretRuleError(c, err) + return + } + c.JSON(http.StatusOK, rule) +} + +func UpdateKubernetesSecretRule(c *gin.Context) { + Require(c, RequestTokenCanManageKubernetesSecretRules(c)) + rule, err := service.GetKubernetesSecretRuleByID(c.Param("id")) + if err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "kubernetes secret rule not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var req kubernetesSecretRuleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + rule.Name = req.Name + rule.ClusterIDs = req.ClusterIDs + rule.NamespacePatterns = req.NamespacePatterns + rule.ServiceAccountPatterns = req.ServiceAccountPatterns + rule.SecretSelectors = req.SecretSelectors + rule.Enabled = req.Enabled + rule.UpdatedByEntityID = GetRequestEntityID(c) + + updated, err := service.UpdateKubernetesSecretRule(rule) + if err != nil { + handleKubernetesSecretRuleError(c, err) + return + } + c.JSON(http.StatusOK, updated) +} + +func DeleteKubernetesSecretRule(c *gin.Context) { + Require(c, RequestTokenCanManageKubernetesSecretRules(c)) + if err := service.DeleteKubernetesSecretRule(c.Param("id")); err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "kubernetes secret rule not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "kubernetes secret rule deleted"}) +} + +func handleKubernetesSecretExportError(c *gin.Context, err error) { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "app secret not found"}) + return + } + if errors.Is(err, service.ErrKubernetesSecretSelectorNotAllowed) { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, service.ErrKubernetesSecretSelectorRequired) || + errors.Is(err, service.ErrKubernetesRequestedSecretSelectorInvalid) || + errors.Is(err, service.ErrKubernetesSecretKeyRequired) || + errors.Is(err, service.ErrKubernetesSecretKeyInvalid) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +} + +func handleKubernetesSecretRuleError(c *gin.Context, err error) { + if errors.Is(err, service.ErrKubernetesSecretRuleNameRequired) || + errors.Is(err, service.ErrKubernetesSecretRuleNameInvalid) || + errors.Is(err, service.ErrKubernetesClusterRequired) || + errors.Is(err, service.ErrKubernetesClusterInvalid) || + errors.Is(err, service.ErrKubernetesNamespacePatternRequired) || + errors.Is(err, service.ErrKubernetesNamespacePatternInvalid) || + errors.Is(err, service.ErrKubernetesServiceAccountPatternRequired) || + errors.Is(err, service.ErrKubernetesServiceAccountPatternInvalid) || + errors.Is(err, service.ErrKubernetesSecretSelectorRequired) || + errors.Is(err, service.ErrKubernetesSecretSelectorInvalid) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, gorm.ErrDuplicatedKey) { + c.JSON(http.StatusConflict, gin.H{"error": "kubernetes secret rule name already exists"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +} + +func handleKubernetesClusterError(c *gin.Context, err error) { + if errors.Is(err, service.ErrKubernetesClusterNameRequired) || + errors.Is(err, service.ErrKubernetesClusterNameInvalid) || + errors.Is(err, service.ErrKubernetesClusterIssuerRequired) || + errors.Is(err, service.ErrKubernetesClusterIssuerInvalid) || + errors.Is(err, service.ErrKubernetesClusterAudienceInvalid) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, gorm.ErrDuplicatedKey) { + c.JSON(http.StatusConflict, gin.H{"error": "kubernetes cluster name or issuer already exists"}) + return + } + if errors.Is(err, service.ErrKubernetesClusterVerificationFailed) { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +} + +func modelKubernetesCluster(req kubernetesClusterRequest, createdBy string, updatedBy string) model.KubernetesCluster { + return model.KubernetesCluster{ + Name: req.Name, + Issuer: req.Issuer, + Audience: req.Audience, + Enabled: req.Enabled, + CreatedByEntityID: createdBy, + UpdatedByEntityID: updatedBy, + } +} + +func modelKubernetesSecretRule(req kubernetesSecretRuleRequest, createdBy string, updatedBy string) model.KubernetesSecretRule { + return model.KubernetesSecretRule{ + Name: req.Name, + ClusterIDs: req.ClusterIDs, + NamespacePatterns: req.NamespacePatterns, + ServiceAccountPatterns: req.ServiceAccountPatterns, + SecretSelectors: req.SecretSelectors, + Enabled: req.Enabled, + CreatedByEntityID: createdBy, + UpdatedByEntityID: updatedBy, + } +} diff --git a/vault/database/db.go b/vault/database/db.go index 260871a..1be2a8f 100644 --- a/vault/database/db.go +++ b/vault/database/db.go @@ -39,6 +39,8 @@ func Init() { &model.Application{}, &model.AppSecret{}, &model.GitHubActionsRule{}, + &model.KubernetesCluster{}, + &model.KubernetesSecretRule{}, ); err != nil { logger.SugarLogger.Fatalf("failed to run database migrations: %v", err) return diff --git a/vault/model/kubernetes_cluster.go b/vault/model/kubernetes_cluster.go new file mode 100644 index 0000000..ec6935d --- /dev/null +++ b/vault/model/kubernetes_cluster.go @@ -0,0 +1,19 @@ +package model + +import "time" + +type KubernetesCluster struct { + ID string `json:"id" gorm:"primaryKey"` + Name string `json:"name" gorm:"uniqueIndex"` + Issuer string `json:"issuer" gorm:"uniqueIndex"` + Audience string `json:"audience"` + Enabled bool `json:"enabled"` + CreatedByEntityID string `json:"created_by_entity_id" gorm:"index"` + UpdatedByEntityID string `json:"updated_by_entity_id" gorm:"index"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` +} + +func (KubernetesCluster) TableName() string { + return "vault_kubernetes_cluster" +} diff --git a/vault/model/kubernetes_secret_rule.go b/vault/model/kubernetes_secret_rule.go new file mode 100644 index 0000000..f12ade3 --- /dev/null +++ b/vault/model/kubernetes_secret_rule.go @@ -0,0 +1,21 @@ +package model + +import "time" + +type KubernetesSecretRule struct { + ID string `json:"id" gorm:"primaryKey"` + Name string `json:"name" gorm:"uniqueIndex"` + ClusterIDs []string `json:"cluster_ids" gorm:"type:jsonb;serializer:json"` + NamespacePatterns []string `json:"namespace_patterns" gorm:"type:jsonb;serializer:json"` + ServiceAccountPatterns []string `json:"service_account_patterns" gorm:"type:jsonb;serializer:json"` + SecretSelectors []string `json:"secret_selectors" gorm:"type:jsonb;serializer:json"` + Enabled bool `json:"enabled"` + CreatedByEntityID string `json:"created_by_entity_id" gorm:"index"` + UpdatedByEntityID string `json:"updated_by_entity_id" gorm:"index"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` +} + +func (KubernetesSecretRule) TableName() string { + return "vault_kubernetes_secret_rule" +} diff --git a/vault/pkg/kubernetes/oidc.go b/vault/pkg/kubernetes/oidc.go new file mode 100644 index 0000000..70b8d1f --- /dev/null +++ b/vault/pkg/kubernetes/oidc.go @@ -0,0 +1,430 @@ +package kubernetes + +import ( + "context" + "crypto" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "strings" + "sync" + "time" +) + +const DefaultAudience = "gaucho-racing-vault" + +var ErrInvalidToken = errors.New("invalid kubernetes service account token") +var ErrNotConfigured = errors.New("kubernetes oidc issuer is not configured") + +type Claims struct { + Issuer string + Subject string + Audience []string + Namespace string + ServiceAccountName string + ServiceAccountUID string + ExpiresAt time.Time + NotBefore time.Time + IssuedAt time.Time +} + +type Verifier struct { + issuer string + audience string + client *http.Client + now func() time.Time + + mu sync.Mutex + jwksURI string + keys map[string]*rsa.PublicKey + keysExpiresAt time.Time +} + +type tokenHeader struct { + Algorithm string `json:"alg"` + KeyID string `json:"kid"` +} + +type tokenClaims struct { + Issuer string `json:"iss"` + Subject string `json:"sub"` + Audience audienceClaim `json:"aud"` + Kubernetes kubernetesClaims `json:"kubernetes.io"` + ExpiresAt int64 `json:"exp"` + NotBefore int64 `json:"nbf"` + IssuedAt int64 `json:"iat"` +} + +type kubernetesClaims struct { + Namespace string `json:"namespace"` + ServiceAccount serviceAccountClaim `json:"serviceaccount"` +} + +type serviceAccountClaim struct { + Name string `json:"name"` + UID string `json:"uid"` +} + +type audienceClaim []string + +type discoveryDocument struct { + JWKSURI string `json:"jwks_uri"` +} + +type jwksDocument struct { + Keys []jwk `json:"keys"` +} + +type jwk struct { + KeyID string `json:"kid"` + KeyType string `json:"kty"` + Algorithm string `json:"alg"` + Use string `json:"use"` + N string `json:"n"` + E string `json:"e"` +} + +func NewVerifier(issuer string, audience string, client *http.Client) *Verifier { + if strings.TrimSpace(audience) == "" { + audience = DefaultAudience + } + if client == nil { + client = &http.Client{Timeout: 5 * time.Second} + } + return &Verifier{ + issuer: strings.TrimRight(strings.TrimSpace(issuer), "/"), + audience: strings.TrimSpace(audience), + client: client, + now: time.Now, + } +} + +func UnverifiedIssuer(token string) (string, error) { + token = strings.TrimSpace(token) + if token == "" { + return "", invalidToken("token is required") + } + if len(token) > 64*1024 { + return "", invalidToken("token is too large") + } + parts := strings.Split(token, ".") + if len(parts) != 3 { + return "", invalidToken("token must have three segments") + } + + var claims tokenClaims + if err := decodeSegment(parts[1], &claims); err != nil { + return "", invalidToken("decode claims: %v", err) + } + if strings.TrimSpace(claims.Issuer) == "" { + return "", invalidToken("issuer is required") + } + return strings.TrimRight(strings.TrimSpace(claims.Issuer), "/"), nil +} + +func (v *Verifier) Verify(ctx context.Context, token string) (Claims, error) { + if v.issuer == "" { + return Claims{}, ErrNotConfigured + } + token = strings.TrimSpace(token) + if token == "" { + return Claims{}, invalidToken("token is required") + } + if len(token) > 64*1024 { + return Claims{}, invalidToken("token is too large") + } + parts := strings.Split(token, ".") + if len(parts) != 3 { + return Claims{}, invalidToken("token must have three segments") + } + + var header tokenHeader + if err := decodeSegment(parts[0], &header); err != nil { + return Claims{}, invalidToken("decode header: %v", err) + } + if header.Algorithm != "RS256" { + return Claims{}, invalidToken("unsupported signing algorithm") + } + if header.KeyID == "" { + return Claims{}, invalidToken("missing key id") + } + + key, err := v.publicKey(ctx, header.KeyID) + if err != nil { + return Claims{}, err + } + + signingInput := parts[0] + "." + parts[1] + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return Claims{}, invalidToken("decode signature: %v", err) + } + digest := sha256.Sum256([]byte(signingInput)) + if err := rsa.VerifyPKCS1v15(key, crypto.SHA256, digest[:], signature); err != nil { + return Claims{}, invalidToken("signature verification failed") + } + + var rawClaims tokenClaims + if err := decodeSegment(parts[1], &rawClaims); err != nil { + return Claims{}, invalidToken("decode claims: %v", err) + } + if err := v.validateClaims(rawClaims); err != nil { + return Claims{}, err + } + + namespace := rawClaims.Kubernetes.Namespace + serviceAccountName := rawClaims.Kubernetes.ServiceAccount.Name + if namespace == "" || serviceAccountName == "" { + namespace, serviceAccountName = serviceAccountFromSubject(rawClaims.Subject) + } + + return Claims{ + Issuer: rawClaims.Issuer, + Subject: rawClaims.Subject, + Audience: []string(rawClaims.Audience), + Namespace: strings.ToLower(namespace), + ServiceAccountName: strings.ToLower(serviceAccountName), + ServiceAccountUID: rawClaims.Kubernetes.ServiceAccount.UID, + ExpiresAt: unixTime(rawClaims.ExpiresAt), + NotBefore: unixTime(rawClaims.NotBefore), + IssuedAt: unixTime(rawClaims.IssuedAt), + }, nil +} + +func (v *Verifier) Check(ctx context.Context) error { + if v.issuer == "" { + return ErrNotConfigured + } + return v.refreshKeys(ctx) +} + +func (v *Verifier) publicKey(ctx context.Context, keyID string) (*rsa.PublicKey, error) { + v.mu.Lock() + if v.keysExpiresAt.After(v.now()) { + key := v.keys[keyID] + v.mu.Unlock() + if key != nil { + return key, nil + } + } else { + v.mu.Unlock() + } + + if err := v.refreshKeys(ctx); err != nil { + return nil, err + } + + v.mu.Lock() + defer v.mu.Unlock() + key := v.keys[keyID] + if key == nil { + return nil, invalidToken("signing key is not trusted") + } + return key, nil +} + +func (v *Verifier) refreshKeys(ctx context.Context) error { + jwksURI, err := v.discoveryJWKSURI(ctx) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, jwksURI, nil) + if err != nil { + return err + } + resp, err := v.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("kubernetes oidc jwks returned status %d", resp.StatusCode) + } + + var document jwksDocument + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&document); err != nil { + return err + } + + keys := make(map[string]*rsa.PublicKey, len(document.Keys)) + for _, rawKey := range document.Keys { + key, err := rawKey.publicKey() + if err != nil || key == nil { + continue + } + keys[rawKey.KeyID] = key + } + if len(keys) == 0 { + return errors.New("kubernetes oidc jwks did not include usable rsa keys") + } + + v.mu.Lock() + v.keys = keys + v.keysExpiresAt = v.now().Add(5 * time.Minute) + v.mu.Unlock() + return nil +} + +func (v *Verifier) discoveryJWKSURI(ctx context.Context) (string, error) { + v.mu.Lock() + jwksURI := v.jwksURI + v.mu.Unlock() + if jwksURI != "" { + return jwksURI, nil + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, v.issuer+"/.well-known/openid-configuration", nil) + if err != nil { + return "", err + } + resp, err := v.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("kubernetes oidc discovery returned status %d", resp.StatusCode) + } + + var document discoveryDocument + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&document); err != nil { + return "", err + } + if document.JWKSURI == "" { + return "", errors.New("kubernetes oidc discovery did not include jwks_uri") + } + + v.mu.Lock() + v.jwksURI = document.JWKSURI + v.mu.Unlock() + return document.JWKSURI, nil +} + +func (v *Verifier) validateClaims(claims tokenClaims) error { + now := v.now() + if claims.Issuer != v.issuer { + return invalidToken("issuer is not trusted") + } + if claims.Subject == "" { + return invalidToken("subject is required") + } + if !claims.Audience.contains(v.audience) { + return invalidToken("audience is not trusted") + } + if claims.ExpiresAt == 0 { + return invalidToken("expiration is required") + } + if now.Add(-1*time.Minute).Unix() > claims.ExpiresAt { + return invalidToken("token is expired") + } + if claims.NotBefore != 0 && now.Add(time.Minute).Unix() < claims.NotBefore { + return invalidToken("token is not valid yet") + } + if claims.IssuedAt != 0 && now.Add(time.Minute).Unix() < claims.IssuedAt { + return invalidToken("token issued-at is in the future") + } + + namespace := claims.Kubernetes.Namespace + serviceAccountName := claims.Kubernetes.ServiceAccount.Name + if namespace == "" || serviceAccountName == "" { + namespace, serviceAccountName = serviceAccountFromSubject(claims.Subject) + } + if namespace == "" { + return invalidToken("namespace claim is required") + } + if serviceAccountName == "" { + return invalidToken("service account claim is required") + } + return nil +} + +func serviceAccountFromSubject(subject string) (string, string) { + parts := strings.Split(subject, ":") + if len(parts) != 4 || parts[0] != "system" || parts[1] != "serviceaccount" { + return "", "" + } + return parts[2], parts[3] +} + +func (a *audienceClaim) UnmarshalJSON(data []byte) error { + var single string + if err := json.Unmarshal(data, &single); err == nil { + *a = []string{single} + return nil + } + var many []string + if err := json.Unmarshal(data, &many); err != nil { + return err + } + *a = many + return nil +} + +func (a audienceClaim) contains(expected string) bool { + for _, audience := range a { + if audience == expected { + return true + } + } + return false +} + +func (k jwk) publicKey() (*rsa.PublicKey, error) { + if k.KeyID == "" || k.KeyType != "RSA" || k.N == "" || k.E == "" { + return nil, nil + } + if k.Algorithm != "" && k.Algorithm != "RS256" { + return nil, nil + } + if k.Use != "" && k.Use != "sig" { + return nil, nil + } + + modulusBytes, err := base64.RawURLEncoding.DecodeString(k.N) + if err != nil { + return nil, err + } + exponentBytes, err := base64.RawURLEncoding.DecodeString(k.E) + if err != nil { + return nil, err + } + + exponent := 0 + for _, value := range exponentBytes { + exponent = exponent<<8 + int(value) + } + if exponent == 0 { + return nil, errors.New("rsa exponent is empty") + } + + return &rsa.PublicKey{ + N: new(big.Int).SetBytes(modulusBytes), + E: exponent, + }, nil +} + +func decodeSegment(segment string, destination any) error { + data, err := base64.RawURLEncoding.DecodeString(segment) + if err != nil { + return err + } + return json.Unmarshal(data, destination) +} + +func invalidToken(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrInvalidToken, fmt.Sprintf(format, args...)) +} + +func unixTime(value int64) time.Time { + if value == 0 { + return time.Time{} + } + return time.Unix(value, 0) +} diff --git a/vault/service/kubernetes_cluster.go b/vault/service/kubernetes_cluster.go new file mode 100644 index 0000000..2ddd3b4 --- /dev/null +++ b/vault/service/kubernetes_cluster.go @@ -0,0 +1,188 @@ +package service + +import ( + "context" + "errors" + "fmt" + "net/url" + "regexp" + "strings" + "sync" + + "github.com/gaucho-racing/ulid-go" + "github.com/gaucho-racing/vault/vault/database" + "github.com/gaucho-racing/vault/vault/model" + "github.com/gaucho-racing/vault/vault/pkg/kubernetes" + "gorm.io/gorm" +) + +var ErrKubernetesClusterNameRequired = errors.New("kubernetes cluster name is required") +var ErrKubernetesClusterNameInvalid = errors.New("kubernetes cluster name must contain only lowercase letters, numbers, hyphens, and underscores") +var ErrKubernetesClusterIssuerRequired = errors.New("kubernetes cluster issuer is required") +var ErrKubernetesClusterIssuerInvalid = errors.New("kubernetes cluster issuer must be an http or https URL") +var ErrKubernetesClusterAudienceInvalid = errors.New("kubernetes cluster audience cannot contain whitespace") +var ErrKubernetesClusterNotTrusted = errors.New("kubernetes cluster is not trusted") +var ErrKubernetesClusterInUse = errors.New("kubernetes cluster is used by an access rule") +var ErrKubernetesClusterVerificationFailed = errors.New("kubernetes cluster verification failed") + +var kubernetesAudiencePattern = regexp.MustCompile(`^\S+$`) + +type KubernetesVerifiedIdentity struct { + Cluster model.KubernetesCluster + Claims kubernetes.Claims +} + +var kubernetesVerifierStore = struct { + sync.Mutex + items map[string]*kubernetes.Verifier +}{items: map[string]*kubernetes.Verifier{}} + +func GetAllKubernetesClusters() ([]model.KubernetesCluster, error) { + clusters := []model.KubernetesCluster{} + if err := database.DB.Order("name ASC").Find(&clusters).Error; err != nil { + return []model.KubernetesCluster{}, err + } + return clusters, nil +} + +func GetKubernetesClusterByID(id string) (model.KubernetesCluster, error) { + var cluster model.KubernetesCluster + if err := database.DB.Where("id = ?", id).First(&cluster).Error; err != nil { + return model.KubernetesCluster{}, err + } + return cluster, nil +} + +func CreateKubernetesCluster(ctx context.Context, cluster model.KubernetesCluster) (model.KubernetesCluster, error) { + if cluster.ID == "" { + cluster.ID = ulid.Make().Prefixed("k8scluster") + } + if err := VerifyKubernetesClusterConfig(ctx, &cluster); err != nil { + return model.KubernetesCluster{}, err + } + if err := database.DB.Create(&cluster).Error; err != nil { + return model.KubernetesCluster{}, err + } + return cluster, nil +} + +func UpdateKubernetesCluster(ctx context.Context, cluster model.KubernetesCluster) (model.KubernetesCluster, error) { + if err := VerifyKubernetesClusterConfig(ctx, &cluster); err != nil { + return model.KubernetesCluster{}, err + } + if err := database.DB.Save(&cluster).Error; err != nil { + return model.KubernetesCluster{}, err + } + return cluster, nil +} + +func DeleteKubernetesCluster(id string) error { + rules := []model.KubernetesSecretRule{} + if err := database.DB.Find(&rules).Error; err != nil { + return err + } + for _, rule := range rules { + if stringSliceContains(rule.ClusterIDs, id) { + return ErrKubernetesClusterInUse + } + } + + result := database.DB.Where("id = ?", id).Delete(&model.KubernetesCluster{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +func VerifyKubernetesToken(ctx context.Context, token string) (KubernetesVerifiedIdentity, error) { + issuer, err := kubernetes.UnverifiedIssuer(token) + if err != nil { + return KubernetesVerifiedIdentity{}, err + } + + cluster, err := getEnabledKubernetesClusterByIssuer(issuer) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return KubernetesVerifiedIdentity{}, ErrKubernetesClusterNotTrusted + } + return KubernetesVerifiedIdentity{}, err + } + + claims, err := kubernetesVerifierForCluster(cluster).Verify(ctx, token) + if err != nil { + return KubernetesVerifiedIdentity{}, err + } + return KubernetesVerifiedIdentity{Cluster: cluster, Claims: claims}, nil +} + +func VerifyKubernetesClusterConfig(ctx context.Context, cluster *model.KubernetesCluster) error { + if err := normalizeKubernetesCluster(cluster); err != nil { + return err + } + if err := kubernetes.NewVerifier(cluster.Issuer, cluster.Audience, nil).Check(ctx); err != nil { + return fmt.Errorf("%w: %v", ErrKubernetesClusterVerificationFailed, err) + } + return nil +} + +func normalizeKubernetesCluster(cluster *model.KubernetesCluster) error { + cluster.Name = strings.ToLower(strings.TrimSpace(cluster.Name)) + cluster.Issuer = strings.TrimRight(strings.TrimSpace(cluster.Issuer), "/") + cluster.Audience = strings.TrimSpace(cluster.Audience) + if cluster.Audience == "" { + cluster.Audience = kubernetes.DefaultAudience + } + + if cluster.Name == "" { + return ErrKubernetesClusterNameRequired + } + if !appSecretIdentifierPattern.MatchString(cluster.Name) { + return ErrKubernetesClusterNameInvalid + } + if cluster.Issuer == "" { + return ErrKubernetesClusterIssuerRequired + } + issuerURL, err := url.Parse(cluster.Issuer) + if err != nil || issuerURL.Host == "" || (issuerURL.Scheme != "https" && issuerURL.Scheme != "http") { + return ErrKubernetesClusterIssuerInvalid + } + if !kubernetesAudiencePattern.MatchString(cluster.Audience) { + return ErrKubernetesClusterAudienceInvalid + } + return nil +} + +func getEnabledKubernetesClusterByIssuer(issuer string) (model.KubernetesCluster, error) { + var cluster model.KubernetesCluster + if err := database.DB. + Where("enabled = ? AND issuer = ?", true, strings.TrimRight(strings.TrimSpace(issuer), "/")). + First(&cluster).Error; err != nil { + return model.KubernetesCluster{}, err + } + return cluster, nil +} + +func kubernetesVerifierForCluster(cluster model.KubernetesCluster) *kubernetes.Verifier { + key := strings.Join([]string{cluster.ID, cluster.Issuer, cluster.Audience}, "\x00") + + kubernetesVerifierStore.Lock() + defer kubernetesVerifierStore.Unlock() + verifier := kubernetesVerifierStore.items[key] + if verifier == nil { + verifier = kubernetes.NewVerifier(cluster.Issuer, cluster.Audience, nil) + kubernetesVerifierStore.items[key] = verifier + } + return verifier +} + +func stringSliceContains(values []string, expected string) bool { + for _, value := range values { + if value == expected { + return true + } + } + return false +} diff --git a/vault/service/kubernetes_secret.go b/vault/service/kubernetes_secret.go new file mode 100644 index 0000000..9f8243f --- /dev/null +++ b/vault/service/kubernetes_secret.go @@ -0,0 +1,234 @@ +package service + +import ( + "errors" + "fmt" + "regexp" + "strings" + + "github.com/gaucho-racing/ulid-go" + "github.com/gaucho-racing/vault/vault/database" + "github.com/gaucho-racing/vault/vault/model" + "github.com/gaucho-racing/vault/vault/pkg/kubernetes" + "gorm.io/gorm" +) + +var ErrKubernetesSecretRuleNameRequired = errors.New("kubernetes secret rule name is required") +var ErrKubernetesSecretRuleNameInvalid = errors.New("kubernetes secret rule name must contain only lowercase letters, numbers, hyphens, and underscores") +var ErrKubernetesClusterRequired = errors.New("kubernetes cluster is required") +var ErrKubernetesClusterInvalid = errors.New("kubernetes cluster is invalid") +var ErrKubernetesNamespacePatternRequired = errors.New("kubernetes namespace pattern is required") +var ErrKubernetesNamespacePatternInvalid = errors.New("kubernetes namespace pattern must contain only lowercase letters, numbers, hyphens, and wildcards") +var ErrKubernetesServiceAccountPatternRequired = errors.New("kubernetes service account pattern is required") +var ErrKubernetesServiceAccountPatternInvalid = errors.New("kubernetes service account pattern must contain only lowercase letters, numbers, hyphens, and wildcards") +var ErrKubernetesSecretSelectorRequired = errors.New("kubernetes secret selector is required") +var ErrKubernetesSecretSelectorInvalid = errors.New("kubernetes secret selector must use application.secret format and may include wildcards") +var ErrKubernetesRequestedSecretSelectorInvalid = errors.New("kubernetes requested secret selector must use explicit application.secret format") +var ErrKubernetesSecretSelectorNotAllowed = errors.New("kubernetes secret selector is not allowed") +var ErrKubernetesSecretKeyRequired = errors.New("kubernetes secret key is required") +var ErrKubernetesSecretKeyInvalid = errors.New("kubernetes secret key must contain only letters, numbers, hyphens, underscores, and dots") + +type KubernetesExportRequest struct { + Secrets map[string]string + Claims kubernetes.Claims + Cluster model.KubernetesCluster +} + +var kubernetesNamespacePattern = regexp.MustCompile(`^[a-z0-9*][a-z0-9*-]*$`) +var kubernetesServiceAccountPattern = regexp.MustCompile(`^[a-z0-9*][a-z0-9*-]*$`) +var kubernetesSecretKeyPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) + +func GetAllKubernetesSecretRules() ([]model.KubernetesSecretRule, error) { + rules := []model.KubernetesSecretRule{} + if err := database.DB.Order("name ASC").Find(&rules).Error; err != nil { + return []model.KubernetesSecretRule{}, err + } + return rules, nil +} + +func GetKubernetesSecretRuleByID(id string) (model.KubernetesSecretRule, error) { + var rule model.KubernetesSecretRule + if err := database.DB.Where("id = ?", id).First(&rule).Error; err != nil { + return model.KubernetesSecretRule{}, err + } + return rule, nil +} + +func CreateKubernetesSecretRule(rule model.KubernetesSecretRule) (model.KubernetesSecretRule, error) { + if rule.ID == "" { + rule.ID = ulid.Make().Prefixed("k8srule") + } + if err := normalizeKubernetesSecretRule(&rule); err != nil { + return model.KubernetesSecretRule{}, err + } + if err := database.DB.Create(&rule).Error; err != nil { + return model.KubernetesSecretRule{}, err + } + return rule, nil +} + +func UpdateKubernetesSecretRule(rule model.KubernetesSecretRule) (model.KubernetesSecretRule, error) { + if err := normalizeKubernetesSecretRule(&rule); err != nil { + return model.KubernetesSecretRule{}, err + } + if err := database.DB.Save(&rule).Error; err != nil { + return model.KubernetesSecretRule{}, err + } + return rule, nil +} + +func DeleteKubernetesSecretRule(id string) error { + result := database.DB.Where("id = ?", id).Delete(&model.KubernetesSecretRule{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +func BuildKubernetesSecretData(req KubernetesExportRequest) (map[string]string, error) { + normalizedSecrets, err := normalizeRequestedKubernetesSecrets(req.Secrets) + if err != nil { + return map[string]string{}, err + } + + rules := []model.KubernetesSecretRule{} + if err := database.DB.Where("enabled = ?", true).Find(&rules).Error; err != nil { + return map[string]string{}, err + } + + matchingRules := make([]model.KubernetesSecretRule, 0, len(rules)) + for _, rule := range rules { + if kubernetesSecretRuleMatchesClaims(rule, req.Cluster, req.Claims) { + matchingRules = append(matchingRules, rule) + } + } + + data := make(map[string]string, len(normalizedSecrets)) + for key, selector := range normalizedSecrets { + if !kubernetesSelectorAllowed(matchingRules, selector) { + return map[string]string{}, fmt.Errorf("%w: %s", ErrKubernetesSecretSelectorNotAllowed, selector) + } + secret, err := getAppSecretBySelector(selector) + if err != nil { + return map[string]string{}, err + } + value, err := RevealAppSecret(secret) + if err != nil { + return map[string]string{}, fmt.Errorf("reveal app secret %s: %w", selector, err) + } + data[key] = value + } + return data, nil +} + +func normalizeKubernetesSecretRule(rule *model.KubernetesSecretRule) error { + rule.Name = strings.ToLower(strings.TrimSpace(rule.Name)) + rule.ClusterIDs = normalizeStringSlice(rule.ClusterIDs) + rule.NamespacePatterns = normalizeLowerStringSlice(rule.NamespacePatterns) + rule.ServiceAccountPatterns = normalizeLowerStringSlice(rule.ServiceAccountPatterns) + rule.SecretSelectors = normalizeGitHubActionsSecretSelectors(rule.SecretSelectors) + if rule.Name == "" { + return ErrKubernetesSecretRuleNameRequired + } + if !appSecretIdentifierPattern.MatchString(rule.Name) { + return ErrKubernetesSecretRuleNameInvalid + } + if len(rule.ClusterIDs) == 0 { + return ErrKubernetesClusterRequired + } + if len(rule.NamespacePatterns) == 0 { + return ErrKubernetesNamespacePatternRequired + } + if len(rule.ServiceAccountPatterns) == 0 { + return ErrKubernetesServiceAccountPatternRequired + } + if len(rule.SecretSelectors) == 0 { + return ErrKubernetesSecretSelectorRequired + } + for _, clusterID := range rule.ClusterIDs { + if _, err := GetKubernetesClusterByID(clusterID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrKubernetesClusterInvalid + } + return err + } + } + for _, pattern := range rule.NamespacePatterns { + if !kubernetesNamespacePattern.MatchString(pattern) { + return ErrKubernetesNamespacePatternInvalid + } + } + for _, pattern := range rule.ServiceAccountPatterns { + if !kubernetesServiceAccountPattern.MatchString(pattern) { + return ErrKubernetesServiceAccountPatternInvalid + } + } + for _, selector := range rule.SecretSelectors { + if !githubSecretSelectorPattern.MatchString(selector) { + return ErrKubernetesSecretSelectorInvalid + } + } + return nil +} + +func normalizeRequestedKubernetesSecrets(secrets map[string]string) (map[string]string, error) { + if len(secrets) == 0 { + return map[string]string{}, ErrKubernetesSecretSelectorRequired + } + normalized := make(map[string]string, len(secrets)) + for key, selector := range secrets { + trimmedKey := strings.TrimSpace(key) + if trimmedKey == "" { + return map[string]string{}, ErrKubernetesSecretKeyRequired + } + if !kubernetesSecretKeyPattern.MatchString(trimmedKey) { + return map[string]string{}, ErrKubernetesSecretKeyInvalid + } + + normalizedSelector := strings.ToLower(strings.TrimSpace(selector)) + if normalizedSelector == "" { + return map[string]string{}, ErrKubernetesSecretSelectorRequired + } + if strings.Contains(normalizedSelector, "*") || !githubSecretSelectorPattern.MatchString(normalizedSelector) { + return map[string]string{}, ErrKubernetesRequestedSecretSelectorInvalid + } + normalized[trimmedKey] = normalizedSelector + } + return normalized, nil +} + +func kubernetesSecretRuleMatchesClaims(rule model.KubernetesSecretRule, cluster model.KubernetesCluster, claims kubernetes.Claims) bool { + if !stringSliceContains(rule.ClusterIDs, cluster.ID) { + return false + } + if !anyWildcardMatches(rule.NamespacePatterns, strings.ToLower(claims.Namespace)) { + return false + } + return anyWildcardMatches(rule.ServiceAccountPatterns, strings.ToLower(claims.ServiceAccountName)) +} + +func kubernetesSelectorAllowed(rules []model.KubernetesSecretRule, selector string) bool { + for _, rule := range rules { + if anyWildcardMatches(rule.SecretSelectors, selector) { + return true + } + } + return false +} + +func normalizeLowerStringSlice(values []string) []string { + normalized := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + lower := strings.ToLower(strings.TrimSpace(value)) + if lower == "" || seen[lower] { + continue + } + seen[lower] = true + normalized = append(normalized, lower) + } + return normalized +} diff --git a/web/src/lib/vault.ts b/web/src/lib/vault.ts index 63c8747..35bd024 100644 --- a/web/src/lib/vault.ts +++ b/web/src/lib/vault.ts @@ -147,6 +147,48 @@ export type GitHubActionsRuleInput = { enabled: boolean } +export type KubernetesCluster = { + id: string + name: string + issuer: string + audience: string + enabled: boolean + created_by_entity_id: string + updated_by_entity_id: string + created_at: string + updated_at: string +} + +export type KubernetesClusterInput = { + name: string + issuer: string + audience: string + enabled: boolean +} + +export type KubernetesSecretRule = { + id: string + name: string + cluster_ids: string[] + namespace_patterns: string[] + service_account_patterns: string[] + secret_selectors: string[] + enabled: boolean + created_by_entity_id: string + updated_by_entity_id: string + created_at: string + updated_at: string +} + +export type KubernetesSecretRuleInput = { + name: string + cluster_ids: string[] + namespace_patterns: string[] + service_account_patterns: string[] + secret_selectors: string[] + enabled: boolean +} + export type SentinelGroup = { id: string name: string @@ -196,6 +238,13 @@ type GitHubActionsRuleResponseFields = { secret_selectors?: string[] | null } +type KubernetesSecretRuleResponseFields = { + cluster_ids?: string[] | null + namespace_patterns?: string[] | null + service_account_patterns?: string[] | null + secret_selectors?: string[] | null +} + function normalizeStringArray(value: string[] | null | undefined) { return Array.isArray(value) ? value : [] } @@ -216,6 +265,16 @@ function normalizeGitHubActionsRule(r } } +function normalizeKubernetesSecretRule(rule: T) { + return { + ...rule, + cluster_ids: normalizeStringArray(rule.cluster_ids), + namespace_patterns: normalizeStringArray(rule.namespace_patterns), + service_account_patterns: normalizeStringArray(rule.service_account_patterns), + secret_selectors: normalizeStringArray(rule.secret_selectors), + } +} + export async function listAccounts() { const response = await api.get("/accounts") return response.data @@ -264,6 +323,49 @@ export async function deleteGitHubActionsRule(id: string) { await api.delete(`/integrations/github/actions/rules/${id}`) } +export async function listKubernetesClusters() { + const response = await api.get("/integrations/kubernetes/clusters") + return response.data +} + +export async function createKubernetesCluster(input: KubernetesClusterInput) { + const response = await api.post("/integrations/kubernetes/clusters", input) + return response.data +} + +export async function updateKubernetesCluster(id: string, input: KubernetesClusterInput) { + const response = await api.put(`/integrations/kubernetes/clusters/${id}`, input) + return response.data +} + +export async function verifyKubernetesCluster(input: KubernetesClusterInput) { + const response = await api.post<{ valid: boolean }>("/integrations/kubernetes/clusters/verify", input) + return response.data +} + +export async function deleteKubernetesCluster(id: string) { + await api.delete(`/integrations/kubernetes/clusters/${id}`) +} + +export async function listKubernetesSecretRules() { + const response = await api.get("/integrations/kubernetes/rules") + return response.data.map(normalizeKubernetesSecretRule) +} + +export async function createKubernetesSecretRule(input: KubernetesSecretRuleInput) { + const response = await api.post("/integrations/kubernetes/rules", input) + return normalizeKubernetesSecretRule(response.data) +} + +export async function updateKubernetesSecretRule(id: string, input: KubernetesSecretRuleInput) { + const response = await api.put(`/integrations/kubernetes/rules/${id}`, input) + return normalizeKubernetesSecretRule(response.data) +} + +export async function deleteKubernetesSecretRule(id: string) { + await api.delete(`/integrations/kubernetes/rules/${id}`) +} + export async function createAppSecret(applicationID: string, input: AppSecretInput) { const response = await api.post(`/app-secrets/${applicationID}/secrets`, input) return response.data diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 8452649..2a076e7 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -1,5 +1,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { Edit2, Monitor, Moon, Plus, Sun, Trash2, type LucideIcon } from "lucide-react" +import { + Edit2, + Monitor, + Moon, + Plus, + Server, + ShieldCheck, + Sun, + Trash2, + type LucideIcon, +} from "lucide-react" import { useState, type ReactNode } from "react" import { toast } from "sonner" @@ -23,11 +33,24 @@ import { Textarea } from "@/components/ui/textarea" import { useTheme, type Theme } from "@/lib/theme" import { createGitHubActionsRule, + createKubernetesCluster, + createKubernetesSecretRule, + deleteKubernetesCluster, deleteGitHubActionsRule, + deleteKubernetesSecretRule, listGitHubActionsRules, + listKubernetesClusters, + listKubernetesSecretRules, + updateKubernetesCluster, updateGitHubActionsRule, + updateKubernetesSecretRule, + verifyKubernetesCluster, type GitHubActionsRule, type GitHubActionsRuleInput, + type KubernetesCluster, + type KubernetesClusterInput, + type KubernetesSecretRule, + type KubernetesSecretRuleInput, } from "@/lib/vault" const themeOptions: Array<{ value: Theme; label: string; Icon: LucideIcon }> = [ @@ -36,6 +59,8 @@ const themeOptions: Array<{ value: Theme; label: string; Icon: LucideIcon }> = [ { value: "dark", label: "Dark", Icon: Moon }, ] +const defaultKubernetesAudience = "gaucho-racing-vault" + function errorMessage(error: unknown, fallback: string) { return (error as { response?: { data?: { error?: string } } })?.response?.data?.error ?? fallback } @@ -194,11 +219,319 @@ function GitHubActionsRuleDialog({ ) } +function KubernetesClusterDialog({ + cluster, + trigger, + isPending, + isVerifying, + onSubmit, + onVerify, +}: { + cluster?: KubernetesCluster + trigger: ReactNode + isPending: boolean + isVerifying: boolean + onSubmit: (input: KubernetesClusterInput) => Promise + onVerify: (input: KubernetesClusterInput) => Promise +}) { + const [open, setOpen] = useState(false) + const [name, setName] = useState(cluster?.name ?? "") + const [issuer, setIssuer] = useState(cluster?.issuer ?? "") + const [audience, setAudience] = useState(cluster?.audience ?? defaultKubernetesAudience) + const [enabled, setEnabled] = useState(cluster?.enabled ?? true) + const isEditing = !!cluster + + function handleOpenChange(nextOpen: boolean) { + setOpen(nextOpen) + if (nextOpen) return + setName(cluster?.name ?? "") + setIssuer(cluster?.issuer ?? "") + setAudience(cluster?.audience ?? defaultKubernetesAudience) + setEnabled(cluster?.enabled ?? true) + } + + function clusterInput(): KubernetesClusterInput { + return { + name: normalizeIdentifier(name), + issuer: issuer.trim(), + audience: audience.trim() || defaultKubernetesAudience, + enabled, + } + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + await onSubmit(clusterInput()) + handleOpenChange(false) + } + + async function handleVerify() { + await onVerify(clusterInput()) + } + + return ( + + {trigger} + + + {isEditing ? "Edit Kubernetes cluster" : "New Kubernetes cluster"} + +
+
+
+ + setName(event.target.value.toLowerCase())} + placeholder="prod" + required + /> +
+ +
+ +
+ + setIssuer(event.target.value)} + placeholder="https://oidc.eks.us-west-2.amazonaws.com/id/..." + required + /> +
+ +
+ + setAudience(event.target.value)} + placeholder={defaultKubernetesAudience} + required + /> +
+ +
+ + + +
+
+
+
+ ) +} + +function KubernetesSecretRuleDialog({ + rule, + clusters, + trigger, + isPending, + onSubmit, +}: { + rule?: KubernetesSecretRule + clusters: KubernetesCluster[] + trigger: ReactNode + isPending: boolean + onSubmit: (input: KubernetesSecretRuleInput) => Promise +}) { + const [open, setOpen] = useState(false) + const [name, setName] = useState(rule?.name ?? "") + const [clusterIds, setClusterIds] = useState(rule?.cluster_ids ?? []) + const [namespacePatterns, setNamespacePatterns] = useState(() => listText(rule?.namespace_patterns)) + const [serviceAccountPatterns, setServiceAccountPatterns] = useState(() => + listText(rule?.service_account_patterns), + ) + const [secretSelectors, setSecretSelectors] = useState(() => listText(rule?.secret_selectors)) + const [enabled, setEnabled] = useState(rule?.enabled ?? true) + const isEditing = !!rule + + function handleOpenChange(nextOpen: boolean) { + setOpen(nextOpen) + if (nextOpen) return + setName(rule?.name ?? "") + setClusterIds(rule?.cluster_ids ?? []) + setNamespacePatterns(listText(rule?.namespace_patterns)) + setServiceAccountPatterns(listText(rule?.service_account_patterns)) + setSecretSelectors(listText(rule?.secret_selectors)) + setEnabled(rule?.enabled ?? true) + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + await onSubmit({ + name: normalizeIdentifier(name), + cluster_ids: clusterIds, + namespace_patterns: normalizeList(namespacePatterns, true), + service_account_patterns: normalizeList(serviceAccountPatterns, true), + secret_selectors: normalizeList(secretSelectors, true), + enabled, + }) + handleOpenChange(false) + } + + function handleClusterChange(clusterID: string, checked: boolean) { + setClusterIds((current) => + checked ? Array.from(new Set([...current, clusterID])) : current.filter((id) => id !== clusterID), + ) + } + + return ( + + {trigger} + + + {isEditing ? "Edit Kubernetes rule" : "New Kubernetes rule"} + +
+
+
+ + setName(event.target.value.toLowerCase())} + placeholder="production-sync" + required + /> +
+ +
+ +
+ +
+ {clusters.map((cluster) => ( + + ))} +
+
+ +
+
+ +