From 97c64757a55b392aa2fd5a36a2445cb3e3d169a5 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sun, 28 Jun 2026 21:43:46 -0700 Subject: [PATCH 1/5] feat: add kubernetes secret sync api --- vault/api/api.go | 13 +- vault/api/kubernetes.go | 189 ++++++++++++ vault/config/config.go | 4 + vault/config/verify.go | 9 + vault/database/db.go | 1 + vault/model/kubernetes_secret_rule.go | 21 ++ vault/pkg/kubernetes/oidc.go | 400 ++++++++++++++++++++++++++ vault/service/kubernetes_secret.go | 233 +++++++++++++++ 8 files changed, 869 insertions(+), 1 deletion(-) create mode 100644 vault/api/kubernetes.go create mode 100644 vault/model/kubernetes_secret_rule.go create mode 100644 vault/pkg/kubernetes/oidc.go create mode 100644 vault/service/kubernetes_secret.go diff --git a/vault/api/api.go b/vault/api/api.go index 9de98cb..cee62da 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,11 @@ 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/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 +122,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 +250,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..0cd9c13 --- /dev/null +++ b/vault/api/kubernetes.go @@ -0,0 +1,189 @@ +package api + +import ( + "errors" + "net/http" + + "github.com/gaucho-racing/vault/vault/config" + "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 kubernetesSecretRuleRequest struct { + Name string `json:"name" binding:"required"` + ClusterPatterns []string `json:"cluster_patterns"` + NamespacePatterns []string `json:"namespace_patterns"` + ServiceAccountPatterns []string `json:"service_account_patterns"` + SecretSelectors []string `json:"secret_selectors"` + Enabled bool `json:"enabled"` +} + +var kubernetesVerifier = kubernetes.NewVerifier(config.KubernetesOIDCIssuer, config.KubernetesOIDCAudience, nil) + +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 + } + + claims, err := kubernetesVerifier.Verify(c.Request.Context(), req.Token) + if err != nil { + if errors.Is(err, kubernetes.ErrNotConfigured) { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, kubernetes.ErrInvalidToken) { + 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: claims, + }) + if err != nil { + handleKubernetesSecretExportError(c, err) + return + } + c.JSON(http.StatusOK, kubernetesAppSecretsResponse{Secrets: secrets}) +} + +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.ClusterPatterns = req.ClusterPatterns + 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.ErrKubernetesClusterPatternRequired) || + errors.Is(err, service.ErrKubernetesClusterPatternInvalid) || + 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 modelKubernetesSecretRule(req kubernetesSecretRuleRequest, createdBy string, updatedBy string) model.KubernetesSecretRule { + return model.KubernetesSecretRule{ + Name: req.Name, + ClusterPatterns: req.ClusterPatterns, + NamespacePatterns: req.NamespacePatterns, + ServiceAccountPatterns: req.ServiceAccountPatterns, + SecretSelectors: req.SecretSelectors, + Enabled: req.Enabled, + CreatedByEntityID: createdBy, + UpdatedByEntityID: updatedBy, + } +} diff --git a/vault/config/config.go b/vault/config/config.go index ad5a101..27ca485 100644 --- a/vault/config/config.go +++ b/vault/config/config.go @@ -27,6 +27,10 @@ var SentinelRedirectURI = os.Getenv("SENTINEL_REDIRECT_URI") var GitHubActionsOIDCIssuer = os.Getenv("GITHUB_ACTIONS_OIDC_ISSUER") var GitHubActionsOIDCAudience = os.Getenv("GITHUB_ACTIONS_OIDC_AUDIENCE") +var KubernetesOIDCIssuer = os.Getenv("KUBERNETES_OIDC_ISSUER") +var KubernetesOIDCAudience = os.Getenv("KUBERNETES_OIDC_AUDIENCE") +var KubernetesClusterID = os.Getenv("KUBERNETES_CLUSTER_ID") + var VaultMasterKey = os.Getenv("VAULT_MASTER_KEY") var VaultMasterKeyID = os.Getenv("VAULT_MASTER_KEY_ID") diff --git a/vault/config/verify.go b/vault/config/verify.go index 65c7806..e63980a 100644 --- a/vault/config/verify.go +++ b/vault/config/verify.go @@ -2,6 +2,7 @@ package config import ( "github.com/gaucho-racing/vault/vault/pkg/githubactions" + "github.com/gaucho-racing/vault/vault/pkg/kubernetes" "github.com/gaucho-racing/vault/vault/pkg/logger" ) @@ -51,4 +52,12 @@ func Verify() { GitHubActionsOIDCAudience = githubactions.DefaultAudience logger.SugarLogger.Infof("GITHUB_ACTIONS_OIDC_AUDIENCE is not set, defaulting to %s", GitHubActionsOIDCAudience) } + if KubernetesOIDCAudience == "" { + KubernetesOIDCAudience = kubernetes.DefaultAudience + logger.SugarLogger.Infof("KUBERNETES_OIDC_AUDIENCE is not set, defaulting to %s", KubernetesOIDCAudience) + } + if KubernetesClusterID == "" { + KubernetesClusterID = "default" + logger.SugarLogger.Infof("KUBERNETES_CLUSTER_ID is not set, defaulting to %s", KubernetesClusterID) + } } diff --git a/vault/database/db.go b/vault/database/db.go index 260871a..43bda32 100644 --- a/vault/database/db.go +++ b/vault/database/db.go @@ -39,6 +39,7 @@ func Init() { &model.Application{}, &model.AppSecret{}, &model.GitHubActionsRule{}, + &model.KubernetesSecretRule{}, ); err != nil { logger.SugarLogger.Fatalf("failed to run database migrations: %v", err) return diff --git a/vault/model/kubernetes_secret_rule.go b/vault/model/kubernetes_secret_rule.go new file mode 100644 index 0000000..986263a --- /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"` + ClusterPatterns []string `json:"cluster_patterns" 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..0e99bf0 --- /dev/null +++ b/vault/pkg/kubernetes/oidc.go @@ -0,0 +1,400 @@ +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 (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) 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_secret.go b/vault/service/kubernetes_secret.go new file mode 100644 index 0000000..17e4141 --- /dev/null +++ b/vault/service/kubernetes_secret.go @@ -0,0 +1,233 @@ +package service + +import ( + "errors" + "fmt" + "regexp" + "strings" + + "github.com/gaucho-racing/ulid-go" + "github.com/gaucho-racing/vault/vault/config" + "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 ErrKubernetesClusterPatternRequired = errors.New("kubernetes cluster pattern is required") +var ErrKubernetesClusterPatternInvalid = errors.New("kubernetes cluster pattern must contain only lowercase letters, numbers, hyphens, underscores, dots, and wildcards") +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 +} + +var kubernetesClusterPattern = regexp.MustCompile(`^[a-z0-9*][a-z0-9_.*-]*$`) +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.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.ClusterPatterns = normalizeLowerStringSlice(rule.ClusterPatterns) + 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.ClusterPatterns) == 0 { + return ErrKubernetesClusterPatternRequired + } + if len(rule.NamespacePatterns) == 0 { + return ErrKubernetesNamespacePatternRequired + } + if len(rule.ServiceAccountPatterns) == 0 { + return ErrKubernetesServiceAccountPatternRequired + } + if len(rule.SecretSelectors) == 0 { + return ErrKubernetesSecretSelectorRequired + } + for _, pattern := range rule.ClusterPatterns { + if !kubernetesClusterPattern.MatchString(pattern) { + return ErrKubernetesClusterPatternInvalid + } + } + 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, claims kubernetes.Claims) bool { + cluster := strings.ToLower(config.KubernetesClusterID) + if !anyWildcardMatches(rule.ClusterPatterns, cluster) { + 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 +} From ad58fe198629e12261e9d71809262603114107a1 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Sun, 28 Jun 2026 22:00:41 -0700 Subject: [PATCH 2/5] feat: add kubernetes rules settings ui --- web/src/lib/vault.ts | 59 ++++++ web/src/pages/SettingsPage.tsx | 319 ++++++++++++++++++++++++++++++++- 2 files changed, 376 insertions(+), 2 deletions(-) diff --git a/web/src/lib/vault.ts b/web/src/lib/vault.ts index 63c8747..ccf94fd 100644 --- a/web/src/lib/vault.ts +++ b/web/src/lib/vault.ts @@ -147,6 +147,29 @@ export type GitHubActionsRuleInput = { enabled: boolean } +export type KubernetesSecretRule = { + id: string + name: string + cluster_patterns: 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_patterns: string[] + namespace_patterns: string[] + service_account_patterns: string[] + secret_selectors: string[] + enabled: boolean +} + export type SentinelGroup = { id: string name: string @@ -196,6 +219,13 @@ type GitHubActionsRuleResponseFields = { secret_selectors?: string[] | null } +type KubernetesSecretRuleResponseFields = { + cluster_patterns?: 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 +246,16 @@ function normalizeGitHubActionsRule(r } } +function normalizeKubernetesSecretRule(rule: T) { + return { + ...rule, + cluster_patterns: normalizeStringArray(rule.cluster_patterns), + 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 +304,25 @@ export async function deleteGitHubActionsRule(id: string) { await api.delete(`/integrations/github/actions/rules/${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..dce8dc5 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -1,5 +1,5 @@ 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, Sun, Trash2, type LucideIcon } from "lucide-react" import { useState, type ReactNode } from "react" import { toast } from "sonner" @@ -23,11 +23,17 @@ import { Textarea } from "@/components/ui/textarea" import { useTheme, type Theme } from "@/lib/theme" import { createGitHubActionsRule, + createKubernetesSecretRule, deleteGitHubActionsRule, + deleteKubernetesSecretRule, listGitHubActionsRules, + listKubernetesSecretRules, updateGitHubActionsRule, + updateKubernetesSecretRule, type GitHubActionsRule, type GitHubActionsRuleInput, + type KubernetesSecretRule, + type KubernetesSecretRuleInput, } from "@/lib/vault" const themeOptions: Array<{ value: Theme; label: string; Icon: LucideIcon }> = [ @@ -194,11 +200,171 @@ function GitHubActionsRuleDialog({ ) } +function KubernetesSecretRuleDialog({ + rule, + trigger, + isPending, + onSubmit, +}: { + rule?: KubernetesSecretRule + trigger: ReactNode + isPending: boolean + onSubmit: (input: KubernetesSecretRuleInput) => Promise +}) { + const [open, setOpen] = useState(false) + const [name, setName] = useState(rule?.name ?? "") + const [clusterPatterns, setClusterPatterns] = useState(() => listText(rule?.cluster_patterns)) + 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 ?? "") + setClusterPatterns(listText(rule?.cluster_patterns)) + 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_patterns: normalizeList(clusterPatterns, true), + namespace_patterns: normalizeList(namespacePatterns, true), + service_account_patterns: normalizeList(serviceAccountPatterns, true), + secret_selectors: normalizeList(secretSelectors, true), + enabled, + }) + handleOpenChange(false) + } + + return ( + + {trigger} + + + {isEditing ? "Edit Kubernetes rule" : "New Kubernetes rule"} + +
+
+
+ + setName(event.target.value.toLowerCase())} + placeholder="production-sync" + required + /> +
+ +
+ +
+
+ +