diff --git a/charts/kite/templates/declarative-config.yaml b/charts/kite/templates/declarative-config.yaml new file mode 100644 index 00000000..941cb1a4 --- /dev/null +++ b/charts/kite/templates/declarative-config.yaml @@ -0,0 +1,80 @@ +{{- if .Values.kiteConfig.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "kite.fullname" . }}-declarative-config + namespace: {{ .Release.Namespace }} + labels: + {{- include "kite.labels" . | nindent 4 }} +data: + {{- if .Values.kiteConfig.oauth }} + oauth.yaml: | + oauth: + providers: + {{- range .Values.kiteConfig.oauth.providers }} + - name: {{ .name | quote }} + {{- if .issuerUrl }} + issuerUrl: {{ .issuerUrl | quote }} + {{- end }} + {{- if .clientId }} + clientId: {{ .clientId | quote }} + {{- end }} + {{- if .clientSecret }} + clientSecret: {{ .clientSecret | quote }} + {{- end }} + {{- if .authUrl }} + authUrl: {{ .authUrl | quote }} + {{- end }} + {{- if .tokenUrl }} + tokenUrl: {{ .tokenUrl | quote }} + {{- end }} + {{- if .userInfoUrl }} + userInfoUrl: {{ .userInfoUrl | quote }} + {{- end }} + {{- if .scopes }} + scopes: {{ .scopes | quote }} + {{- end }} + {{- if not (kindIs "invalid" .enabled) }} + enabled: {{ .enabled }} + {{- end }} + {{- end }} + {{- end }} + {{- if .Values.kiteConfig.roles }} + roles.yaml: | + roles: + {{- range .Values.kiteConfig.roles }} + - name: {{ .name | quote }} + {{- if .description }} + description: {{ .description | quote }} + {{- end }} + {{- if not (kindIs "invalid" .clusters) }} + clusters: + {{- toYaml .clusters | nindent 10 }} + {{- end }} + {{- if not (kindIs "invalid" .namespaces) }} + namespaces: + {{- toYaml .namespaces | nindent 10 }} + {{- end }} + {{- if not (kindIs "invalid" .resources) }} + resources: + {{- toYaml .resources | nindent 10 }} + {{- end }} + {{- if not (kindIs "invalid" .verbs) }} + verbs: + {{- toYaml .verbs | nindent 10 }} + {{- end }} + {{- if not (kindIs "invalid" .assignments) }} + assignments: + {{- range .assignments }} + - subjectType: {{ .subjectType | quote }} + subject: {{ .subject | quote }} + {{- end }} + {{- end }} + {{- end }} + {{- end }} + {{- if .Values.kiteConfig.generalSettings }} + settings.yaml: | + generalSettings: + {{- toYaml .Values.kiteConfig.generalSettings | nindent 6 }} + {{- end }} +{{- end }} diff --git a/charts/kite/templates/deployment.yaml b/charts/kite/templates/deployment.yaml index ec886482..d9ce5967 100644 --- a/charts/kite/templates/deployment.yaml +++ b/charts/kite/templates/deployment.yaml @@ -16,9 +16,14 @@ spec: {{- end }} template: metadata: - {{- with .Values.podAnnotations }} + {{- if or .Values.podAnnotations .Values.kiteConfig.enabled }} annotations: + {{- with .Values.podAnnotations }} {{- toYaml . | nindent 8 }} + {{- end }} + {{- if .Values.kiteConfig.enabled }} + checksum/declarative-config: {{ include (print $.Template.BasePath "/declarative-config.yaml") . | sha256sum }} + {{- end }} {{- end }} labels: {{- include "kite.labels" . | nindent 8 }} @@ -73,6 +78,10 @@ spec: - name: KITE_BASE value: {{ .Values.basePath }} {{- end }} + {{- if .Values.kiteConfig.enabled }} + - name: KITE_CONFIG_DIR + value: /etc/kite/config.d + {{- end }} {{- with .Values.extraEnvs }} {{- toYaml . | nindent 12 }} {{- end }} @@ -96,6 +105,11 @@ spec: - name: {{ include "kite.fullname" . }}-storage mountPath: {{ .Values.db.sqlite.persistence.mountPath }} {{- end }} + {{- if .Values.kiteConfig.enabled }} + - name: declarative-config + mountPath: /etc/kite/config.d + readOnly: true + {{- end }} volumes: {{- if eq .Values.db.type "sqlite"}} - name: {{ include "kite.fullname" . }}-storage @@ -110,6 +124,11 @@ spec: emptyDir: {} {{- end }} {{- end }} + {{- if .Values.kiteConfig.enabled }} + - name: declarative-config + configMap: + name: {{ include "kite.fullname" . }}-declarative-config + {{- end }} {{- with .Values.volumes }} {{- toYaml . | nindent 8 }} {{- end }} diff --git a/charts/kite/values.yaml b/charts/kite/values.yaml index 117b693c..e9592e9b 100644 --- a/charts/kite/values.yaml +++ b/charts/kite/values.yaml @@ -305,3 +305,81 @@ nodeSelector: {} tolerations: [] affinity: {} + +# ══════════════════════════════════════════════════════════════════════════════ +# Declarative Configuration (GitOps-ready) +# ══════════════════════════════════════════════════════════════════════════════ +# When enabled, the chart creates a ConfigMap mounted at /etc/kite/config.d/ +# containing YAML config files. Kite watches this directory with fsnotify and +# reconciles OAuth, RBAC, and settings to the database automatically. +# +# Features: +# - Hot reload: changes apply within seconds (no pod restart needed) +# - conf.d pattern: multiple files are merged alphabetically +# - GitOps native: works with ArgoCD, Flux, or any Helm-based pipeline +# - No CRDs required: configuration is a simple ConfigMap +# - Managed resources are tracked: manually-created resources are never touched +# - Orphan cleanup: if you remove a provider/role from config, it gets deleted +# +# For OAuth client secrets, do NOT put them in values.yaml. Instead: +# - Use a Kubernetes Secret and reference it via extraEnvs, or +# - Use External Secrets Operator / Sealed Secrets to inject them +# - The clientSecret field supports env var interpolation at runtime +# +# Example with Azure Entra ID: +# kiteConfig: +# enabled: true +# oauth: +# providers: +# - name: "microsoft-entra-id" +# issuerUrl: "https://login.microsoftonline.com/YOUR_TENANT/v2.0" +# clientId: "YOUR_CLIENT_ID" +# clientSecret: "YOUR_CLIENT_SECRET" +# authUrl: "https://login.microsoftonline.com/YOUR_TENANT/oauth2/v2.0/authorize" +# tokenUrl: "https://login.microsoftonline.com/YOUR_TENANT/oauth2/v2.0/token" +# userInfoUrl: "https://graph.microsoft.com/oidc/userinfo" +# scopes: "openid profile email User.Read" +# roles: +# - name: admin +# assignments: +# - { subjectType: group, subject: "aad-group-id-for-admins" } +# - name: viewer +# assignments: +# - { subjectType: group, subject: "aad-group-id-for-viewers" } +# - name: project-alpha-dev +# description: "Developer access for Project Alpha" +# namespaces: ["alpha-dev", "alpha-pre"] +# verbs: ["get", "log", "terminal"] +# assignments: +# - { subjectType: group, subject: "aad-group-id-alpha-devs" } +# generalSettings: +# kubectlEnabled: true +# enableAnalytics: false +# ══════════════════════════════════════════════════════════════════════════════ +kiteConfig: + # Set to true to create a ConfigMap with declarative config + enabled: false + # OAuth/OIDC providers + # oauth: + # providers: + # - name: "my-oidc-provider" + # issuerUrl: "" + # clientId: "" + # clientSecret: "" + # scopes: "openid profile email" + # RBAC roles with assignments + # roles: + # - name: admin + # assignments: + # - { subjectType: group, subject: "admin-group-id" } + # - name: custom-role + # description: "Custom scoped role" + # namespaces: ["ns1", "ns2"] + # verbs: ["get"] + # assignments: + # - { subjectType: group, subject: "group-id" } + # General settings + # generalSettings: + # kubectlEnabled: true + # enableAnalytics: false + # enableVersionCheck: true \ No newline at end of file diff --git a/deploy/examples/declarative-config-confd.yaml b/deploy/examples/declarative-config-confd.yaml new file mode 100644 index 00000000..c03a5db5 --- /dev/null +++ b/deploy/examples/declarative-config-confd.yaml @@ -0,0 +1,54 @@ +## Example: Standalone declarative config files (conf.d pattern) +## +## These files can be placed in /etc/kite/config.d/ (or the path set in +## KITE_CONFIG_DIR) either via ConfigMap mount, hostPath, or any volume type. +## +## Files are read alphabetically and merged: +## - OAuth providers and roles are appended across files +## - General settings use last-write-wins per field +## +## This example shows how to split config across multiple files: +## /etc/kite/config.d/ +## 01-oauth.yaml ← OAuth providers +## 02-roles-platform.yaml ← Platform RBAC +## 03-roles-team-alpha.yaml ← Team-specific RBAC +## 04-settings.yaml ← General settings +## +## Each file follows the same KiteConfig schema. + +# ── 01-oauth.yaml ──────────────────────────────────────────────────────────── +# oauth: +# providers: +# - name: "my-oidc-provider" +# issuerUrl: "https://accounts.google.com" +# clientId: "xxx.apps.googleusercontent.com" +# clientSecret: "GOCSPX-xxx" +# scopes: "openid profile email" +# enabled: true + +# ── 02-roles-platform.yaml ────────────────────────────────────────────────── +# roles: +# - name: admin +# assignments: +# - { subjectType: group, subject: "platform-admins-group-id" } +# - name: viewer +# assignments: +# - { subjectType: group, subject: "platform-viewers-group-id" } + +# ── 03-roles-team-alpha.yaml ──────────────────────────────────────────────── +# roles: +# - name: alpha-developer +# description: "Alpha team developer access" +# namespaces: ["alpha-*"] +# verbs: ["get", "log", "terminal", "create", "update"] +# assignments: +# - { subjectType: group, subject: "alpha-devs-group-id" } + +# ── 04-settings.yaml ──────────────────────────────────────────────────────── +# generalSettings: +# kubectlEnabled: true +# enableAnalytics: false +# enableVersionCheck: true +# aiAgentEnabled: true +# aiProvider: "openai" +# aiModel: "gpt-4o" diff --git a/deploy/examples/kite-values-entra-id.yaml b/deploy/examples/kite-values-entra-id.yaml new file mode 100644 index 00000000..4eecd134 --- /dev/null +++ b/deploy/examples/kite-values-entra-id.yaml @@ -0,0 +1,61 @@ +## Example: Declarative config with Azure Entra ID OAuth + RBAC roles +## +## Usage with Helm: +## helm upgrade --install kite kite/kite -f kite-values-entra-id.yaml +## +## Usage without Helm (standalone ConfigMap): +## 1. Create the ConfigMap from this file: +## kubectl create configmap kite-declarative-config \ +## --namespace kite \ +## --from-file=oauth.yaml=deploy/examples/entra-id-oauth.yaml \ +## --from-file=roles.yaml=deploy/examples/entra-id-roles.yaml +## 2. Mount it at /etc/kite/config.d/ in the Kite Deployment +## 3. Set env KITE_CONFIG_DIR=/etc/kite/config.d (optional, it's the default) +## +## For OAuth secrets, create a separate K8s Secret and inject via env vars: +## kubectl create secret generic kite-oauth-credentials \ +## --namespace kite \ +## --from-literal=CLIENT_ID=your-client-id \ +## --from-literal=CLIENT_SECRET=your-client-secret +## +## Then reference in the Helm values extraEnvs or Deployment envFrom. + +# ── Helm values.yaml example ───────────────────────────────────────────────── +kiteConfig: + enabled: true + oauth: + providers: + - name: "microsoft-entra-id" + issuerUrl: "https://login.microsoftonline.com/YOUR_TENANT_ID/v2.0" + clientId: "YOUR_CLIENT_ID" + clientSecret: "YOUR_CLIENT_SECRET" + authUrl: "https://login.microsoftonline.com/YOUR_TENANT_ID/oauth2/v2.0/authorize" + tokenUrl: "https://login.microsoftonline.com/YOUR_TENANT_ID/oauth2/v2.0/token" + userInfoUrl: "https://graph.microsoft.com/oidc/userinfo" + scopes: "openid profile email User.Read" + enabled: true + roles: + # Assign Entra ID groups to the built-in admin role + - name: admin + assignments: + - subjectType: group + subject: "00000000-0000-0000-0000-000000000001" # Platform Admins group ID + # Assign Entra ID groups to the built-in viewer role + - name: viewer + assignments: + - subjectType: group + subject: "00000000-0000-0000-0000-000000000002" # Platform Viewers group ID + # Custom project-scoped role + - name: project-alpha-developer + description: "Developer access for Project Alpha namespaces" + clusters: ["*"] + namespaces: ["alpha-dev", "alpha-pre", "alpha-pro"] + resources: ["*"] + verbs: ["get", "log", "terminal"] + assignments: + - subjectType: group + subject: "00000000-0000-0000-0000-000000000003" # Alpha Dev Team group ID + generalSettings: + kubectlEnabled: true + enableAnalytics: false + enableVersionCheck: true diff --git a/go.mod b/go.mod index c393c8b7..c69d0a05 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/anthropics/anthropic-sdk-go v1.26.0 github.com/blang/semver/v4 v4.0.0 github.com/bytedance/mockey v1.4.5 + github.com/fsnotify/fsnotify v1.9.0 github.com/gin-contrib/gzip v1.2.5 github.com/gin-gonic/gin v1.12.0 github.com/glebarez/sqlite v1.11.0 @@ -47,7 +48,6 @@ require ( github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/fatih/camelcase v1.0.0 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gin-contrib/sse v1.1.0 // indirect diff --git a/main.go b/main.go index fa71bb30..ce6f1c9b 100644 --- a/main.go +++ b/main.go @@ -23,6 +23,7 @@ import ( "github.com/zxh326/kite/pkg/auth" "github.com/zxh326/kite/pkg/cluster" "github.com/zxh326/kite/pkg/common" + "github.com/zxh326/kite/pkg/config" "github.com/zxh326/kite/pkg/handlers" "github.com/zxh326/kite/pkg/handlers/resources" "github.com/zxh326/kite/pkg/middleware" @@ -254,6 +255,13 @@ func main() { log.Fatalf("Failed to create ClusterManager: %v", err) } + // Start declarative config watcher (reads /etc/kite/config.d/*.yaml) + configCtx, configCancel := context.WithCancel(context.Background()) + defer configCancel() + if w := config.NewWatcher(); w != nil { + go w.Start(configCtx) + } + base := r.Group(common.Base) // Setup router setupAPIRouter(base, cm) @@ -277,6 +285,7 @@ func main() { <-quit klog.Info("Shutting down server...") + configCancel() // Stop declarative config watcher ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { diff --git a/pkg/config/reconciler.go b/pkg/config/reconciler.go new file mode 100644 index 00000000..9c1af4af --- /dev/null +++ b/pkg/config/reconciler.go @@ -0,0 +1,520 @@ +package config + +import ( + "fmt" + "os" + "regexp" + "strings" + + "github.com/zxh326/kite/pkg/model" + "github.com/zxh326/kite/pkg/rbac" + "gorm.io/gorm/clause" + "k8s.io/klog/v2" +) + +const ( + // managedByLabel is the value stored in ManagedBy fields to distinguish + // declarative-config-managed resources from manually created ones. + managedByLabel = "kite-declarative-config" +) + +// Reconciler applies a KiteConfig to the database. It performs full CRUD +// reconciliation: creates missing resources, updates existing ones, and +// deletes resources that were previously managed but are no longer declared. +type Reconciler struct{} + +// NewReconciler creates a new Reconciler. +func NewReconciler() *Reconciler { + return &Reconciler{} +} + +// Reconcile applies the full KiteConfig to the database. +// Each section (OAuth, Roles, GeneralSettings) is reconciled independently +// so a failure in one doesn't block the others. +func (r *Reconciler) Reconcile(cfg *KiteConfig) error { + var errs []string + + if err := r.reconcileOAuth(cfg); err != nil { + errs = append(errs, fmt.Sprintf("oauth: %v", err)) + } + + if err := r.reconcileRoles(cfg); err != nil { + errs = append(errs, fmt.Sprintf("roles: %v", err)) + } + + if err := r.reconcileGeneralSettings(cfg); err != nil { + errs = append(errs, fmt.Sprintf("generalSettings: %v", err)) + } + + if len(errs) > 0 { + return fmt.Errorf("reconciliation errors: %s", strings.Join(errs, "; ")) + } + return nil +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// OAuth Providers +// ═══════════════════════════════════════════════════════════════════════════════ + +func (r *Reconciler) reconcileOAuth(cfg *KiteConfig) error { + if cfg.OAuth == nil || len(cfg.OAuth.Providers) == 0 { + // If no providers declared, clean up any previously managed ones + return r.deleteOrphanedOAuthProviders(nil) + } + + // Track which provider names are declared + declaredNames := make(map[string]bool) + var errs []string + + for i, p := range cfg.OAuth.Providers { + if p.Name == "" { + return fmt.Errorf("OAuth provider at index %d has an empty name; aborting reconciliation to prevent accidental orphan cleanup", i) + } + + name := strings.ToLower(p.Name) + declaredNames[name] = true + + enabled := true + if p.Enabled != nil { + enabled = *p.Enabled + } + + // Look for existing provider by name + existing, err := model.GetOAuthProviderByNameUnfiltered(name) + if err != nil { + // Not found → create + provider := &model.OAuthProvider{ + Name: model.LowerCaseString(name), + ClientID: p.ClientID, + ClientSecret: model.SecretString(expandEnvBraced(p.ClientSecret)), + AuthURL: p.AuthURL, + TokenURL: p.TokenURL, + UserInfoURL: p.UserInfoURL, + Scopes: p.Scopes, + Issuer: p.IssuerURL, + Enabled: enabled, + ManagedBy: managedByLabel, + } + if err := model.CreateOAuthProvider(provider); err != nil { + klog.Errorf("Failed to create OAuth provider %q: %v", name, err) + errs = append(errs, fmt.Sprintf("create provider %q: %v", name, err)) + continue + } + klog.Infof("Created OAuth provider %q", name) + } else { + // Found → update all fields unconditionally so the declared + // config is the sole source of truth (deduplicateProviders + // already merged multi-fragment values at the watcher level). + // Only client_secret stays conditional — it is commonly + // injected via a Secret env-var rather than config files. + updates := map[string]interface{}{ + "client_id": p.ClientID, + "auth_url": p.AuthURL, + "token_url": p.TokenURL, + "user_info_url": p.UserInfoURL, + "scopes": p.Scopes, + "issuer": p.IssuerURL, + "enabled": enabled, + "managed_by": managedByLabel, + } + if p.ClientSecret != "" { + updates["client_secret"] = model.SecretString(expandEnvBraced(p.ClientSecret)) + } + if err := model.UpdateOAuthProvider(&existing, updates); err != nil { + klog.Errorf("Failed to update OAuth provider %q: %v", name, err) + errs = append(errs, fmt.Sprintf("update provider %q: %v", name, err)) + continue + } + klog.V(1).Infof("Updated OAuth provider %q", name) + } + } + + if err := r.deleteOrphanedOAuthProviders(declaredNames); err != nil { + errs = append(errs, fmt.Sprintf("orphan cleanup: %v", err)) + } + if len(errs) > 0 { + return fmt.Errorf("OAuth reconciliation errors: %s", strings.Join(errs, "; ")) + } + return nil +} + +// deleteOrphanedOAuthProviders removes providers that were managed by declarative +// config but are no longer in the declared set. Manually created providers +// (ManagedBy == "") are never touched. +func (r *Reconciler) deleteOrphanedOAuthProviders(declaredNames map[string]bool) error { + var managed []model.OAuthProvider + if err := model.DB.Where("managed_by = ?", managedByLabel).Find(&managed).Error; err != nil { + return fmt.Errorf("listing managed OAuth providers: %w", err) + } + + var errs []string + for _, p := range managed { + name := strings.ToLower(string(p.Name)) + if !declaredNames[name] { + if err := model.DeleteOAuthProvider(p.ID); err != nil { + klog.Errorf("Failed to delete orphaned OAuth provider %q (id=%d): %v", name, p.ID, err) + errs = append(errs, fmt.Sprintf("delete provider %q: %v", name, err)) + } else { + klog.Infof("Deleted orphaned OAuth provider %q (id=%d)", name, p.ID) + } + } + } + if len(errs) > 0 { + return fmt.Errorf("orphan provider cleanup errors: %s", strings.Join(errs, "; ")) + } + return nil +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// RBAC Roles & Assignments +// ═══════════════════════════════════════════════════════════════════════════════ + +func (r *Reconciler) reconcileRoles(cfg *KiteConfig) error { + declaredNames := make(map[string]bool) + var errs []string + + for i, rc := range cfg.Roles { + if rc.Name == "" { + return fmt.Errorf("role at index %d has an empty name; aborting reconciliation to prevent accidental orphan cleanup", i) + } + declaredNames[strings.ToLower(rc.Name)] = true + + if err := r.reconcileOneRole(rc); err != nil { + klog.Errorf("Failed to reconcile role %q: %v", rc.Name, err) + errs = append(errs, fmt.Sprintf("role %q: %v", rc.Name, err)) + } + } + + if err := r.deleteOrphanedRoles(declaredNames); err != nil { + errs = append(errs, fmt.Sprintf("orphan cleanup: %v", err)) + } + if len(errs) > 0 { + return fmt.Errorf("role reconciliation errors: %s", strings.Join(errs, "; ")) + } + return nil +} + +func (r *Reconciler) reconcileOneRole(rc RoleConfig) error { + // Normalize to lowercase so DB lookups are consistent regardless of + // collation and match the lowercase keys used in orphan cleanup. + rc.Name = strings.ToLower(rc.Name) + + existing, err := model.GetRoleByName(rc.Name) + if err != nil { + // Not found → create + role := model.Role{ + Name: rc.Name, + Description: rc.Description, + Clusters: coalesceSlice(rc.Clusters, []string{"*"}), + Namespaces: rc.Namespaces, + Resources: coalesceSlice(rc.Resources, []string{"*"}), + Verbs: rc.Verbs, + ManagedBy: managedByLabel, + } + if err := model.DB.Create(&role).Error; err != nil { + return fmt.Errorf("creating role: %w", err) + } + klog.Infof("Created role %q", rc.Name) + existing = &role + } else { + // System roles (admin/viewer): only update assignments, don't redefine rules + if !existing.IsSystem { + existing.Description = rc.Description + existing.Clusters = coalesceSlice(rc.Clusters, []string{"*"}) + existing.Namespaces = rc.Namespaces + existing.Resources = coalesceSlice(rc.Resources, []string{"*"}) + existing.Verbs = rc.Verbs + existing.ManagedBy = managedByLabel + if err := model.DB.Save(existing).Error; err != nil { + return fmt.Errorf("updating role: %w", err) + } + klog.V(1).Infof("Updated role %q", rc.Name) + } else { + // For system roles, just mark as managed so assignments are tracked + if existing.ManagedBy != managedByLabel { + if err := model.DB.Model(existing).Update("managed_by", managedByLabel).Error; err != nil { + return fmt.Errorf("adopting system role %q: %w", rc.Name, err) + } + } + } + } + + // Reconcile assignments + return r.reconcileAssignments(existing, rc.Assignments) +} + +func (r *Reconciler) reconcileAssignments(role *model.Role, desired []AssignmentConfig) error { + // Validate subject types before touching the DB. + for _, a := range desired { + if a.SubjectType != "user" && a.SubjectType != "group" { + return fmt.Errorf("invalid subjectType %q for assignment %q in role %q (must be \"user\" or \"group\")", + a.SubjectType, a.Subject, role.Name) + } + } + + var errs []string + + // Upsert desired assignments — atomic INSERT ON CONFLICT UPDATE ensures + // creation and adoption are idempotent and safe under concurrent writers. + // The composite unique index idx_role_assignment_uniq on + // (role_id, subject_type, subject) guarantees no duplicate rows. + // We also update subject/subject_type so case-only renames on + // case-insensitive DBs converge to the declared casing. + for _, a := range desired { + assignment := model.RoleAssignment{ + RoleID: role.ID, + SubjectType: a.SubjectType, + Subject: a.Subject, + ManagedBy: managedByLabel, + } + if err := model.DB.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "role_id"}, {Name: "subject_type"}, {Name: "subject"}}, + DoUpdates: clause.AssignmentColumns([]string{"managed_by", "subject_type", "subject"}), + }).Create(&assignment).Error; err != nil { + klog.Errorf("Failed to upsert assignment %s/%s for role %q: %v", + a.SubjectType, a.Subject, role.Name, err) + errs = append(errs, fmt.Sprintf("upsert %s/%s: %v", a.SubjectType, a.Subject, err)) + } + } + + // Delete orphaned managed assignments via SQL so case comparisons use the + // database’s own collation rules rather than Go map equality. This avoids + // the mismatch where a Go-lowercased key hides a case-only rename. + // + // To stay within SQLite's 999-parameter limit we first collect the IDs of + // desired rows that exist in the DB (batched), then delete all managed rows + // for this role whose ID is not in that keep-set. + if err := deleteOrphanAssignments(role.ID, desired); err != nil { + klog.Errorf("Failed to delete orphaned assignments for role %q: %v", role.Name, err) + errs = append(errs, fmt.Sprintf("orphan cleanup: %v", err)) + } + + if len(errs) > 0 { + return fmt.Errorf("assignment errors for role %q: %s", role.Name, strings.Join(errs, "; ")) + } + return nil +} + +// sqlParamBatchSize is the maximum number of bind parameters per SQL statement. +// SQLite's default SQLITE_MAX_VARIABLE_NUMBER is 999; we stay well below that. +const sqlParamBatchSize = 400 + +// deleteOrphanAssignments deletes managed assignments for roleID that are not +// in the desired list. It collects the IDs of desired rows in batches (to stay +// within SQLite's bind-parameter limit), then deletes everything else. +func deleteOrphanAssignments(roleID uint, desired []AssignmentConfig) error { + if len(desired) == 0 { + // No desired assignments — delete ALL managed rows for this role. + res := model.DB.Where("role_id = ? AND managed_by = ?", roleID, managedByLabel). + Delete(&model.RoleAssignment{}) + if res.Error != nil { + return res.Error + } + if res.RowsAffected > 0 { + klog.Infof("Deleted %d orphaned managed assignment(s) from role ID %d", res.RowsAffected, roleID) + } + return nil + } + + // Step 1: collect IDs of desired rows that already exist (the keep-set). + keepIDs := make(map[uint]bool) + for i := 0; i < len(desired); i += sqlParamBatchSize { + end := i + sqlParamBatchSize + if end > len(desired) { + end = len(desired) + } + batch := desired[i:end] + + var conds []string + var args []interface{} + args = append(args, roleID) + for _, a := range batch { + conds = append(conds, "(subject_type = ? AND subject = ?)") + args = append(args, a.SubjectType, a.Subject) + } + query := "role_id = ? AND (" + strings.Join(conds, " OR ") + ")" + + var rows []model.RoleAssignment + if err := model.DB.Where(query, args...).Find(&rows).Error; err != nil { + return fmt.Errorf("querying keep-set batch: %w", err) + } + for _, r := range rows { + keepIDs[r.ID] = true + } + } + + // Step 2: delete all managed rows for this role that are NOT in the keep-set. + var allManaged []model.RoleAssignment + if err := model.DB.Where("role_id = ? AND managed_by = ?", roleID, managedByLabel). + Find(&allManaged).Error; err != nil { + return fmt.Errorf("listing managed assignments: %w", err) + } + + var toDelete []uint + for _, a := range allManaged { + if !keepIDs[a.ID] { + toDelete = append(toDelete, a.ID) + } + } + + // Delete in batches of sqlParamBatchSize. + for i := 0; i < len(toDelete); i += sqlParamBatchSize { + end := i + sqlParamBatchSize + if end > len(toDelete) { + end = len(toDelete) + } + if res := model.DB.Where("id IN ?", toDelete[i:end]).Delete(&model.RoleAssignment{}); res.Error != nil { + return fmt.Errorf("deleting orphan batch: %w", res.Error) + } else if res.RowsAffected > 0 { + klog.Infof("Deleted %d orphaned managed assignment(s) from role ID %d", res.RowsAffected, roleID) + } + } + return nil +} + +// deleteOrphanedRoles removes roles that were managed by declarative config +// but are no longer in the declared set. System roles are never deleted, but +// their managed assignments ARE revoked when the role leaves the desired config. +func (r *Reconciler) deleteOrphanedRoles(declaredNames map[string]bool) error { + // Fetch ALL managed roles (including system) so we can revoke assignments + // on system roles that are no longer in the desired set. + var allManaged []model.Role + if err := model.DB.Where("managed_by = ?", managedByLabel).Find(&allManaged).Error; err != nil { + return fmt.Errorf("listing managed roles: %w", err) + } + + var errs []string + for _, role := range allManaged { + if declaredNames[strings.ToLower(role.Name)] { + continue + } + + // Revoke managed assignments regardless of whether the role is a system role. + revokeFailed := false + if err := model.DB.Where("role_id = ? AND managed_by = ?", role.ID, managedByLabel). + Delete(&model.RoleAssignment{}).Error; err != nil { + klog.Errorf("Failed to revoke managed assignments for role %q (id=%d): %v", role.Name, role.ID, err) + errs = append(errs, fmt.Sprintf("revoke assignments for %q: %v", role.Name, err)) + revokeFailed = true + } + + if role.IsSystem { + // Only clear managed_by when assignment revocation succeeded; + // otherwise the role stays in the managed set and will be retried. + if revokeFailed { + continue + } + if err := model.DB.Model(&role).Update("managed_by", "").Error; err != nil { + klog.Errorf("Failed to clear managed_by on system role %q: %v", role.Name, err) + errs = append(errs, fmt.Sprintf("clear managed_by on %q: %v", role.Name, err)) + } else { + klog.Infof("Revoked managed assignments and cleared managed_by for system role %q", role.Name) + } + } else { + // Non-system roles: delete entirely (cascading via assignments already removed above). + if err := model.DB.Delete(&role).Error; err != nil { + klog.Errorf("Failed to delete orphaned role %q (id=%d): %v", role.Name, role.ID, err) + errs = append(errs, fmt.Sprintf("delete role %q: %v", role.Name, err)) + } else { + klog.Infof("Deleted orphaned role %q (id=%d)", role.Name, role.ID) + } + } + } + + // Trigger RBAC in-memory refresh + select { + case rbac.SyncNow <- struct{}{}: + default: + } + + if len(errs) > 0 { + return fmt.Errorf("orphan role cleanup errors: %s", strings.Join(errs, "; ")) + } + return nil +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// General Settings +// ═══════════════════════════════════════════════════════════════════════════════ + +func (r *Reconciler) reconcileGeneralSettings(cfg *KiteConfig) error { + gs := cfg.GeneralSettings + if gs == nil { + return nil + } + + updates := make(map[string]interface{}) + + if gs.AIAgentEnabled != nil { + updates["ai_agent_enabled"] = *gs.AIAgentEnabled + } + if gs.AIProvider != nil { + updates["ai_provider"] = *gs.AIProvider + } + if gs.AIModel != nil { + updates["ai_model"] = *gs.AIModel + } + if gs.AIBaseURL != nil { + updates["ai_base_url"] = *gs.AIBaseURL + } + if gs.AIMaxTokens != nil { + updates["ai_max_tokens"] = *gs.AIMaxTokens + } + if gs.KubectlEnabled != nil { + updates["kubectl_enabled"] = *gs.KubectlEnabled + } + if gs.KubectlImage != nil { + updates["kubectl_image"] = *gs.KubectlImage + } + if gs.NodeTerminalImage != nil { + updates["node_terminal_image"] = *gs.NodeTerminalImage + } + if gs.EnableAnalytics != nil { + updates["enable_analytics"] = *gs.EnableAnalytics + } + if gs.EnableVersionCheck != nil { + updates["enable_version_check"] = *gs.EnableVersionCheck + } + + if len(updates) == 0 { + return nil + } + + if _, err := model.UpdateGeneralSetting(updates); err != nil { + return fmt.Errorf("updating general settings: %w", err) + } + + klog.Infof("Updated general settings (%d fields)", len(updates)) + return nil +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Helpers +// ═══════════════════════════════════════════════════════════════════════════════ + +// coalesceSlice returns val when explicitly set (non-nil), even if empty. +// Only when val is nil (field omitted in config) does it fall back to the default. +// This lets "clusters: []" mean "no clusters" rather than "all clusters". +func coalesceSlice(val, fallback []string) []string { + if val != nil { + return val + } + return fallback +} + +// envBracedRe matches only ${VAR} placeholders (brace-delimited). +var envBracedRe = regexp.MustCompile(`\$\{([^}]+)\}`) + +// expandEnvBraced replaces only ${VAR} placeholders with their environment +// values, leaving bare $, $VAR, and other dollar sequences untouched so +// literal secrets containing $ are not corrupted. Unknown variables +// preserve the original placeholder instead of becoming empty strings. +func expandEnvBraced(s string) string { + return envBracedRe.ReplaceAllStringFunc(s, func(m string) string { + key := m[2 : len(m)-1] // strip ${ and } + if val, ok := os.LookupEnv(key); ok { + return val + } + return m // preserve unknown placeholder + }) +} diff --git a/pkg/config/types.go b/pkg/config/types.go new file mode 100644 index 00000000..59cf2ab1 --- /dev/null +++ b/pkg/config/types.go @@ -0,0 +1,85 @@ +// Package config implements file-based declarative configuration for Kite. +// It watches a config directory (conf.d pattern) for YAML files and reconciles +// OAuth providers, RBAC roles/assignments, and general settings to the database. +// +// This replaces the need for a CRD-based controller — configuration is managed +// entirely through Helm values rendered into a ConfigMap and mounted as files. +package config + +// KiteConfig is the top-level structure for a declarative configuration file. +// Multiple files are merged alphabetically: later files override earlier ones +// for scalar fields; list fields (providers, roles) are concatenated. +type KiteConfig struct { + OAuth *OAuthConfig `json:"oauth,omitempty" yaml:"oauth,omitempty"` + Roles []RoleConfig `json:"roles,omitempty" yaml:"roles,omitempty"` + GeneralSettings *GeneralSettingsConfig `json:"generalSettings,omitempty" yaml:"generalSettings,omitempty"` +} + +// OAuthConfig defines the desired OAuth providers. +type OAuthConfig struct { + Providers []OAuthProviderConfig `json:"providers,omitempty" yaml:"providers,omitempty"` +} + +// OAuthProviderConfig declares a single OAuth/OIDC provider. +type OAuthProviderConfig struct { + // Name is the unique identifier (lowercased automatically). + Name string `json:"name" yaml:"name"` + // IssuerURL is the OIDC issuer URL. + IssuerURL string `json:"issuerUrl,omitempty" yaml:"issuerUrl,omitempty"` + // ClientID for the OAuth application. + ClientID string `json:"clientId,omitempty" yaml:"clientId,omitempty"` + // ClientSecret for the OAuth application. + // For production, pass this via a Kubernetes Secret env var or secretRef. + ClientSecret string `json:"clientSecret,omitempty" yaml:"clientSecret,omitempty"` + // AuthURL overrides the authorization endpoint. + AuthURL string `json:"authUrl,omitempty" yaml:"authUrl,omitempty"` + // TokenURL overrides the token endpoint. + TokenURL string `json:"tokenUrl,omitempty" yaml:"tokenUrl,omitempty"` + // UserInfoURL overrides the userinfo endpoint. + UserInfoURL string `json:"userInfoUrl,omitempty" yaml:"userInfoUrl,omitempty"` + // Scopes to request (space- or comma-separated). + Scopes string `json:"scopes,omitempty" yaml:"scopes,omitempty"` + // Enabled controls whether the provider is active. Defaults to true. + Enabled *bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` +} + +// RoleConfig declares a Kite RBAC role and its subject assignments. +type RoleConfig struct { + // Name is the unique role name. + Name string `json:"name" yaml:"name"` + // Description of the role. + Description string `json:"description,omitempty" yaml:"description,omitempty"` + // Clusters this role applies to (glob patterns). Defaults to ["*"]. + Clusters []string `json:"clusters,omitempty" yaml:"clusters,omitempty"` + // Namespaces this role can access (glob patterns). + Namespaces []string `json:"namespaces,omitempty" yaml:"namespaces,omitempty"` + // Resources this role can access (glob patterns). Defaults to ["*"]. + Resources []string `json:"resources,omitempty" yaml:"resources,omitempty"` + // Verbs allowed: get, list, watch, create, update, delete, log, terminal, "*". + Verbs []string `json:"verbs,omitempty" yaml:"verbs,omitempty"` + // Assignments maps subjects (users/groups) to this role. + Assignments []AssignmentConfig `json:"assignments,omitempty" yaml:"assignments,omitempty"` +} + +// AssignmentConfig binds a subject to a role. +type AssignmentConfig struct { + // SubjectType is "user" or "group". + SubjectType string `json:"subjectType" yaml:"subjectType"` + // Subject is the username or OIDC group ID. + Subject string `json:"subject" yaml:"subject"` +} + +// GeneralSettingsConfig mirrors model.GeneralSetting fields that can be set declaratively. +// Pointer types allow distinguishing "not set" from "set to zero/false". +type GeneralSettingsConfig struct { + AIAgentEnabled *bool `json:"aiAgentEnabled,omitempty" yaml:"aiAgentEnabled,omitempty"` + AIProvider *string `json:"aiProvider,omitempty" yaml:"aiProvider,omitempty"` + AIModel *string `json:"aiModel,omitempty" yaml:"aiModel,omitempty"` + AIBaseURL *string `json:"aiBaseUrl,omitempty" yaml:"aiBaseUrl,omitempty"` + AIMaxTokens *int `json:"aiMaxTokens,omitempty" yaml:"aiMaxTokens,omitempty"` + KubectlEnabled *bool `json:"kubectlEnabled,omitempty" yaml:"kubectlEnabled,omitempty"` + KubectlImage *string `json:"kubectlImage,omitempty" yaml:"kubectlImage,omitempty"` + NodeTerminalImage *string `json:"nodeTerminalImage,omitempty" yaml:"nodeTerminalImage,omitempty"` + EnableAnalytics *bool `json:"enableAnalytics,omitempty" yaml:"enableAnalytics,omitempty"` + EnableVersionCheck *bool `json:"enableVersionCheck,omitempty" yaml:"enableVersionCheck,omitempty"` +} diff --git a/pkg/config/watcher.go b/pkg/config/watcher.go new file mode 100644 index 00000000..3c950147 --- /dev/null +++ b/pkg/config/watcher.go @@ -0,0 +1,409 @@ +package config + +import ( + "context" + "crypto/sha256" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/fsnotify/fsnotify" + "k8s.io/klog/v2" + "sigs.k8s.io/yaml" +) + +const ( + // DefaultConfigDir is the default path where declarative config files are mounted. + DefaultConfigDir = "/etc/kite/config.d" + + // EnvConfigDir overrides the default config directory. + EnvConfigDir = "KITE_CONFIG_DIR" + + // debounceDelay prevents rapid re-reconciliation when multiple files change at once + // (e.g., ConfigMap atomic update replaces all symlinks simultaneously). + debounceDelay = 2 * time.Second + + // resyncInterval is a safety-net full re-read in case fsnotify misses an event + // (e.g., some NFS/FUSE drivers don't emit inotify events). + resyncInterval = 5 * time.Minute +) + +// Watcher monitors a config directory for YAML files and triggers reconciliation +// whenever a file is created, modified, or deleted. +type Watcher struct { + dir string + reconciler *Reconciler + mu sync.Mutex + lastHash string // SHA-256 of the merged config to skip no-op reconciliations +} + +// NewWatcher creates a new file-based config watcher. +// It returns nil if the config directory does not exist (declarative config disabled). +func NewWatcher() *Watcher { + dir := os.Getenv(EnvConfigDir) + if dir == "" { + dir = DefaultConfigDir + } + + info, err := os.Stat(dir) + if err != nil || !info.IsDir() { + klog.Infof("Declarative config directory %q not found — file-based config disabled", dir) + return nil + } + + klog.Infof("Declarative config directory found: %s", dir) + return &Watcher{ + dir: dir, + reconciler: NewReconciler(), + } +} + +// Start begins watching the config directory. It performs an initial reconciliation, +// then watches for changes via fsnotify with a periodic resync as safety net. +// Blocks until ctx is canceled. +func (w *Watcher) Start(ctx context.Context) { + // Initial reconciliation + w.reconcileFromDisk() + + // Set up fsnotify watcher + fswatcher, err := fsnotify.NewWatcher() + if err != nil { + klog.Errorf("Failed to create fsnotify watcher: %v — falling back to polling only", err) + w.pollLoop(ctx) + return + } + defer fswatcher.Close() + + // Watch the config directory itself. + // For ConfigMap volume mounts, Kubernetes uses a symlink swap pattern: + // ..data -> ..2024_01_01_00_00_00.123456789 + // When the ConfigMap is updated, a new timestamped dir is created and ..data + // is atomically re-pointed. We watch the parent dir to catch this. + if err := fswatcher.Add(w.dir); err != nil { + klog.Errorf("Failed to watch directory %s: %v — falling back to polling only", w.dir, err) + w.pollLoop(ctx) + return + } + + // Also watch the ..data symlink target if it exists (ConfigMap mount pattern) + dataLink := filepath.Join(w.dir, "..data") + if target, err := filepath.EvalSymlinks(dataLink); err == nil { + _ = fswatcher.Add(target) + } + + klog.Infof("Watching %s for declarative config changes (fsnotify + %s resync)", w.dir, resyncInterval) + + resyncTicker := time.NewTicker(resyncInterval) + defer resyncTicker.Stop() + + var debounceTimer *time.Timer + + for { + select { + case <-ctx.Done(): + klog.Info("Declarative config watcher stopped") + return + + case event, ok := <-fswatcher.Events: + if !ok { + return + } + // Only react to meaningful events + if event.Op&(fsnotify.Create|fsnotify.Write|fsnotify.Remove|fsnotify.Rename) == 0 { + continue + } + klog.V(2).Infof("Config file event: %s %s", event.Op, event.Name) + + // Debounce: reset timer on each event + if debounceTimer != nil { + debounceTimer.Stop() + } + debounceTimer = time.AfterFunc(debounceDelay, func() { + w.reconcileFromDisk() + // Re-watch ..data symlink target in case it changed (ConfigMap rotation) + if target, err := filepath.EvalSymlinks(dataLink); err == nil { + _ = fswatcher.Add(target) + } + }) + + case err, ok := <-fswatcher.Errors: + if !ok { + return + } + klog.Warningf("fsnotify error: %v", err) + + case <-resyncTicker.C: + w.reconcileFromDisk() + } + } +} + +// pollLoop is a fallback when fsnotify is unavailable. It polls the directory +// for changes at resyncInterval. +func (w *Watcher) pollLoop(ctx context.Context) { + klog.Info("Using polling fallback for declarative config changes") + ticker := time.NewTicker(resyncInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + w.reconcileFromDisk() + } + } +} + +// reconcileFromDisk reads all YAML files from the config directory, merges them, +// and reconciles the result to the database. Skips reconciliation if the merged +// config hasn't changed since the last run. +func (w *Watcher) reconcileFromDisk() { + w.mu.Lock() + defer w.mu.Unlock() + + cfg, raw, err := w.loadAndMerge() + if err != nil { + klog.Errorf("Failed to load declarative config: %v", err) + return + } + + // Compute hash to detect actual changes + hash := fmt.Sprintf("%x", sha256.Sum256(raw)) + if hash == w.lastHash { + klog.V(2).Info("Declarative config unchanged — skipping reconciliation") + return + } + + klog.Infof("Declarative config changed (hash %s…) — reconciling", hash[:12]) + + if err := w.reconciler.Reconcile(cfg); err != nil { + klog.Errorf("Declarative config reconciliation failed: %v", err) + return + } + + w.lastHash = hash + klog.Info("Declarative config reconciliation completed successfully") +} + +// loadAndMerge reads all *.yaml and *.yml files from the config directory, +// sorts them alphabetically, and merges them into a single KiteConfig. +// Returns the merged config and the raw concatenated bytes (for hashing). +func (w *Watcher) loadAndMerge() (*KiteConfig, []byte, error) { + entries, err := os.ReadDir(w.dir) + if err != nil { + return nil, nil, fmt.Errorf("reading config directory %s: %w", w.dir, err) + } + + // Collect YAML file paths, sorted alphabetically + var files []string + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + // Skip hidden files and Kubernetes ConfigMap metadata + if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "..") { + continue + } + ext := strings.ToLower(filepath.Ext(name)) + if ext == ".yaml" || ext == ".yml" { + files = append(files, filepath.Join(w.dir, name)) + } + } + sort.Strings(files) + + if len(files) == 0 { + klog.V(1).Info("No YAML files found in config directory — nothing to reconcile") + return &KiteConfig{}, []byte{}, nil + } + + merged := &KiteConfig{} + var allRaw []byte + + for _, f := range files { + data, err := os.ReadFile(f) + if err != nil { + return nil, nil, fmt.Errorf("reading config file %s: %w", filepath.Base(f), err) + } + if len(strings.TrimSpace(string(data))) == 0 { + continue + } + allRaw = append(allRaw, data...) + + var fragment KiteConfig + if err := yaml.UnmarshalStrict(data, &fragment); err != nil { + return nil, nil, fmt.Errorf("invalid config in %s: %w", filepath.Base(f), err) + } + + klog.V(2).Infof("Loaded config fragment: %s", filepath.Base(f)) + mergeConfig(merged, &fragment) + } + + // Deduplicate providers: if multiple fragments declare the same provider name, + // merge their fields (last-write-wins for non-empty fields). + if merged.OAuth != nil { + merged.OAuth.Providers = deduplicateProviders(merged.OAuth.Providers) + } + + // Deduplicate roles: if multiple fragments declare the same role name, + // merge their assignments (union) so conf.d splits don't cause revocations. + merged.Roles = deduplicateRoles(merged.Roles) + + return merged, allRaw, nil +} + +// deduplicateProviders coalesces OAuth providers with the same name after merging +// all fragments. Last-write-wins for non-empty scalar fields; the Enabled pointer +// is overwritten only when set. +func deduplicateProviders(providers []OAuthProviderConfig) []OAuthProviderConfig { + order := make([]string, 0, len(providers)) + byName := make(map[string]*OAuthProviderConfig, len(providers)) + + for _, p := range providers { + name := strings.ToLower(p.Name) + if existing, ok := byName[name]; ok { + if p.IssuerURL != "" { + existing.IssuerURL = p.IssuerURL + } + if p.ClientID != "" { + existing.ClientID = p.ClientID + } + if p.ClientSecret != "" { + existing.ClientSecret = p.ClientSecret + } + if p.AuthURL != "" { + existing.AuthURL = p.AuthURL + } + if p.TokenURL != "" { + existing.TokenURL = p.TokenURL + } + if p.UserInfoURL != "" { + existing.UserInfoURL = p.UserInfoURL + } + if p.Scopes != "" { + existing.Scopes = p.Scopes + } + if p.Enabled != nil { + existing.Enabled = p.Enabled + } + } else { + copy := p + byName[name] = © + order = append(order, name) + } + } + + result := make([]OAuthProviderConfig, 0, len(order)) + for _, name := range order { + result = append(result, *byName[name]) + } + return result +} + +// deduplicateRoles coalesces roles with the same name after merging all fragments. +// The last definition of scope fields (description, clusters, namespaces, resources, verbs) +// wins, while assignments are unioned across all entries to prevent conf.d splits from +// accidentally revoking access during orphan cleanup. +func deduplicateRoles(roles []RoleConfig) []RoleConfig { + order := make([]string, 0, len(roles)) + byName := make(map[string]*RoleConfig, len(roles)) + + for _, r := range roles { + name := strings.ToLower(r.Name) + if existing, ok := byName[name]; ok { + // Last-write-wins for scope fields. + // Use nil-checks (not len > 0) so a later fragment can + // explicitly clear a scope with an empty list (e.g. namespaces: []). + if r.Description != "" { + existing.Description = r.Description + } + if r.Clusters != nil { + existing.Clusters = r.Clusters + } + if r.Namespaces != nil { + existing.Namespaces = r.Namespaces + } + if r.Resources != nil { + existing.Resources = r.Resources + } + if r.Verbs != nil { + existing.Verbs = r.Verbs + } + // Union assignments + existing.Assignments = append(existing.Assignments, r.Assignments...) + } else { + copy := r + byName[name] = © + order = append(order, name) + } + } + + result := make([]RoleConfig, 0, len(order)) + for _, name := range order { + result = append(result, *byName[name]) + } + return result +} + +// mergeConfig merges src into dst. +// - OAuth providers and roles are appended (later files can add more). +// - GeneralSettings fields from src override dst when set (non-nil). +func mergeConfig(dst, src *KiteConfig) { + // Merge OAuth providers + if src.OAuth != nil { + if dst.OAuth == nil { + dst.OAuth = &OAuthConfig{} + } + dst.OAuth.Providers = append(dst.OAuth.Providers, src.OAuth.Providers...) + } + + // Merge roles + dst.Roles = append(dst.Roles, src.Roles...) + + // Merge general settings (last-write-wins per field) + if src.GeneralSettings != nil { + if dst.GeneralSettings == nil { + dst.GeneralSettings = &GeneralSettingsConfig{} + } + mergeGeneralSettings(dst.GeneralSettings, src.GeneralSettings) + } +} + +// mergeGeneralSettings copies non-nil fields from src to dst. +func mergeGeneralSettings(dst, src *GeneralSettingsConfig) { + if src.AIAgentEnabled != nil { + dst.AIAgentEnabled = src.AIAgentEnabled + } + if src.AIProvider != nil { + dst.AIProvider = src.AIProvider + } + if src.AIModel != nil { + dst.AIModel = src.AIModel + } + if src.AIBaseURL != nil { + dst.AIBaseURL = src.AIBaseURL + } + if src.AIMaxTokens != nil { + dst.AIMaxTokens = src.AIMaxTokens + } + if src.KubectlEnabled != nil { + dst.KubectlEnabled = src.KubectlEnabled + } + if src.KubectlImage != nil { + dst.KubectlImage = src.KubectlImage + } + if src.NodeTerminalImage != nil { + dst.NodeTerminalImage = src.NodeTerminalImage + } + if src.EnableAnalytics != nil { + dst.EnableAnalytics = src.EnableAnalytics + } + if src.EnableVersionCheck != nil { + dst.EnableVersionCheck = src.EnableVersionCheck + } +} diff --git a/pkg/model/model.go b/pkg/model/model.go index d4c9551f..334704a4 100644 --- a/pkg/model/model.go +++ b/pkg/model/model.go @@ -90,6 +90,10 @@ func InitDB() { panic("failed to enable sqlite foreign keys: " + err.Error()) } } + // Deduplicate role_assignments before adding the unique index so + // upgrades on clusters with pre-existing duplicates don't fail. + deduplicateRoleAssignments() + models := []interface{}{ User{}, Cluster{}, @@ -115,3 +119,49 @@ func InitDB() { sqldb.SetConnMaxLifetime(common.DBMaxIdleTime) } } + +// deduplicateRoleAssignments removes duplicate (role_id, subject_type, subject) +// rows from the role_assignments table, keeping only the row with the lowest ID. +// This must run BEFORE AutoMigrate adds the composite unique index to avoid a +// migration failure on clusters that already have duplicates. +func deduplicateRoleAssignments() { + // Only act if the table already exists (fresh installs have no data yet). + if !DB.Migrator().HasTable(&RoleAssignment{}) { + return + } + + // Find groups with more than one row for the same (role_id, subject_type, subject). + type dupGroup struct { + RoleID uint `gorm:"column:role_id"` + SubjectType string `gorm:"column:subject_type"` + Subject string `gorm:"column:subject"` + MinID uint `gorm:"column:min_id"` + } + var groups []dupGroup + if err := DB.Raw(` + SELECT role_id, subject_type, subject, MIN(id) AS min_id + FROM role_assignments + GROUP BY role_id, subject_type, subject + HAVING COUNT(*) > 1 + `).Scan(&groups).Error; err != nil { + klog.Warningf("deduplicateRoleAssignments: query failed (table may not exist yet): %v", err) + return + } + if len(groups) == 0 { + return + } + + for _, g := range groups { + res := DB.Exec( + "DELETE FROM role_assignments WHERE role_id = ? AND subject_type = ? AND subject = ? AND id != ?", + g.RoleID, g.SubjectType, g.Subject, g.MinID, + ) + if res.Error != nil { + klog.Errorf("deduplicateRoleAssignments: failed to clean duplicates for (%d, %s, %s): %v", + g.RoleID, g.SubjectType, g.Subject, res.Error) + } else if res.RowsAffected > 0 { + klog.Infof("deduplicateRoleAssignments: removed %d duplicate(s) for (%d, %s, %s)", + res.RowsAffected, g.RoleID, g.SubjectType, g.Subject) + } + } +} diff --git a/pkg/model/oauth.go b/pkg/model/oauth.go index 8f88e71c..6f114723 100644 --- a/pkg/model/oauth.go +++ b/pkg/model/oauth.go @@ -14,6 +14,10 @@ type OAuthProvider struct { Issuer string `json:"issuer" gorm:"type:varchar(255)"` Enabled bool `json:"enabled" gorm:"type:boolean;default:true"` + // ManagedBy tracks resources created by declarative file-based config. + // Empty means manually created (UI/API). + ManagedBy string `json:"managedBy,omitempty" gorm:"type:varchar(255);index;default:''"` + // Auto-generated redirect URL RedirectURL string `json:"-" gorm:"-"` } @@ -32,7 +36,7 @@ func GetEnabledOAuthProviders() ([]OAuthProvider, error) { return providers, err } -// GetOAuthProviderByName returns an OAuth provider by name +// GetOAuthProviderByName returns an OAuth provider by name (enabled only). func GetOAuthProviderByName(name string) (OAuthProvider, error) { var provider OAuthProvider name = strings.ToLower(name) @@ -43,6 +47,17 @@ func GetOAuthProviderByName(name string) (OAuthProvider, error) { return provider, nil } +// GetOAuthProviderByNameUnfiltered returns an OAuth provider by name regardless of enabled status. +func GetOAuthProviderByNameUnfiltered(name string) (OAuthProvider, error) { + var provider OAuthProvider + name = strings.ToLower(name) + err := DB.Where("name = ?", name).First(&provider).Error + if err != nil { + return OAuthProvider{}, err + } + return provider, nil +} + // CreateOAuthProvider creates a new OAuth provider func CreateOAuthProvider(provider *OAuthProvider) error { return DB.Create(provider).Error diff --git a/pkg/model/rbac.go b/pkg/model/rbac.go index e8f59787..0a11ab22 100644 --- a/pkg/model/rbac.go +++ b/pkg/model/rbac.go @@ -7,6 +7,10 @@ type Role struct { Description string `json:"description" gorm:"type:text"` IsSystem bool `json:"isSystem" gorm:"type:boolean;not null;default:false"` + // ManagedBy records the KiteConfig CR name that created this role. + // Empty means manually created (UI/API) or a system role. + ManagedBy string `json:"managedBy,omitempty" gorm:"type:varchar(255);index;default:''"` + // Rules Clusters SliceString `json:"clusters" gorm:"type:text"` Resources SliceString `json:"resources" gorm:"type:text"` @@ -21,10 +25,14 @@ type Role struct { type RoleAssignment struct { Model - RoleID uint `json:"roleId" gorm:"index;not null;constraint:OnDelete:CASCADE"` + RoleID uint `json:"roleId" gorm:"not null;constraint:OnDelete:CASCADE;uniqueIndex:idx_role_assignment_uniq,priority:1"` + + SubjectType string `json:"subjectType" gorm:"type:varchar(20);not null;index:idx_role_assignments_subject,priority:2;uniqueIndex:idx_role_assignment_uniq,priority:2"` + Subject string `json:"subject" gorm:"type:varchar(255);not null;index:idx_role_assignments_subject,priority:1;uniqueIndex:idx_role_assignment_uniq,priority:3"` - SubjectType string `json:"subjectType" gorm:"type:varchar(20);not null;index:idx_role_assignments_subject,priority:2"` - Subject string `json:"subject" gorm:"type:varchar(255);not null;index:idx_role_assignments_subject,priority:1"` + // ManagedBy records the KiteConfig CR name that created this assignment. + // Empty means manually created (UI/API). + ManagedBy string `json:"managedBy,omitempty" gorm:"type:varchar(255);index;default:''"` } // Convenience constants for SubjectType