diff --git a/app.go b/app.go index 2529d9ce..5e5517c0 100644 --- a/app.go +++ b/app.go @@ -28,7 +28,10 @@ func initializeApp() (*cluster.ClusterManager, error) { rbac.InitRBAC() handlers.InitTemplates() - internal.LoadConfigFromEnv() + internal.LoadConfigFromFile(common.ConfigFilePath) + if common.ConfigFilePath == "" { + internal.LoadConfigFromEnv() + } return cluster.NewClusterManager() } diff --git a/charts/kite/templates/deployment.yaml b/charts/kite/templates/deployment.yaml index ec886482..7b414c20 100644 --- a/charts/kite/templates/deployment.yaml +++ b/charts/kite/templates/deployment.yaml @@ -73,6 +73,10 @@ spec: - name: KITE_BASE value: {{ .Values.basePath }} {{- end }} + {{- if .Values.config.enabled }} + - name: KITE_CONFIG_FILE + value: /etc/kite/config.yaml + {{- end }} {{- with .Values.extraEnvs }} {{- toYaml . | nindent 12 }} {{- end }} @@ -92,12 +96,25 @@ spec: {{- with .Values.volumeMounts }} {{- toYaml . | nindent 12 }} {{- end }} + {{- if .Values.config.enabled }} + - name: kite-config + mountPath: /etc/kite + readOnly: true + {{- end }} {{- if and (eq .Values.db.type "sqlite")}} - name: {{ include "kite.fullname" . }}-storage mountPath: {{ .Values.db.sqlite.persistence.mountPath }} {{- end }} volumes: - {{- if eq .Values.db.type "sqlite"}} + {{- if .Values.config.enabled }} + - name: kite-config + secret: + secretName: {{ .Values.config.existingSecret | default (printf "%s-config" (include "kite.fullname" .)) }} + items: + - key: config.yaml + path: config.yaml + {{- end }} + {{- if eq .Values.db.type "sqlite"}} - name: {{ include "kite.fullname" . }}-storage {{- if .Values.db.sqlite.persistence.pvc.enabled }} persistentVolumeClaim: diff --git a/charts/kite/templates/secret-config.yaml b/charts/kite/templates/secret-config.yaml new file mode 100644 index 00000000..91dfc4c6 --- /dev/null +++ b/charts/kite/templates/secret-config.yaml @@ -0,0 +1,16 @@ +{{- if and .Values.config.enabled (not .Values.config.existingSecret) }} +{{- $cfg := omit .Values.config "enabled" "existingSecret" }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "kite.fullname" . }}-config + namespace: {{ .Release.Namespace }} + labels: + {{- include "kite.labels" . | nindent 4 }} +type: Opaque +stringData: + config.yaml: | + {{- with $cfg }} + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/kite/templates/secret.yaml b/charts/kite/templates/secret.yaml index c1bcae96..11857384 100644 --- a/charts/kite/templates/secret.yaml +++ b/charts/kite/templates/secret.yaml @@ -18,8 +18,4 @@ data: DB_TYPE: {{ .Values.db.type | b64enc | quote }} DB_DSN: {{ .Values.db.dsn | b64enc | quote }} {{- end }} - {{- if .Values.superUser.create }} - KITE_USERNAME: {{ .Values.superUser.username | b64enc | quote }} - KITE_PASSWORD: {{ .Values.superUser.password | b64enc | quote }} - {{- end }} {{- end }} \ No newline at end of file diff --git a/charts/kite/values.yaml b/charts/kite/values.yaml index 904928b2..89e129f8 100644 --- a/charts/kite/values.yaml +++ b/charts/kite/values.yaml @@ -58,17 +58,6 @@ jwtSecret: "" # Ignored if using existingSecret encryptKey: "kite-default-encryption-key-change-in-production" -# Superuser configuration -# Used to create an initial superuser account on first startup -# If superUser.create is false, will be setup in landing page -# First install will create the superuser automatically -# Subsequent installs/update will not modify the user -# Ignored if using existingSecret -superUser: - create: false - username: "admin" - password: "" - # Secret handling # By default the chart will create a Kubernetes Secret containing sensitive values. secret: @@ -80,10 +69,6 @@ secret: # KITE_ENCRYPT_KEY # DB_TYPE supported values: sqlite, postgres, mysql (defaults to sqlite if not set) # DB_DSN (not required for sqlite) - # - # see superUser section for more details - # KITE_USERNAME (optional for superuser) - # KITE_PASSWORD (optional for superuser) # if set, the db.dsn and db.type values from the chart will be ignored. existingSecret: "" @@ -131,6 +116,90 @@ extraEnvs: # - name: "EXAMPLE_ENV" # value: "example_value" +# Application configuration from values +# When enabled, the specified sections become read-only in the UI. +# You can either provide an existing Secret containing a config.yaml key, +# or define the configuration inline below. +config: + enabled: false + # Name of an existing Secret containing a `config.yaml` key. + # When set, the inline config below is ignored. + # The user is responsible for creating and managing this Secret. + # Example: kubectl create secret generic kite-config --from-file=config.yaml=./my-config.yaml + existingSecret: "" + # --- Inline configuration (used when existingSecret is empty) --- + # Super user configuration (created if the user doesn't exist, password updated on restart) + # superUser: + # username: "admin" + # password: "change-me-in-production" + # Sensitive values support ${ENV_VAR} placeholders, expanded from environment + # variables at startup. Use extraEnvs to inject secrets from Kubernetes Secrets: + # + # extraEnvs: + # - name: PROD_KUBECONFIG + # valueFrom: + # secretKeyRef: + # name: my-cluster-secrets + # key: prod-kubeconfig + # - name: OAUTH_SECRET + # valueFrom: + # secretKeyRef: + # name: my-oauth-secrets + # key: google-client-secret + # + # Then reference them in the config below: + # clusters: + # - name: production + # config: "${PROD_KUBECONFIG}" + # oauth: + # - name: google + # clientSecret: "${OAUTH_SECRET}" + # + # Cluster configurations + # clusters: + # - name: production + # description: "Production cluster" + # config: | + # apiVersion: v1 + # kind: Config + # ... + # prometheusURL: "http://prometheus:9090" + # default: true + # - name: in-cluster + # inCluster: true + # + # OAuth provider configurations + # oauth: + # - name: google + # clientId: "xxx.apps.googleusercontent.com" + # clientSecret: "secret-value" + # issuer: "https://accounts.google.com" + # scopes: "openid,profile,email" + # enabled: true + # + # LDAP configuration + # ldap: + # enabled: true + # serverUrl: "ldap://ldap.example.com:389" + # bindDn: "cn=admin,dc=example,dc=com" + # bindPassword: "secret" + # userBaseDn: "ou=users,dc=example,dc=com" + # groupBaseDn: "ou=groups,dc=example,dc=com" + # + # RBAC configuration (roles and role mappings) + # rbac: + # roles: + # - name: admin + # description: "Full access" + # clusters: ["*"] + # namespaces: ["*"] + # resources: ["*"] + # verbs: ["*"] + # roleMapping: + # - name: admin + # users: ["alice"] + # oidcGroups: ["admins"] + # This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/ serviceAccount: # Specifies whether a service account should be created diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 313a93f6..92276457 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -94,6 +94,7 @@ export default defineConfig({ { text: "Prometheus Setup", link: "/config/prometheus-setup" }, { text: "Managed K8s Auth", link: "/config/managed-k8s-auth" }, { text: "Environment Variables", link: "/config/env" }, + { text: "Configuration File", link: "/config/config-file" }, { text: "Chart Values", link: "/config/chart-values" }, ], }, @@ -155,6 +156,7 @@ export default defineConfig({ { text: "Prometheus 设置", link: "/zh/config/prometheus-setup" }, { text: "托管 K8s 认证", link: "/zh/config/managed-k8s-auth" }, { text: "环境变量", link: "/zh/config/env" }, + { text: "配置文件", link: "/zh/config/config-file" }, { text: "Chart Values", link: "/zh/config/chart-values" }, ], }, diff --git a/docs/config/chart-values.md b/docs/config/chart-values.md index f6c921b8..3f905037 100644 --- a/docs/config/chart-values.md +++ b/docs/config/chart-values.md @@ -53,6 +53,25 @@ This document describes all available configuration options for the Kite Helm Ch | ----------- | ---------------------------------------- | ------- | | `extraEnvs` | List of additional environment variables | `[]` | +## Application Configuration + +Kite supports loading cluster, OAuth/LDAP, and RBAC configuration from a YAML config file. When enabled, managed sections become read-only in the UI. + +Available in Kite `v0.10.0` and later. + +See [Configuration File](./config-file) for the full config file format, usage examples, and reference. + +| Parameter | Description | Default | +| ----------------------- | ------------------------------------------------------------------------------ | ------- | +| `config.enabled` | Enable configuration file mode | `false` | +| `config.existingSecret` | Name of an existing Secret containing a `config.yaml` key. Recommended approach. | `""` | +| `config.superUser` | Inline super user configuration (created on first startup only) | `{}` | +| `config.clusters` | Inline cluster configurations (when no existingSecret) | `[]` | +| `config.oauth` | Inline OAuth provider configurations | `[]` | +| `config.ldap` | Inline LDAP configuration | `{}` | +| `config.rbac.roles` | Inline RBAC role definitions | `[]` | +| `config.rbac.roleMapping` | Inline RBAC role mappings | `[]` | + ## Service Account Configuration | Parameter | Description | Default | diff --git a/docs/config/config-file.md b/docs/config/config-file.md new file mode 100644 index 00000000..ea046de5 --- /dev/null +++ b/docs/config/config-file.md @@ -0,0 +1,301 @@ +# Configuration File + +Kite supports loading cluster, OAuth/LDAP, and RBAC configuration from a YAML file. When a section is configured this way, it becomes **read-only** in the UI — users can view the settings but cannot modify them through the dashboard. + +This is useful for GitOps workflows where configuration is version-controlled and applied via Helm. + +> Available in Kite `v0.10.0` and later. + +## How It Works + +1. Kite reads a YAML config file from the path specified by the `KITE_CONFIG_FILE` environment variable. +2. On every startup, the config is applied to the database, overwriting existing values for managed sections. +3. Sensitive values in the config file support `${ENV_VAR}` placeholder expansion from environment variables. +4. The UI automatically detects managed sections and displays them as read-only with an informational banner. +5. Write API endpoints for managed sections return `403 Forbidden`. + +## Config File Format + +```yaml +superUser: + username: "admin" + password: "change-me-in-production" + +clusters: + - name: production + description: "Production cluster" + config: | + apiVersion: v1 + kind: Config + clusters: + - cluster: + server: https://k8s.example.com + certificate-authority-data: LS0t... + name: production + contexts: + - context: + cluster: production + user: admin + name: production + current-context: production + users: + - name: admin + user: + token: eyJhb... + prometheusURL: "http://prometheus:9090" + default: true + - name: local + inCluster: true + +oauth: + - name: google + clientId: "xxx.apps.googleusercontent.com" + clientSecret: "client-secret-value" + issuer: "https://accounts.google.com" + scopes: "openid,profile,email" + usernameClaim: "email" + groupsClaim: "groups" + enabled: true + +ldap: + enabled: true + serverUrl: "ldaps://ldap.example.com:636" + bindDn: "cn=svc-kite,ou=services,dc=example,dc=com" + bindPassword: "bind-password" + userBaseDn: "ou=users,dc=example,dc=com" + userFilter: "(uid=%s)" + usernameAttribute: "uid" + displayNameAttribute: "cn" + groupBaseDn: "ou=groups,dc=example,dc=com" + groupFilter: "(member=%s)" + groupNameAttribute: "cn" + +rbac: + roles: + - name: admin + description: "Administrator role with full access" + clusters: ["*"] + namespaces: ["*"] + resources: ["*"] + verbs: ["*"] + - name: viewer + description: "Read-only access" + clusters: ["*"] + namespaces: ["*"] + resources: ["*"] + verbs: ["get", "log"] + - name: dev-team + description: "Development team access" + clusters: ["dev-*", "staging-*"] + namespaces: ["*"] + resources: ["*"] + verbs: ["get", "create", "update", "delete", "log"] + roleMapping: + - name: admin + users: ["alice", "bob"] + oidcGroups: ["platform-admins"] + - name: viewer + users: ["*"] + - name: dev-team + oidcGroups: ["developers"] +``` + +You only need to include the sections you want to manage. For example, if you only want to manage clusters via config file, just include the `clusters` section — OAuth, LDAP, and RBAC will remain editable through the UI. + +## Using with Helm + +There are two approaches to provide the config file via Helm. + +### Approach 1: Existing Secret (Recommended) + +Create and manage your own Kubernetes Secret containing the config file. This is the recommended approach for production since it keeps sensitive values (kubeconfigs, OAuth secrets, LDAP passwords) in a proper Secret. + +**Step 1:** Create your `config.yaml` file locally. + +**Step 2:** Create the Kubernetes Secret: + +```bash +kubectl create secret generic kite-config \ + --from-file=config.yaml=./config.yaml \ + -n +``` + +**Step 3:** Reference it in your Helm values: + +```yaml +config: + enabled: true + existingSecret: "kite-config" +``` + +::: tip +You can manage the Secret with tools like [External Secrets Operator](https://external-secrets.io/), [Sealed Secrets](https://sealed-secrets.netlify.app/), or any GitOps-compatible secret management solution. +::: + +### Approach 2: Inline Configuration + +Define the configuration directly in Helm values. A Secret is automatically generated. Suitable for simple setups or development environments. + +```yaml +config: + enabled: true + superUser: + username: "admin" + password: "change-me-in-production" + clusters: + - name: local + inCluster: true + default: true + rbac: + roles: + - name: admin + clusters: ["*"] + namespaces: ["*"] + resources: ["*"] + verbs: ["*"] + - name: viewer + clusters: ["*"] + namespaces: ["*"] + resources: ["*"] + verbs: ["get", "log"] + roleMapping: + - name: admin + oidcGroups: ["admins"] + - name: viewer + users: ["*"] +``` + +#### Using Environment Variable Placeholders + +For sensitive values in inline configuration, use `${ENV_VAR}` placeholders. These are expanded from environment variables at startup. Combine with `extraEnvs` to inject secrets from Kubernetes Secrets: + +```yaml +# Reference external secrets as environment variables +extraEnvs: + - name: PROD_KUBECONFIG + valueFrom: + secretKeyRef: + name: my-cluster-secrets + key: prod-kubeconfig + - name: OAUTH_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: my-oauth-secrets + key: google-client-secret + - name: LDAP_BIND_PWD + valueFrom: + secretKeyRef: + name: my-ldap-secrets + key: bind-password + +# Use placeholders in config +config: + enabled: true + clusters: + - name: production + config: "${PROD_KUBECONFIG}" + prometheusURL: "http://prometheus:9090" + default: true + - name: local + inCluster: true + oauth: + - name: google + clientId: "xxx.apps.googleusercontent.com" + clientSecret: "${OAUTH_CLIENT_SECRET}" + issuer: "https://accounts.google.com" + enabled: true + ldap: + enabled: true + serverUrl: "ldaps://ldap.example.com:636" + bindDn: "cn=admin,dc=example,dc=com" + bindPassword: "${LDAP_BIND_PWD}" + userBaseDn: "ou=users,dc=example,dc=com" + groupBaseDn: "ou=groups,dc=example,dc=com" +``` + +## Config Values Reference + +### Super User Configuration + +| Field | Type | Description | Required | +| ---------- | ------ | ------------------------------------- | -------- | +| `username` | string | Super user username | Yes | +| `password` | string | Super user password | Yes | + +The super user is created on first startup if it doesn't exist. On subsequent startups, the password is updated to match the config file. + +### Cluster Configuration + +| Field | Type | Description | Required | +| --------------- | ------- | ------------------------------- | -------- | +| `name` | string | Unique cluster name | Yes | +| `description` | string | Cluster description | No | +| `config` | string | Kubeconfig YAML content | No * | +| `prometheusURL` | string | Prometheus endpoint URL | No | +| `inCluster` | boolean | Use in-cluster service account | No | +| `default` | boolean | Set as default cluster | No | + +\* Either `config` or `inCluster: true` must be provided. + +### OAuth Provider Configuration + +| Field | Type | Description | Required | +| --------------- | ------- | ------------------------------------------- | -------- | +| `name` | string | Provider name (e.g., "google", "github") | Yes | +| `clientId` | string | OAuth client ID | Yes | +| `clientSecret` | string | OAuth client secret | Yes | +| `issuer` | string | OIDC issuer URL (enables auto-discovery) | No | +| `authUrl` | string | Authorization endpoint (if no issuer) | No | +| `tokenUrl` | string | Token endpoint (if no issuer) | No | +| `userInfoUrl` | string | User info endpoint (if no issuer) | No | +| `scopes` | string | Comma-separated scopes | No | +| `usernameClaim` | string | JWT claim for username | No | +| `groupsClaim` | string | JWT claim for groups | No | +| `allowedGroups` | string | Comma-separated list of allowed groups | No | +| `enabled` | boolean | Enable this provider | No | + +### LDAP Configuration + +| Field | Type | Description | Default | +| ---------------------- | ------- | ------------------------------------ | -------------- | +| `enabled` | boolean | Enable LDAP authentication | `false` | +| `serverUrl` | string | LDAP server URL | | +| `useStartTLS` | boolean | Use StartTLS for `ldap://` | `false` | +| `bindDn` | string | Service account DN | | +| `bindPassword` | string | Service account password | | +| `userBaseDn` | string | Base DN for user searches | | +| `userFilter` | string | User search filter | `(uid=%s)` | +| `usernameAttribute` | string | Username attribute | `uid` | +| `displayNameAttribute` | string | Display name attribute | `cn` | +| `groupBaseDn` | string | Base DN for group searches | | +| `groupFilter` | string | Group membership filter | `(member=%s)` | +| `groupNameAttribute` | string | Group name attribute | `cn` | + +### RBAC Configuration + +#### Role + +| Field | Type | Description | Required | +| ------------- | -------- | ---------------------------------------- | -------- | +| `name` | string | Role name | Yes | +| `description` | string | Role description | No | +| `clusters` | string[] | Cluster patterns (`*`, `prod-*`, `!dev`) | Yes | +| `namespaces` | string[] | Namespace patterns | Yes | +| `resources` | string[] | Resource types (`pods`, `*`, etc.) | Yes | +| `verbs` | string[] | Allowed verbs (`get`, `create`, `*`) | Yes | + +#### Role Mapping + +| Field | Type | Description | Required | +| ------------ | -------- | ------------------------------- | -------- | +| `name` | string | Role name to map to | Yes | +| `users` | string[] | Usernames (`*` for all users) | No | +| `oidcGroups` | string[] | OIDC/LDAP group names | No | + +## Behavior Notes + +- **Startup override**: Config is re-applied on every startup. Changes made to managed sections through the database will be overwritten. +- **Super user**: Created on first startup if it doesn't exist. On subsequent startups, the password is updated to match the config file. +- **Setup wizard skip**: When clusters are configured via config file, the initialization wizard is automatically skipped. +- **Partial management**: You can manage some sections via config file and leave others for UI management. Only sections present in the config file become read-only. +- **System roles**: The default `admin` and `viewer` system roles are updated (not duplicated) when defined in the RBAC config. diff --git a/docs/config/env.md b/docs/config/env.md index 86f6b955..4a306461 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -2,9 +2,10 @@ Kite supports several environment variables by default to change the default values of some configuration items. -- **KITE_USERNAME**: Set the initial administrator username. Can be created through the initialization page -- **KITE_PASSWORD**: Set the initial administrator password. Can be created through the initialization page -- **KUBECONFIG**: Kubernetes configuration file path, default value is `~/.kube/config`. When kite has no configured clusters, it will discover and import clusters from this path by default. Can import clusters through the initialization page +- **KITE_CONFIG_FILE**: Path to the configuration file. Available in Kite `v0.10.0` and later. When set, Kite loads cluster, OAuth, LDAP, RBAC, and super user settings from this file. See [Configuration File](/config/config-file) for details. +- **KITE_USERNAME**: Legacy environment variable for the initial administrator username. It is only used for env-to-DB migration when `KITE_CONFIG_FILE` is not set. +- **KITE_PASSWORD**: Legacy environment variable for the initial administrator password. It is only used for env-to-DB migration when `KITE_CONFIG_FILE` is not set. +- **KUBECONFIG**: Legacy kubeconfig environment variable used to import clusters when `KITE_CONFIG_FILE` is not set. - **ANONYMOUS_USER_ENABLED**: Enable anonymous user access, default value is `false`. When enabled, all access will no longer require authentication and will have the highest permissions by default. - **JWT_SECRET**: Secret key used for signing and verifying JWT diff --git a/docs/package.json b/docs/package.json index de1e4536..fac0a367 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,7 +1,7 @@ { "scripts": { "docs:dev": "vitepress dev .", - "docs:build": "vitepress build .", + "docs:build": "git fetch --unshallow || vitepress build .", "docs:preview": "vitepress preview ." }, "devDependencies": { diff --git a/docs/zh/config/config-file.md b/docs/zh/config/config-file.md new file mode 100644 index 00000000..1cad4cca --- /dev/null +++ b/docs/zh/config/config-file.md @@ -0,0 +1,301 @@ +# 配置文件 + +Kite 支持通过 YAML 配置文件来管理集群、OAuth/LDAP 和 RBAC 配置。通过配置文件管理的部分在 UI 中将变为**只读**——用户可以查看配置但无法通过界面修改。 + +这对于 GitOps 工作流非常有用,配置可以版本控制并通过 Helm 部署。 + +> 该功能仅适用于 Kite `v0.10.0` 及以上版本。 + +## 工作原理 + +1. Kite 从 `KITE_CONFIG_FILE` 环境变量指定的路径读取 YAML 配置文件。 +2. 每次启动时,配置都会应用到数据库,覆盖已管理部分的现有值。 +3. 配置文件中的敏感值支持 `${ENV_VAR}` 占位符,从环境变量中展开。 +4. UI 自动检测已管理的部分,以只读模式显示并附带提示横幅。 +5. 已管理部分的写入 API 返回 `403 Forbidden`。 + +## 配置文件格式 + +```yaml +superUser: + username: "admin" + password: "change-me-in-production" + +clusters: + - name: production + description: "生产集群" + config: | + apiVersion: v1 + kind: Config + clusters: + - cluster: + server: https://k8s.example.com + certificate-authority-data: LS0t... + name: production + contexts: + - context: + cluster: production + user: admin + name: production + current-context: production + users: + - name: admin + user: + token: eyJhb... + prometheusURL: "http://prometheus:9090" + default: true + - name: local + inCluster: true + +oauth: + - name: google + clientId: "xxx.apps.googleusercontent.com" + clientSecret: "client-secret-value" + issuer: "https://accounts.google.com" + scopes: "openid,profile,email" + usernameClaim: "email" + groupsClaim: "groups" + enabled: true + +ldap: + enabled: true + serverUrl: "ldaps://ldap.example.com:636" + bindDn: "cn=svc-kite,ou=services,dc=example,dc=com" + bindPassword: "bind-password" + userBaseDn: "ou=users,dc=example,dc=com" + userFilter: "(uid=%s)" + usernameAttribute: "uid" + displayNameAttribute: "cn" + groupBaseDn: "ou=groups,dc=example,dc=com" + groupFilter: "(member=%s)" + groupNameAttribute: "cn" + +rbac: + roles: + - name: admin + description: "管理员,完全访问权限" + clusters: ["*"] + namespaces: ["*"] + resources: ["*"] + verbs: ["*"] + - name: viewer + description: "只读访问" + clusters: ["*"] + namespaces: ["*"] + resources: ["*"] + verbs: ["get", "log"] + - name: dev-team + description: "开发团队权限" + clusters: ["dev-*", "staging-*"] + namespaces: ["*"] + resources: ["*"] + verbs: ["get", "create", "update", "delete", "log"] + roleMapping: + - name: admin + users: ["alice", "bob"] + oidcGroups: ["platform-admins"] + - name: viewer + users: ["*"] + - name: dev-team + oidcGroups: ["developers"] +``` + +你只需包含想要管理的部分。例如,如果只想通过配置文件管理集群,只需包含 `clusters` 部分——OAuth、LDAP 和 RBAC 仍然可以通过 UI 编辑。 + +## 通过 Helm 使用 + +有两种方式通过 Helm 提供配置文件。 + +### 方式一:使用已有的 Secret(推荐) + +创建并管理你自己的 Kubernetes Secret。这是生产环境推荐的方式,因为可以妥善保管敏感值(kubeconfig、OAuth 密钥、LDAP 密码)。 + +**步骤 1:** 在本地创建 `config.yaml` 文件。 + +**步骤 2:** 创建 Kubernetes Secret: + +```bash +kubectl create secret generic kite-config \ + --from-file=config.yaml=./config.yaml \ + -n +``` + +**步骤 3:** 在 Helm values 中引用: + +```yaml +config: + enabled: true + existingSecret: "kite-config" +``` + +::: tip +你可以使用 [External Secrets Operator](https://external-secrets.io/)、[Sealed Secrets](https://sealed-secrets.netlify.app/) 或任何 GitOps 兼容的密钥管理方案来管理该 Secret。 +::: + +### 方式二:内联配置 + +直接在 Helm values 中定义配置,会自动生成 Secret。适合简单场景或开发环境。 + +```yaml +config: + enabled: true + superUser: + username: "admin" + password: "change-me-in-production" + clusters: + - name: local + inCluster: true + default: true + rbac: + roles: + - name: admin + clusters: ["*"] + namespaces: ["*"] + resources: ["*"] + verbs: ["*"] + - name: viewer + clusters: ["*"] + namespaces: ["*"] + resources: ["*"] + verbs: ["get", "log"] + roleMapping: + - name: admin + oidcGroups: ["admins"] + - name: viewer + users: ["*"] +``` + +#### 使用环境变量占位符 + +对于内联配置中的敏感值,可以使用 `${ENV_VAR}` 占位符,启动时会从环境变量中展开。配合 `extraEnvs` 从 Kubernetes Secret 注入: + +```yaml +# 从外部 Secret 引用环境变量 +extraEnvs: + - name: PROD_KUBECONFIG + valueFrom: + secretKeyRef: + name: my-cluster-secrets + key: prod-kubeconfig + - name: OAUTH_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: my-oauth-secrets + key: google-client-secret + - name: LDAP_BIND_PWD + valueFrom: + secretKeyRef: + name: my-ldap-secrets + key: bind-password + +# 在配置中使用占位符 +config: + enabled: true + clusters: + - name: production + config: "${PROD_KUBECONFIG}" + prometheusURL: "http://prometheus:9090" + default: true + - name: local + inCluster: true + oauth: + - name: google + clientId: "xxx.apps.googleusercontent.com" + clientSecret: "${OAUTH_CLIENT_SECRET}" + issuer: "https://accounts.google.com" + enabled: true + ldap: + enabled: true + serverUrl: "ldaps://ldap.example.com:636" + bindDn: "cn=admin,dc=example,dc=com" + bindPassword: "${LDAP_BIND_PWD}" + userBaseDn: "ou=users,dc=example,dc=com" + groupBaseDn: "ou=groups,dc=example,dc=com" +``` + +## 配置值参考 + +### 超级用户配置 + +| 字段 | 类型 | 描述 | 必填 | +| ---------- | ------ | -------------- | ---- | +| `username` | string | 超级用户用户名 | 是 | +| `password` | string | 超级用户密码 | 是 | + +超级用户仅在首次启动且数据库中没有该用户时创建。后续启动会更新该用户的密码(如果配置文件中的密码发生了变化)。 + +### 集群配置 + +| 字段 | 类型 | 描述 | 必填 | +| --------------- | ------- | ----------------------- | ------ | +| `name` | string | 唯一集群名称 | 是 | +| `description` | string | 集群描述 | 否 | +| `config` | string | Kubeconfig YAML 内容 | 否 * | +| `prometheusURL` | string | Prometheus 端点 URL | 否 | +| `inCluster` | boolean | 使用集群内服务账号 | 否 | +| `default` | boolean | 设为默认集群 | 否 | + +\* 必须提供 `config` 或 `inCluster: true`。 + +### OAuth 提供者配置 + +| 字段 | 类型 | 描述 | 必填 | +| --------------- | ------- | --------------------------------------- | ---- | +| `name` | string | 提供者名称(如 "google"、"github") | 是 | +| `clientId` | string | OAuth Client ID | 是 | +| `clientSecret` | string | OAuth Client Secret | 是 | +| `issuer` | string | OIDC Issuer URL(启用自动发现) | 否 | +| `authUrl` | string | 授权端点(无 issuer 时) | 否 | +| `tokenUrl` | string | Token 端点(无 issuer 时) | 否 | +| `userInfoUrl` | string | 用户信息端点(无 issuer 时) | 否 | +| `scopes` | string | 逗号分隔的 scopes | 否 | +| `usernameClaim` | string | 用于用户名的 JWT claim | 否 | +| `groupsClaim` | string | 用于组的 JWT claim | 否 | +| `allowedGroups` | string | 逗号分隔的允许组列表 | 否 | +| `enabled` | boolean | 启用此提供者 | 否 | + +### LDAP 配置 + +| 字段 | 类型 | 描述 | 默认值 | +| ---------------------- | ------- | ------------------- | -------------- | +| `enabled` | boolean | 启用 LDAP 认证 | `false` | +| `serverUrl` | string | LDAP 服务器 URL | | +| `useStartTLS` | boolean | 对 `ldap://` 使用 StartTLS | `false` | +| `bindDn` | string | 服务账号 DN | | +| `bindPassword` | string | 服务账号密码 | | +| `userBaseDn` | string | 用户搜索 Base DN | | +| `userFilter` | string | 用户搜索过滤器 | `(uid=%s)` | +| `usernameAttribute` | string | 用户名属性 | `uid` | +| `displayNameAttribute` | string | 显示名属性 | `cn` | +| `groupBaseDn` | string | 组搜索 Base DN | | +| `groupFilter` | string | 组成员过滤器 | `(member=%s)` | +| `groupNameAttribute` | string | 组名属性 | `cn` | + +### RBAC 配置 + +#### 角色 + +| 字段 | 类型 | 描述 | 必填 | +| ------------- | -------- | ---------------------------------------- | ---- | +| `name` | string | 角色名称 | 是 | +| `description` | string | 角色描述 | 否 | +| `clusters` | string[] | 集群匹配模式(`*`、`prod-*`、`!dev`) | 是 | +| `namespaces` | string[] | 命名空间匹配模式 | 是 | +| `resources` | string[] | 资源类型(`pods`、`*` 等) | 是 | +| `verbs` | string[] | 允许的操作(`get`、`create`、`*`) | 是 | + +#### 角色映射 + +| 字段 | 类型 | 描述 | 必填 | +| ------------ | -------- | ----------------------------- | ---- | +| `name` | string | 要映射的角色名称 | 是 | +| `users` | string[] | 用户名(`*` 表示所有用户) | 否 | +| `oidcGroups` | string[] | OIDC/LDAP 组名 | 否 | + +## 行为说明 + +- **启动覆盖**:每次启动时都会重新应用配置。通过数据库对已管理部分的修改会被覆盖。 +- **超级用户**:首次启动时如果该用户不存在则创建。后续启动会同步更新密码。 +- **跳过初始化向导**:当集群通过配置文件配置时,初始化向导会自动跳过。 +- **部分管理**:你可以通过配置文件管理某些部分,其余部分留给 UI 管理。只有配置文件中存在的部分才会变为只读。 +- **系统角色**:在 RBAC 配置中定义默认的 `admin` 和 `viewer` 系统角色时,会更新现有角色(不会重复创建)。 diff --git a/docs/zh/config/env.md b/docs/zh/config/env.md index eddd4cae..cd1fd74f 100644 --- a/docs/zh/config/env.md +++ b/docs/zh/config/env.md @@ -2,9 +2,10 @@ Kite 默认支持一些环境变量,来改变一些配置项的默认值。 -- **KITE_USERNAME**:设置初始管理员用户名。可通过初始化页面中创建 -- **KITE_PASSWORD**:设置初始管理员密码。可通过初始化页面中创建 -- **KUBECONFIG**:Kubernetes 配置文件路径, 默认值为 `~/.kube/config`,当 kite 没有配置集群时默认从此路径发现并导入集群到 Kite。可通过初始化页面中导入集群 +- **KITE_CONFIG_FILE**:配置文件路径。该功能仅适用于 Kite `v0.10.0` 及以上版本。设置后,Kite 从该文件加载集群、OAuth、LDAP、RBAC 和超级用户设置。详见[配置文件](/zh/config/config-file)。 +- **KITE_USERNAME**:兼容旧配置的超级用户名环境变量。仅在未设置 `KITE_CONFIG_FILE` 时,用于环境变量到数据库配置的迁移。 +- **KITE_PASSWORD**:兼容旧配置的超级用户密码环境变量。仅在未设置 `KITE_CONFIG_FILE` 时,用于环境变量到数据库配置的迁移。 +- **KUBECONFIG**:兼容旧配置的 kubeconfig 环境变量。仅在未设置 `KITE_CONFIG_FILE` 时读取并导入集群配置。 - **ANONYMOUS_USER_ENABLED**:启用匿名用户访问,默认值为 `false`,当启用后所有访问将不再需要身份验证,并且默认拥有最高权限。 - **JWT_SECRET**:用于签名和验证 JWT 的密钥 diff --git a/internal/config.go b/internal/config.go new file mode 100644 index 00000000..3776b147 --- /dev/null +++ b/internal/config.go @@ -0,0 +1,375 @@ +package internal + +import ( + "os" + "strings" + + "github.com/zxh326/kite/pkg/common" + "github.com/zxh326/kite/pkg/model" + "github.com/zxh326/kite/pkg/rbac" + "github.com/zxh326/kite/pkg/utils" + "gopkg.in/yaml.v3" + "gorm.io/gorm" + "k8s.io/klog/v2" +) + +// KiteConfig represents the external configuration file structure. +type KiteConfig struct { + SuperUser *SuperUserConfig `yaml:"superUser"` + Clusters []ClusterConfig `yaml:"clusters"` + OAuth []OAuthConfig `yaml:"oauth"` + LDAP *LDAPConfig `yaml:"ldap"` + RBAC *RBACConfig `yaml:"rbac"` +} + +type SuperUserConfig struct { + Username string `yaml:"username"` + Password string `yaml:"password"` +} + +type ClusterConfig struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Config string `yaml:"config"` + PrometheusURL string `yaml:"prometheusURL"` + InCluster bool `yaml:"inCluster"` + Default bool `yaml:"default"` +} + +type OAuthConfig struct { + Name string `yaml:"name"` + ClientID string `yaml:"clientId"` + ClientSecret string `yaml:"clientSecret"` + AuthURL string `yaml:"authUrl"` + TokenURL string `yaml:"tokenUrl"` + UserInfoURL string `yaml:"userInfoUrl"` + Scopes string `yaml:"scopes"` + Issuer string `yaml:"issuer"` + Enabled *bool `yaml:"enabled"` + UsernameClaim string `yaml:"usernameClaim"` + GroupsClaim string `yaml:"groupsClaim"` + AllowedGroups string `yaml:"allowedGroups"` +} + +type LDAPConfig struct { + Enabled bool `yaml:"enabled"` + ServerURL string `yaml:"serverUrl"` + UseStartTLS bool `yaml:"useStartTLS"` + BindDN string `yaml:"bindDn"` + BindPassword string `yaml:"bindPassword"` + UserBaseDN string `yaml:"userBaseDn"` + UserFilter string `yaml:"userFilter"` + UsernameAttribute string `yaml:"usernameAttribute"` + DisplayNameAttribute string `yaml:"displayNameAttribute"` + GroupBaseDN string `yaml:"groupBaseDn"` + GroupFilter string `yaml:"groupFilter"` + GroupNameAttribute string `yaml:"groupNameAttribute"` +} + +type RBACConfig struct { + Roles []RoleConfig `yaml:"roles"` + RoleMapping []RoleMappingConfig `yaml:"roleMapping"` +} + +type RoleConfig struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Clusters []string `yaml:"clusters"` + Namespaces []string `yaml:"namespaces"` + Resources []string `yaml:"resources"` + Verbs []string `yaml:"verbs"` +} + +type RoleMappingConfig struct { + Name string `yaml:"name"` + Users []string `yaml:"users"` + OIDCGroups []string `yaml:"oidcGroups"` +} + +// LoadConfigFromFile loads and applies configuration from the given file path. +// Sensitive values can use ${ENV_VAR} placeholders which are expanded from environment variables. +func LoadConfigFromFile(path string) { + if path == "" { + return + } + + data, err := os.ReadFile(path) + if err != nil { + klog.Warningf("Failed to read config file %s: %v", path, err) + return + } + + // Expand ${ENV_VAR} placeholders from environment + expanded := os.ExpandEnv(string(data)) + + var cfg KiteConfig + if err := yaml.Unmarshal([]byte(expanded), &cfg); err != nil { + klog.Fatalf("Failed to parse config file %s: %v", path, err) + } + + klog.Infof("Loading configuration from file: %s", path) + + if cfg.Clusters != nil { + if err := applyClusters(cfg.Clusters); err != nil { + klog.Errorf("Failed to apply cluster config: %v", err) + } else { + common.ManagedSections["clusters"] = true + klog.Infof("Applied %d cluster(s) from config file", len(cfg.Clusters)) + } + } + + if cfg.OAuth != nil { + if err := applyOAuth(cfg.OAuth); err != nil { + klog.Errorf("Failed to apply OAuth config: %v", err) + } else { + common.ManagedSections["oauth"] = true + klog.Infof("Applied %d OAuth provider(s) from config file", len(cfg.OAuth)) + } + } + + if cfg.LDAP != nil { + if err := applyLDAP(cfg.LDAP); err != nil { + klog.Errorf("Failed to apply LDAP config: %v", err) + } else { + common.ManagedSections["ldap"] = true + klog.Info("Applied LDAP settings from config file") + } + } + + if cfg.RBAC != nil { + if err := applyRBAC(cfg.RBAC); err != nil { + klog.Errorf("Failed to apply RBAC config: %v", err) + } else { + common.ManagedSections["rbac"] = true + klog.Infof("Applied RBAC config from config file (%d roles, %d mappings)", + len(cfg.RBAC.Roles), len(cfg.RBAC.RoleMapping)) + } + } + + // Apply super user AFTER RBAC so the admin role assignment + // is not wiped by applyRBAC's "delete all assignments" step. + if cfg.SuperUser != nil && cfg.SuperUser.Username != "" && cfg.SuperUser.Password != "" { + common.ManagedSections["superUser"] = true + if err := applySuperUser(cfg.SuperUser); err != nil { + klog.Errorf("Failed to apply super user config: %v", err) + } else { + klog.Infof("Applied super user %q from config file", cfg.SuperUser.Username) + } + } +} + +func applySuperUser(cfg *SuperUserConfig) error { + existing, err := model.GetUserByUsername(cfg.Username) + if err == nil { + // User exists — update password and ensure admin role + hash, err := utils.HashPassword(cfg.Password) + if err != nil { + return err + } + existing.Password = hash + if err := model.DB.Save(existing).Error; err != nil { + return err + } + if err := ensureAdminRole(cfg.Username); err != nil { + return err + } + rbac.TriggerSync() + return nil + } + + // User does not exist — create + u := &model.User{ + Username: cfg.Username, + Password: cfg.Password, + } + if err := model.AddSuperUser(u); err != nil { + return err + } + rbac.TriggerSync() + return nil +} + +// ensureAdminRole ensures the user has an admin role assignment (idempotent). +func ensureAdminRole(username string) error { + adminRole, err := model.GetRoleByName("admin") + if err != nil { + return err + } + var count int64 + model.DB.Model(&model.RoleAssignment{}). + Where("role_id = ? AND subject_type = ? AND subject = ?", + adminRole.ID, model.SubjectTypeUser, username). + Count(&count) + if count > 0 { + return nil + } + return model.DB.Create(&model.RoleAssignment{ + RoleID: adminRole.ID, + SubjectType: model.SubjectTypeUser, + Subject: username, + }).Error +} + +func applyClusters(clusters []ClusterConfig) error { + return model.DB.Transaction(func(tx *gorm.DB) error { + // Delete all existing clusters, then insert from config + if err := tx.Where("1 = 1").Delete(&model.Cluster{}).Error; err != nil { + return err + } + + for _, c := range clusters { + cluster := &model.Cluster{ + Name: c.Name, + Description: c.Description, + Config: model.SecretString(c.Config), + PrometheusURL: c.PrometheusURL, + InCluster: c.InCluster, + IsDefault: c.Default, + Enable: true, + } + if err := tx.Create(cluster).Error; err != nil { + return err + } + } + return nil + }) +} + +func applyOAuth(providers []OAuthConfig) error { + return model.DB.Transaction(func(tx *gorm.DB) error { + // Delete all existing OAuth providers, then insert from config + if err := tx.Where("1 = 1").Delete(&model.OAuthProvider{}).Error; err != nil { + return err + } + + for _, p := range providers { + enabled := true + if p.Enabled != nil { + enabled = *p.Enabled + } + scopes := p.Scopes + if scopes == "" { + scopes = "openid,profile,email" + } + provider := &model.OAuthProvider{ + Name: model.LowerCaseString(strings.TrimSpace(p.Name)), + ClientID: p.ClientID, + ClientSecret: model.SecretString(p.ClientSecret), + AuthURL: p.AuthURL, + TokenURL: p.TokenURL, + UserInfoURL: p.UserInfoURL, + Scopes: scopes, + Issuer: p.Issuer, + Enabled: enabled, + UsernameClaim: p.UsernameClaim, + GroupsClaim: p.GroupsClaim, + AllowedGroups: p.AllowedGroups, + } + if err := tx.Create(provider).Error; err != nil { + return err + } + } + return nil + }) +} + +func applyLDAP(cfg *LDAPConfig) error { + setting := &model.LDAPSetting{ + Enabled: cfg.Enabled, + ServerURL: cfg.ServerURL, + UseStartTLS: cfg.UseStartTLS, + BindDN: cfg.BindDN, + BindPassword: model.SecretString(cfg.BindPassword), + UserBaseDN: cfg.UserBaseDN, + UserFilter: cfg.UserFilter, + UsernameAttribute: cfg.UsernameAttribute, + DisplayNameAttribute: cfg.DisplayNameAttribute, + GroupBaseDN: cfg.GroupBaseDN, + GroupFilter: cfg.GroupFilter, + GroupNameAttribute: cfg.GroupNameAttribute, + } + + _, err := model.UpdateLDAPSetting(setting) + return err +} + +func applyRBAC(cfg *RBACConfig) error { + err := model.DB.Transaction(func(tx *gorm.DB) error { + // Delete all non-system roles and their assignments + if err := tx.Where("is_system = ?", false).Delete(&model.Role{}).Error; err != nil { + return err + } + + // Delete all role assignments (including system role assignments, they'll be re-created from config) + if err := tx.Where("1 = 1").Delete(&model.RoleAssignment{}).Error; err != nil { + return err + } + + // Upsert roles from config + for _, r := range cfg.Roles { + var existing model.Role + if err := tx.Where("name = ?", r.Name).First(&existing).Error; err == nil { + // Update existing role (likely a system role) + existing.Description = r.Description + existing.Clusters = r.Clusters + existing.Namespaces = r.Namespaces + existing.Resources = r.Resources + existing.Verbs = r.Verbs + if err := tx.Save(&existing).Error; err != nil { + return err + } + } else { + // Create new role + role := &model.Role{ + Name: r.Name, + Description: r.Description, + Clusters: r.Clusters, + Namespaces: r.Namespaces, + Resources: r.Resources, + Verbs: r.Verbs, + } + if err := tx.Create(role).Error; err != nil { + return err + } + } + } + + // Apply role mappings + for _, m := range cfg.RoleMapping { + var role model.Role + if err := tx.Where("name = ?", m.Name).First(&role).Error; err != nil { + klog.Warningf("Role %q not found for mapping, skipping", m.Name) + continue + } + for _, user := range m.Users { + assignment := &model.RoleAssignment{ + RoleID: role.ID, + SubjectType: model.SubjectTypeUser, + Subject: user, + } + if err := tx.Create(assignment).Error; err != nil { + return err + } + } + for _, group := range m.OIDCGroups { + assignment := &model.RoleAssignment{ + RoleID: role.ID, + SubjectType: model.SubjectTypeGroup, + Subject: group, + } + if err := tx.Create(assignment).Error; err != nil { + return err + } + } + } + + return nil + }) + if err != nil { + return err + } + + // Trigger RBAC sync to update in-memory cache (outside transaction) + rbac.TriggerSync() + return nil +} diff --git a/internal/config_test.go b/internal/config_test.go new file mode 100644 index 00000000..410b44ca --- /dev/null +++ b/internal/config_test.go @@ -0,0 +1,711 @@ +package internal + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/zxh326/kite/pkg/common" + "github.com/zxh326/kite/pkg/handlers" + "github.com/zxh326/kite/pkg/model" + "github.com/zxh326/kite/pkg/rbac" + "github.com/zxh326/kite/pkg/utils" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// setupTestDB initializes an in-memory SQLite database for testing. +// It returns a cleanup function that restores the original DB. +func setupTestDB(t *testing.T) { + t.Helper() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("failed to open test database: %v", err) + } + if err := db.Exec("PRAGMA foreign_keys = ON").Error; err != nil { + t.Fatalf("failed to enable foreign keys: %v", err) + } + + models := []any{ + model.User{}, + model.Cluster{}, + model.GeneralSetting{}, + model.LDAPSetting{}, + model.OAuthProvider{}, + model.Role{}, + model.RoleAssignment{}, + } + for _, m := range models { + if err := db.AutoMigrate(m); err != nil { + t.Fatalf("failed to migrate: %v", err) + } + } + + oldDB := model.DB + model.DB = db + t.Cleanup(func() { model.DB = oldDB }) +} + +// saveManagedSections saves and restores the global ManagedSections map. +func saveManagedSections(t *testing.T) { + t.Helper() + orig := common.ManagedSections + common.ManagedSections = map[string]bool{} + t.Cleanup(func() { common.ManagedSections = orig }) +} + +const testConfigYAML = `clusters: + - name: prod + description: "Production cluster" + config: | + apiVersion: v1 + kind: Config + clusters: + - cluster: + server: https://k8s.example.com + name: prod + prometheusURL: "http://prom:9090" + default: true + - name: local + inCluster: true + +oauth: + - name: google + clientId: "test-client-id" + clientSecret: "test-secret" + issuer: "https://accounts.google.com" + scopes: "openid,profile,email" + usernameClaim: "email" + enabled: true + +ldap: + enabled: true + serverUrl: "ldaps://ldap.example.com:636" + bindDn: "cn=admin,dc=example,dc=com" + bindPassword: "test-bind-password" + userBaseDn: "ou=users,dc=example,dc=com" + userFilter: "(uid=%s)" + groupBaseDn: "ou=groups,dc=example,dc=com" + groupFilter: "(member=%s)" + +rbac: + roles: + - name: admin + description: "Full access" + clusters: ["*"] + namespaces: ["*"] + resources: ["*"] + verbs: ["*"] + - name: viewer + description: "Read-only" + clusters: ["*"] + namespaces: ["*"] + resources: ["*"] + verbs: ["get", "log"] + - name: dev-team + description: "Dev team access" + clusters: ["dev-*", "staging-*"] + namespaces: ["*"] + resources: ["*"] + verbs: ["get", "create", "update", "delete", "log"] + roleMapping: + - name: admin + users: ["alice", "bob"] + oidcGroups: ["platform-admins"] + - name: viewer + users: ["*"] + - name: dev-team + oidcGroups: ["developers"] +` + +// TestLoadConfigFromFile_EndToEnd tests the full config file loading flow with a real database. +func TestLoadConfigFromFile_EndToEnd(t *testing.T) { //nolint:gocyclo // end-to-end test with multiple subtests + setupTestDB(t) + saveManagedSections(t) + + // Drain SyncNow so TriggerSync in applyRBAC doesn't block + oldSyncNow := rbac.SyncNow + rbac.SyncNow = make(chan struct{}, 10) + t.Cleanup(func() { rbac.SyncNow = oldSyncNow }) + + // Create system roles first (normally done by InitRBAC) + if err := model.InitDefaultRole(); err != nil { + t.Fatalf("InitDefaultRole: %v", err) + } + + // Write config file to temp dir + dir := t.TempDir() + configPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(configPath, []byte(testConfigYAML), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + // Load config + LoadConfigFromFile(configPath) + + // --- Verify managed sections --- + t.Run("ManagedSections", func(t *testing.T) { + for _, section := range []string{"clusters", "oauth", "ldap", "rbac"} { + if !common.IsSectionManaged(section) { + t.Errorf("expected section %q to be managed", section) + } + } + }) + + // --- Verify clusters --- + t.Run("Clusters", func(t *testing.T) { + clusters, err := model.ListClusters() + if err != nil { + t.Fatalf("ListClusters: %v", err) + } + if len(clusters) != 2 { + t.Fatalf("expected 2 clusters, got %d", len(clusters)) + } + + byName := map[string]*model.Cluster{} + for _, c := range clusters { + byName[c.Name] = c + } + + prod := byName["prod"] + if prod == nil { + t.Fatal("prod cluster not found") + } + if prod.PrometheusURL != "http://prom:9090" { + t.Errorf("prod prometheus URL = %q, want %q", prod.PrometheusURL, "http://prom:9090") + } + if !prod.IsDefault { + t.Error("prod should be default") + } + if prod.Description != "Production cluster" { + t.Errorf("prod description = %q", prod.Description) + } + + local := byName["local"] + if local == nil { + t.Fatal("local cluster not found") + } + if !local.InCluster { + t.Error("local should be inCluster") + } + }) + + // --- Verify OAuth --- + t.Run("OAuth", func(t *testing.T) { + providers, err := model.GetAllOAuthProviders() + if err != nil { + t.Fatalf("GetAllOAuthProviders: %v", err) + } + if len(providers) != 1 { + t.Fatalf("expected 1 OAuth provider, got %d", len(providers)) + } + p := providers[0] + if string(p.Name) != "google" { + t.Errorf("provider name = %q, want %q", p.Name, "google") + } + if p.ClientID != "test-client-id" { + t.Errorf("clientId = %q", p.ClientID) + } + if !p.Enabled { + t.Error("provider should be enabled") + } + if p.Issuer != "https://accounts.google.com" { + t.Errorf("issuer = %q", p.Issuer) + } + if p.UsernameClaim != "email" { + t.Errorf("usernameClaim = %q", p.UsernameClaim) + } + }) + + // --- Verify LDAP --- + t.Run("LDAP", func(t *testing.T) { + setting, err := model.GetLDAPSetting() + if err != nil { + t.Fatalf("GetLDAPSetting: %v", err) + } + if !setting.Enabled { + t.Error("LDAP should be enabled") + } + if setting.ServerURL != "ldaps://ldap.example.com:636" { + t.Errorf("serverUrl = %q", setting.ServerURL) + } + if setting.BindDN != "cn=admin,dc=example,dc=com" { + t.Errorf("bindDn = %q", setting.BindDN) + } + if setting.UserBaseDN != "ou=users,dc=example,dc=com" { + t.Errorf("userBaseDn = %q", setting.UserBaseDN) + } + if setting.GroupBaseDN != "ou=groups,dc=example,dc=com" { + t.Errorf("groupBaseDn = %q", setting.GroupBaseDN) + } + }) + + // --- Verify RBAC roles --- + t.Run("RBAC_Roles", func(t *testing.T) { + var roles []model.Role + if err := model.DB.Find(&roles).Error; err != nil { + t.Fatalf("Find roles: %v", err) + } + if len(roles) != 3 { + t.Fatalf("expected 3 roles, got %d", len(roles)) + } + byName := map[string]model.Role{} + for _, r := range roles { + byName[r.Name] = r + } + + admin := byName["admin"] + if admin.Description != "Full access" { + t.Errorf("admin description = %q", admin.Description) + } + if !admin.IsSystem { + t.Error("admin should still be system role") + } + + viewer := byName["viewer"] + if viewer.Description != "Read-only" { + t.Errorf("viewer description = %q", viewer.Description) + } + + devTeam := byName["dev-team"] + if devTeam.Description != "Dev team access" { + t.Errorf("dev-team description = %q", devTeam.Description) + } + }) + + // --- Verify RBAC role assignments --- + t.Run("RBAC_Assignments", func(t *testing.T) { + var assignments []model.RoleAssignment + if err := model.DB.Find(&assignments).Error; err != nil { + t.Fatalf("Find assignments: %v", err) + } + // admin: alice, bob (users) + platform-admins (group) = 3 + // viewer: * (user) = 1 + // dev-team: developers (group) = 1 + // Total = 5 + if len(assignments) != 5 { + t.Fatalf("expected 5 assignments, got %d", len(assignments)) + } + }) +} + +// TestLoadConfigFromFile_EnvExpansion tests ${ENV_VAR} placeholder expansion. +func TestLoadConfigFromFile_EnvExpansion(t *testing.T) { + setupTestDB(t) + saveManagedSections(t) + + t.Setenv("TEST_OAUTH_SECRET", "expanded-secret-value") + + configYAML := `oauth: + - name: test-provider + clientId: "my-client" + clientSecret: "${TEST_OAUTH_SECRET}" + issuer: "https://example.com" + enabled: true +` + + dir := t.TempDir() + configPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(configPath, []byte(configYAML), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + LoadConfigFromFile(configPath) + + providers, err := model.GetAllOAuthProviders() + if err != nil { + t.Fatalf("GetAllOAuthProviders: %v", err) + } + if len(providers) != 1 { + t.Fatalf("expected 1 provider, got %d", len(providers)) + } + if string(providers[0].ClientSecret) != "expanded-secret-value" { + t.Errorf("clientSecret = %q, want %q", providers[0].ClientSecret, "expanded-secret-value") + } +} + +// TestLoadConfigFromFile_PartialConfig tests that only configured sections become managed. +func TestLoadConfigFromFile_PartialConfig(t *testing.T) { + setupTestDB(t) + saveManagedSections(t) + + // Only clusters section + configYAML := `clusters: + - name: test-only + inCluster: true +` + dir := t.TempDir() + configPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(configPath, []byte(configYAML), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + LoadConfigFromFile(configPath) + + if !common.IsSectionManaged("clusters") { + t.Error("clusters should be managed") + } + for _, section := range []string{"oauth", "ldap", "rbac"} { + if common.IsSectionManaged(section) { + t.Errorf("section %q should NOT be managed", section) + } + } +} + +// TestLoadConfigFromFile_EmptyPath tests that empty path is a no-op. +func TestLoadConfigFromFile_EmptyPath(t *testing.T) { + saveManagedSections(t) + + LoadConfigFromFile("") + + for _, section := range []string{"clusters", "oauth", "ldap", "rbac"} { + if common.IsSectionManaged(section) { + t.Errorf("section %q should NOT be managed with empty path", section) + } + } +} + +// TestLoadConfigFromFile_InvalidFile tests handling of a non-existent file. +func TestLoadConfigFromFile_InvalidFile(t *testing.T) { + saveManagedSections(t) + + LoadConfigFromFile("/tmp/nonexistent-kite-config-test.yaml") + + for _, section := range []string{"clusters", "oauth", "ldap", "rbac"} { + if common.IsSectionManaged(section) { + t.Errorf("section %q should NOT be managed when file doesn't exist", section) + } + } +} + +// TestLoadConfigFromFile_StartupOverwrite tests that config file overwrites existing DB data. +func TestLoadConfigFromFile_StartupOverwrite(t *testing.T) { + setupTestDB(t) + saveManagedSections(t) + + // Pre-populate with an existing cluster + existing := &model.Cluster{ + Name: "old-cluster", + InCluster: true, + Enable: true, + } + if err := model.AddCluster(existing); err != nil { + t.Fatalf("AddCluster: %v", err) + } + + configYAML := `clusters: + - name: new-cluster + inCluster: true +` + dir := t.TempDir() + configPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(configPath, []byte(configYAML), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + LoadConfigFromFile(configPath) + + clusters, _ := model.ListClusters() + if len(clusters) != 1 { + t.Fatalf("expected 1 cluster after overwrite, got %d", len(clusters)) + } + if clusters[0].Name != "new-cluster" { + t.Errorf("cluster name = %q, want %q", clusters[0].Name, "new-cluster") + } +} + +// TestManagedSectionsEndpoint tests the GET /api/v1/managed-sections API endpoint. +func TestManagedSectionsEndpoint(t *testing.T) { + saveManagedSections(t) + common.ManagedSections["clusters"] = true + common.ManagedSections["oauth"] = true + + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/api/v1/managed-sections", handlers.GetManagedSections) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/managed-sections", nil) + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var result map[string]bool + if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !result["clusters"] { + t.Error("expected clusters=true in response") + } + if !result["oauth"] { + t.Error("expected oauth=true in response") + } + if result["ldap"] { + t.Error("ldap should not be in response") + } +} + +// TestInitCheckWithManagedClusters tests that InitCheck returns initialized=true +// when clusters are managed AND a user exists. +func TestInitCheckWithManagedClusters(t *testing.T) { + setupTestDB(t) + saveManagedSections(t) + common.ManagedSections["clusters"] = true + + // Drain SyncNow so TriggerSync doesn't block + oldSyncNow := rbac.SyncNow + rbac.SyncNow = make(chan struct{}, 10) + t.Cleanup(func() { rbac.SyncNow = oldSyncNow }) + + // Create a user so InitCheck considers step 1 (user) done + if err := model.InitDefaultRole(); err != nil { + t.Fatalf("InitDefaultRole: %v", err) + } + if err := model.AddSuperUser(&model.User{Username: "admin", Password: "pass"}); err != nil { + t.Fatalf("AddSuperUser: %v", err) + } + + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/api/v1/init_check", handlers.InitCheck) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/init_check", nil) + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var result map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if init, ok := result["initialized"].(bool); !ok || !init { + t.Errorf("initialized = %v, want true", result["initialized"]) + } + if step, ok := result["step"].(float64); !ok || step != 2 { + t.Errorf("step = %v, want 2", result["step"]) + } +} + +// TestInitCheckWithManagedClustersNoUsers tests that InitCheck returns initialized=false +// when clusters are managed but no user exists. +func TestInitCheckWithManagedClustersNoUsers(t *testing.T) { + setupTestDB(t) + saveManagedSections(t) + common.ManagedSections["clusters"] = true + + // Save and restore AnonymousUserEnabled + origAnon := common.AnonymousUserEnabled + common.AnonymousUserEnabled = false + t.Cleanup(func() { common.AnonymousUserEnabled = origAnon }) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/api/v1/init_check", handlers.InitCheck) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/init_check", nil) + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var result map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if init, ok := result["initialized"].(bool); !ok || init { + t.Errorf("initialized = %v, want false (no users)", result["initialized"]) + } + if step, ok := result["step"].(float64); !ok || step != 0 { + t.Errorf("step = %v, want 0 (no users)", result["step"]) + } +} + +// TestLoadConfigFromFile_SuperUser tests that superUser is created on first startup. +func TestLoadConfigFromFile_SuperUser(t *testing.T) { + setupTestDB(t) + saveManagedSections(t) + + // Drain SyncNow so TriggerSync doesn't block + oldSyncNow := rbac.SyncNow + rbac.SyncNow = make(chan struct{}, 10) + t.Cleanup(func() { rbac.SyncNow = oldSyncNow }) + + // Create system roles (needed for AddSuperUser -> AddRoleAssignment) + if err := model.InitDefaultRole(); err != nil { + t.Fatalf("InitDefaultRole: %v", err) + } + + configYAML := `superUser: + username: "testadmin" + password: "testpass123" +clusters: + - name: test + inCluster: true +` + dir := t.TempDir() + configPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(configPath, []byte(configYAML), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + LoadConfigFromFile(configPath) + + // Verify super user was created + uc, _ := model.CountUsers() + if uc != 1 { + t.Fatalf("expected 1 user, got %d", uc) + } + + user, err := model.GetUserByUsername("testadmin") + if err != nil { + t.Fatalf("GetUserByUsername: %v", err) + } + if user.Username != "testadmin" { + t.Errorf("username = %q, want %q", user.Username, "testadmin") + } +} + +// TestLoadConfigFromFile_SuperUserUpdatesPassword tests that superUser password is updated on restart. +func TestLoadConfigFromFile_SuperUserUpdatesPassword(t *testing.T) { + setupTestDB(t) + saveManagedSections(t) + + oldSyncNow := rbac.SyncNow + rbac.SyncNow = make(chan struct{}, 10) + t.Cleanup(func() { rbac.SyncNow = oldSyncNow }) + + if err := model.InitDefaultRole(); err != nil { + t.Fatalf("InitDefaultRole: %v", err) + } + + // First startup: create the super user + configYAML := `superUser: + username: "myadmin" + password: "old-password" +` + dir := t.TempDir() + configPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(configPath, []byte(configYAML), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + LoadConfigFromFile(configPath) + + // Second startup: update the password + configYAML2 := `superUser: + username: "myadmin" + password: "new-password" +` + if err := os.WriteFile(configPath, []byte(configYAML2), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + LoadConfigFromFile(configPath) + + // Should still be only 1 user + uc, _ := model.CountUsers() + if uc != 1 { + t.Fatalf("expected 1 user, got %d", uc) + } + + // Verify the password was updated (can login with new password) + user, err := model.GetUserByUsername("myadmin") + if err != nil { + t.Fatalf("GetUserByUsername: %v", err) + } + if !utils.CheckPasswordHash("new-password", user.Password) { + t.Error("password should have been updated to new-password") + } +} + +// TestLoadConfigFromFile_SuperUserWithRBAC tests that superUser retains admin role +// even when RBAC roleMapping doesn't explicitly include the superUser. +func TestLoadConfigFromFile_SuperUserWithRBAC(t *testing.T) { + setupTestDB(t) + saveManagedSections(t) + + oldSyncNow := rbac.SyncNow + rbac.SyncNow = make(chan struct{}, 10) + t.Cleanup(func() { rbac.SyncNow = oldSyncNow }) + + if err := model.InitDefaultRole(); err != nil { + t.Fatalf("InitDefaultRole: %v", err) + } + + // Config with superUser + RBAC, but roleMapping does NOT include the superUser + configYAML := `superUser: + username: "myadmin" + password: "mypass" +rbac: + roles: + - name: admin + description: "Full access" + clusters: ["*"] + namespaces: ["*"] + resources: ["*"] + verbs: ["*"] + - name: viewer + description: "Read-only" + clusters: ["*"] + namespaces: ["*"] + resources: ["*"] + verbs: ["get", "log"] + roleMapping: + - name: viewer + users: ["*"] +` + dir := t.TempDir() + configPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(configPath, []byte(configYAML), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + LoadConfigFromFile(configPath) + + // Verify the super user was created + user, err := model.GetUserByUsername("myadmin") + if err != nil { + t.Fatalf("GetUserByUsername: %v", err) + } + if user.Username != "myadmin" { + t.Errorf("username = %q", user.Username) + } + + // Verify the super user has the admin role assignment + // (superUser is applied AFTER RBAC, so it shouldn't be wiped) + adminRole, err := model.GetRoleByName("admin") + if err != nil { + t.Fatalf("GetRoleByName(admin): %v", err) + } + + var assignment model.RoleAssignment + err = model.DB.Where("role_id = ? AND subject_type = ? AND subject = ?", + adminRole.ID, model.SubjectTypeUser, "myadmin").First(&assignment).Error + if err != nil { + t.Fatalf("super user should have admin role assignment, but got: %v", err) + } + + // Simulate second startup: applyRBAC wipes all assignments, then applySuperUser + // must re-create the admin role assignment. + LoadConfigFromFile(configPath) + + var assignment2 model.RoleAssignment + err = model.DB.Where("role_id = ? AND subject_type = ? AND subject = ?", + adminRole.ID, model.SubjectTypeUser, "myadmin").First(&assignment2).Error + if err != nil { + t.Fatalf("after second startup, super user should still have admin role, but got: %v", err) + } +} diff --git a/internal/load.go b/internal/load.go index 9b14405c..05c42991 100644 --- a/internal/load.go +++ b/internal/load.go @@ -5,6 +5,7 @@ import ( "path/filepath" "github.com/zxh326/kite/pkg/cluster" + "github.com/zxh326/kite/pkg/common" "github.com/zxh326/kite/pkg/model" "github.com/zxh326/kite/pkg/rbac" "k8s.io/client-go/tools/clientcmd" @@ -67,13 +68,15 @@ func loadClusters() error { return nil } -// LoadConfigFromEnv loads configuration from environment variables. func LoadConfigFromEnv() { - if err := loadUser(); err != nil { - klog.Warningf("Failed to migrate env to db user: %v", err) + if !common.IsSectionManaged("superUser") { + if err := loadUser(); err != nil { + klog.Warningf("Failed to migrate env to db user: %v", err) + } } - - if err := loadClusters(); err != nil { - klog.Warningf("Failed to migrate env to db cluster: %v", err) + if !common.IsSectionManaged("clusters") { + if err := loadClusters(); err != nil { + klog.Warningf("Failed to migrate env to db cluster: %v", err) + } } } diff --git a/internal/load_test.go b/internal/load_test.go deleted file mode 100644 index 7f03d6c3..00000000 --- a/internal/load_test.go +++ /dev/null @@ -1,167 +0,0 @@ -package internal - -import ( - "errors" - "os" - "path/filepath" - "testing" - - "github.com/bytedance/mockey" - "github.com/zxh326/kite/pkg/cluster" - "github.com/zxh326/kite/pkg/model" - "github.com/zxh326/kite/pkg/rbac" - clientcmdapi "k8s.io/client-go/tools/clientcmd/api" -) - -func init() { - _ = os.Setenv("MOCKEY_CHECK_GCFLAGS", "false") -} - -func TestLoadUserCreatesSuperUser(t *testing.T) { - oldUsername := kiteUsername - oldPassword := kitePassword - oldSyncNow := rbac.SyncNow - defer func() { - kiteUsername = oldUsername - kitePassword = oldPassword - rbac.SyncNow = oldSyncNow - }() - - kiteUsername = "admin" - kitePassword = "secret" - rbac.SyncNow = make(chan struct{}, 1) - - countMock := mockey.Mock(model.CountUsers).Return(int64(0), nil).Build() - defer countMock.UnPatch() - - addMock := mockey.Mock(model.AddSuperUser).To(func(user *model.User) error { - if user.Username != "admin" || user.Password != "secret" { - t.Fatalf("unexpected user: %#v", user) - } - return nil - }).Build() - defer addMock.UnPatch() - - if err := loadUser(); err != nil { - t.Fatalf("loadUser() error = %v", err) - } - - select { - case <-rbac.SyncNow: - default: - t.Fatal("expected rbac sync signal") - } -} - -func TestLoadUserReturnsAddSuperUserError(t *testing.T) { - oldUsername := kiteUsername - oldPassword := kitePassword - oldSyncNow := rbac.SyncNow - defer func() { - kiteUsername = oldUsername - kitePassword = oldPassword - rbac.SyncNow = oldSyncNow - }() - - kiteUsername = "admin" - kitePassword = "secret" - rbac.SyncNow = make(chan struct{}, 1) - - wantErr := errors.New("boom") - countMock := mockey.Mock(model.CountUsers).Return(int64(0), nil).Build() - defer countMock.UnPatch() - - addMock := mockey.Mock(model.AddSuperUser).Return(wantErr).Build() - defer addMock.UnPatch() - - if err := loadUser(); !errors.Is(err, wantErr) { - t.Fatalf("loadUser() error = %v, want %v", err, wantErr) - } - - select { - case <-rbac.SyncNow: - t.Fatal("unexpected rbac sync signal") - default: - } -} - -func TestLoadClustersSkipsWhenClustersExist(t *testing.T) { - countMock := mockey.Mock(model.CountClusters).Return(int64(1), nil).Build() - defer countMock.UnPatch() - - importMock := mockey.Mock(cluster.ImportClustersFromKubeconfig).To(func(*clientcmdapi.Config) int64 { - t.Fatal("ImportClustersFromKubeconfig() should not be called") - return 0 - }).Build() - defer importMock.UnPatch() - - if err := loadClusters(); err != nil { - t.Fatalf("loadClusters() error = %v", err) - } -} - -func TestLoadClustersImportsFromKubeconfig(t *testing.T) { - countMock := mockey.Mock(model.CountClusters).Return(int64(0), nil).Build() - defer countMock.UnPatch() - - imported := false - importMock := mockey.Mock(cluster.ImportClustersFromKubeconfig).To(func(cfg *clientcmdapi.Config) int64 { - imported = true - if cfg.CurrentContext != "dev" { - t.Fatalf("CurrentContext = %q, want %q", cfg.CurrentContext, "dev") - } - if len(cfg.Contexts) != 1 { - t.Fatalf("len(Contexts) = %d, want 1", len(cfg.Contexts)) - } - return 1 - }).Build() - defer importMock.UnPatch() - - dir := t.TempDir() - kubeconfigPath := filepath.Join(dir, "config") - if err := os.WriteFile(kubeconfigPath, []byte(validKubeconfig), 0o600); err != nil { - t.Fatalf("os.WriteFile() error = %v", err) - } - t.Setenv("KUBECONFIG", kubeconfigPath) - - if err := loadClusters(); err != nil { - t.Fatalf("loadClusters() error = %v", err) - } - if !imported { - t.Fatal("expected kubeconfig import") - } -} - -func TestLoadClustersReturnsLoadError(t *testing.T) { - countMock := mockey.Mock(model.CountClusters).Return(int64(0), nil).Build() - defer countMock.UnPatch() - - dir := t.TempDir() - kubeconfigPath := filepath.Join(dir, "config") - if err := os.WriteFile(kubeconfigPath, []byte("not: [valid"), 0o600); err != nil { - t.Fatalf("os.WriteFile() error = %v", err) - } - t.Setenv("KUBECONFIG", kubeconfigPath) - - if err := loadClusters(); err == nil { - t.Fatal("expected loadClusters() to return error") - } -} - -const validKubeconfig = `apiVersion: v1 -kind: Config -current-context: dev -clusters: -- name: dev - cluster: - server: https://example.com -contexts: -- name: dev - context: - cluster: dev - user: dev -users: -- name: dev - user: - token: test-token -` diff --git a/pkg/auth/ldap_setting_handler.go b/pkg/auth/ldap_setting_handler.go index e534fc3b..9d2b5d8d 100644 --- a/pkg/auth/ldap_setting_handler.go +++ b/pkg/auth/ldap_setting_handler.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/gin-gonic/gin" + "github.com/zxh326/kite/pkg/common" "github.com/zxh326/kite/pkg/model" ) @@ -35,6 +36,11 @@ func (h *AuthHandler) GetLDAPSetting(c *gin.Context) { } func (h *AuthHandler) UpdateLDAPSetting(c *gin.Context) { + if common.IsSectionManaged("ldap") { + c.JSON(http.StatusForbidden, gin.H{"error": common.ManagedSectionError}) + return + } + var req UpdateLDAPSettingRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid request: %v", err)}) diff --git a/pkg/auth/oauth_provider_handler.go b/pkg/auth/oauth_provider_handler.go index 4f397464..b8cbe6e7 100644 --- a/pkg/auth/oauth_provider_handler.go +++ b/pkg/auth/oauth_provider_handler.go @@ -6,6 +6,7 @@ import ( "strconv" "github.com/gin-gonic/gin" + "github.com/zxh326/kite/pkg/common" "github.com/zxh326/kite/pkg/model" ) @@ -28,6 +29,11 @@ func (h *AuthHandler) ListOAuthProviders(c *gin.Context) { } func (h *AuthHandler) CreateOAuthProvider(c *gin.Context) { + if common.IsSectionManaged("oauth") { + c.JSON(http.StatusForbidden, gin.H{"error": common.ManagedSectionError}) + return + } + var provider model.OAuthProvider if err := c.ShouldBindJSON(&provider); err != nil { c.JSON(http.StatusBadRequest, gin.H{ @@ -71,6 +77,11 @@ func (h *AuthHandler) CreateOAuthProvider(c *gin.Context) { } func (h *AuthHandler) UpdateOAuthProvider(c *gin.Context) { + if common.IsSectionManaged("oauth") { + c.JSON(http.StatusForbidden, gin.H{"error": common.ManagedSectionError}) + return + } + id := c.Param("id") var provider model.OAuthProvider if err := c.ShouldBindJSON(&provider); err != nil { @@ -139,6 +150,11 @@ func (h *AuthHandler) UpdateOAuthProvider(c *gin.Context) { } func (h *AuthHandler) DeleteOAuthProvider(c *gin.Context) { + if common.IsSectionManaged("oauth") { + c.JSON(http.StatusForbidden, gin.H{"error": common.ManagedSectionError}) + return + } + id := c.Param("id") dbID, err := strconv.ParseUint(id, 10, 32) if err != nil { diff --git a/pkg/cluster/cluster_handler.go b/pkg/cluster/cluster_handler.go index 94ad9315..4ab8bd45 100644 --- a/pkg/cluster/cluster_handler.go +++ b/pkg/cluster/cluster_handler.go @@ -82,6 +82,11 @@ func (cm *ClusterManager) GetClusterList(c *gin.Context) { } func (cm *ClusterManager) CreateCluster(c *gin.Context) { + if common.IsSectionManaged("clusters") { + c.JSON(http.StatusForbidden, gin.H{"error": common.ManagedSectionError}) + return + } + var req struct { Name string `json:"name" binding:"required"` Description string `json:"description"` @@ -135,6 +140,11 @@ func (cm *ClusterManager) CreateCluster(c *gin.Context) { } func (cm *ClusterManager) UpdateCluster(c *gin.Context) { + if common.IsSectionManaged("clusters") { + c.JSON(http.StatusForbidden, gin.H{"error": common.ManagedSectionError}) + return + } + idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { @@ -201,6 +211,11 @@ func (cm *ClusterManager) UpdateCluster(c *gin.Context) { } func (cm *ClusterManager) DeleteCluster(c *gin.Context) { + if common.IsSectionManaged("clusters") { + c.JSON(http.StatusForbidden, gin.H{"error": common.ManagedSectionError}) + return + } + idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { @@ -234,6 +249,11 @@ func (cm *ClusterManager) DeleteCluster(c *gin.Context) { } func (cm *ClusterManager) ImportClustersFromKubeconfig(c *gin.Context) { + if common.IsSectionManaged("clusters") { + c.JSON(http.StatusForbidden, gin.H{"error": common.ManagedSectionError}) + return + } + var clusterReq common.ImportClustersRequest if err := c.ShouldBindJSON(&clusterReq); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) diff --git a/pkg/common/common.go b/pkg/common/common.go index 499b8841..e44dd66c 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -54,8 +54,21 @@ var ( APIKeyProvider = "api_key" AgentPodNamespace = "kube-system" + + // ConfigFilePath is the path to the external config file (set via KITE_CONFIG_FILE env) + ConfigFilePath = "" + + // ManagedSections tracks which configuration sections are managed by the config file. + // Keys: "clusters", "oauth", "ldap", "rbac", "superUser" + ManagedSections = map[string]bool{} ) +const ManagedSectionError = "This section is managed by configuration file and cannot be modified through the UI" + +func IsSectionManaged(section string) bool { + return ManagedSections[section] +} + func LoadEnvs() { if secret := os.Getenv("JWT_SECRET"); secret != "" { JwtSecret = secret @@ -120,6 +133,10 @@ func LoadEnvs() { klog.Infof("Using base path: %s", Base) } + if v := os.Getenv("KITE_CONFIG_FILE"); v != "" { + ConfigFilePath = v + } + if v := os.Getenv("CORS_ALLOWED_ORIGINS"); v != "" { origins := strings.Split(v, ",") for _, o := range origins { diff --git a/pkg/handlers/overview_handler.go b/pkg/handlers/overview_handler.go index b5b40af4..cc1ce3d5 100644 --- a/pkg/handlers/overview_handler.go +++ b/pkg/handlers/overview_handler.go @@ -166,20 +166,32 @@ func GetOverview(c *gin.Context) { c.JSON(http.StatusOK, overview) } +func GetManagedSections(c *gin.Context) { + c.JSON(http.StatusOK, common.ManagedSections) +} + func InitCheck(c *gin.Context) { step := 0 uc, _ := model.CountUsers() if uc == 0 && !common.AnonymousUserEnabled { c.SetCookie("auth_token", "", -1, "/", "", false, true) c.JSON(http.StatusOK, gin.H{"initialized": false, "step": step}) + return } if uc > 0 || common.AnonymousUserEnabled { step++ } - cc, _ := model.CountClusters() - if cc > 0 { + + // If clusters are managed by config file, skip the cluster setup step + if common.IsSectionManaged("clusters") { step++ + } else { + cc, _ := model.CountClusters() + if cc > 0 { + step++ + } } + initialized := step == 2 c.JSON(http.StatusOK, gin.H{"initialized": initialized, "step": step}) } diff --git a/pkg/rbac/handler.go b/pkg/rbac/handler.go index d0703b9b..e7255f9b 100644 --- a/pkg/rbac/handler.go +++ b/pkg/rbac/handler.go @@ -5,6 +5,7 @@ import ( "strconv" "github.com/gin-gonic/gin" + "github.com/zxh326/kite/pkg/common" "github.com/zxh326/kite/pkg/model" ) @@ -36,6 +37,11 @@ func GetRole(c *gin.Context) { // CreateRole creates a new role func CreateRole(c *gin.Context) { + if common.IsSectionManaged("rbac") { + c.JSON(http.StatusForbidden, gin.H{"error": common.ManagedSectionError}) + return + } + var role model.Role if err := c.ShouldBindJSON(&role); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -56,6 +62,11 @@ func CreateRole(c *gin.Context) { // UpdateRole updates an existing role func UpdateRole(c *gin.Context) { + if common.IsSectionManaged("rbac") { + c.JSON(http.StatusForbidden, gin.H{"error": common.ManagedSectionError}) + return + } + id := c.Param("id") dbID, err := strconv.ParseUint(id, 10, 32) if err != nil { @@ -90,6 +101,11 @@ func UpdateRole(c *gin.Context) { // DeleteRole deletes a role and its assignments func DeleteRole(c *gin.Context) { + if common.IsSectionManaged("rbac") { + c.JSON(http.StatusForbidden, gin.H{"error": common.ManagedSectionError}) + return + } + id := c.Param("id") dbID, err := strconv.ParseUint(id, 10, 32) if err != nil { @@ -112,6 +128,11 @@ type roleAssignmentReq struct { // AssignRole assigns a role to a user or group func AssignRole(c *gin.Context) { + if common.IsSectionManaged("rbac") { + c.JSON(http.StatusForbidden, gin.H{"error": common.ManagedSectionError}) + return + } + id := c.Param("id") dbID, err := strconv.ParseUint(id, 10, 32) if err != nil { @@ -157,6 +178,11 @@ func AssignRole(c *gin.Context) { // UnassignRole removes an assignment. Accepts query params subjectType and subject. func UnassignRole(c *gin.Context) { + if common.IsSectionManaged("rbac") { + c.JSON(http.StatusForbidden, gin.H{"error": common.ManagedSectionError}) + return + } + id := c.Param("id") dbID, err := strconv.ParseUint(id, 10, 32) if err != nil { diff --git a/routes.go b/routes.go index a7b51ee1..cdbae861 100644 --- a/routes.go +++ b/routes.go @@ -36,6 +36,7 @@ func registerBaseRoutes(r *gin.RouterGroup) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) r.GET("/api/v1/init_check", handlers.InitCheck) + r.GET("/api/v1/managed-sections", handlers.GetManagedSections) r.GET("/api/v1/version", version.GetVersion) } diff --git a/ui/src/components/settings/authentication-management.tsx b/ui/src/components/settings/authentication-management.tsx index 216fc702..1881caac 100644 --- a/ui/src/components/settings/authentication-management.tsx +++ b/ui/src/components/settings/authentication-management.tsx @@ -12,6 +12,7 @@ import { useGeneralSetting, useLDAPSetting, } from '@/lib/api' +import { useManagedSections } from '@/lib/api/system' import { translateError } from '@/lib/utils' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' @@ -19,6 +20,7 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' +import { ManagedBanner } from './managed-banner' import { OAuthProviderManagement } from './oauth-provider-management' type AuthenticationFormData = LDAPSetting @@ -68,6 +70,8 @@ export function AuthenticationManagement() { const queryClient = useQueryClient() const { data, error, isError, isLoading, refetch } = useLDAPSetting() const { data: generalSetting } = useGeneralSetting() + const { data: managedSections } = useManagedSections() + const isLdapManaged = !!managedSections?.ldap const [formData, setFormData] = useState( createDefaultSettings ) @@ -85,16 +89,20 @@ export function AuthenticationManagement() { const mutation = useMutation({ mutationFn: (params: { - ldap: LDAPSettingUpdateRequest + ldap?: LDAPSettingUpdateRequest passwordLoginDisabled: boolean - }) => - Promise.all([ - updateLDAPSetting(params.ldap), + }) => { + const promises: Promise[] = [ updateGeneralSetting({ ...generalSetting!, passwordLoginDisabled: params.passwordLoginDisabled, }), - ]), + ] + if (params.ldap) { + promises.push(updateLDAPSetting(params.ldap)) + } + return Promise.all(promises) + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ldap-setting'], @@ -143,7 +151,7 @@ export function AuthenticationManagement() { } mutation.mutate({ - ldap: payload, + ldap: isLdapManaged ? undefined : payload, passwordLoginDisabled: !passwordLoginEnabled, }) } @@ -181,6 +189,7 @@ export function AuthenticationManagement() { return (
+ {isLdapManaged && } @@ -254,6 +263,7 @@ export function AuthenticationManagement() { onCheckedChange={(checked) => setFormData((prev) => ({ ...prev, enabled: checked })) } + disabled={isLdapManaged} />
@@ -277,6 +287,7 @@ export function AuthenticationManagement() { })) } placeholder="ldaps://ldap.example.com:636" + disabled={isLdapManaged} /> @@ -305,6 +316,7 @@ export function AuthenticationManagement() { useStartTLS: checked, })) } + disabled={isLdapManaged} /> @@ -326,6 +338,7 @@ export function AuthenticationManagement() { })) } placeholder="cn=svc-kite,ou=services,dc=example,dc=com" + disabled={isLdapManaged} /> @@ -354,6 +367,7 @@ export function AuthenticationManagement() { ) : '' } + disabled={isLdapManaged} /> @@ -374,6 +388,7 @@ export function AuthenticationManagement() { })) } placeholder="ou=users,dc=example,dc=com" + disabled={isLdapManaged} /> @@ -393,6 +408,7 @@ export function AuthenticationManagement() { userFilter: e.target.value, })) } + disabled={isLdapManaged} /> @@ -412,6 +428,7 @@ export function AuthenticationManagement() { usernameAttribute: e.target.value, })) } + disabled={isLdapManaged} /> @@ -431,6 +448,7 @@ export function AuthenticationManagement() { displayNameAttribute: e.target.value, })) } + disabled={isLdapManaged} /> @@ -451,6 +469,7 @@ export function AuthenticationManagement() { })) } placeholder="ou=groups,dc=example,dc=com" + disabled={isLdapManaged} /> @@ -470,6 +489,7 @@ export function AuthenticationManagement() { groupFilter: e.target.value, })) } + disabled={isLdapManaged} /> @@ -489,6 +509,7 @@ export function AuthenticationManagement() { groupNameAttribute: e.target.value, })) } + disabled={isLdapManaged} /> diff --git a/ui/src/components/settings/cluster-management.tsx b/ui/src/components/settings/cluster-management.tsx index 19ad11bc..c116203c 100644 --- a/ui/src/components/settings/cluster-management.tsx +++ b/ui/src/components/settings/cluster-management.tsx @@ -14,6 +14,7 @@ import { updateCluster, useClusterList, } from '@/lib/api' +import { useManagedSections } from '@/lib/api/system' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' @@ -26,12 +27,15 @@ import { DeleteConfirmationDialog } from '@/components/delete-confirmation-dialo import { Action, ActionTable } from '../action-table' import { ClusterDialog } from './cluster-dialog' +import { ManagedBanner } from './managed-banner' export function ClusterManagement() { const { t } = useTranslation() const queryClient = useQueryClient() const { data: clusters = [], isLoading, error } = useClusterList() + const { data: managedSections } = useManagedSections() + const isManaged = !!managedSections?.clusters const [showClusterDialog, setShowClusterDialog] = useState(false) const [editingCluster, setEditingCluster] = useState(null) @@ -149,33 +153,36 @@ export function ClusterManagement() { ) const actions = useMemo[]>( - () => [ - { - label: ( - <> - - {t('common.edit', 'Edit')} - - ), - onClick: (cluster) => { - setEditingCluster(cluster) - setShowClusterDialog(true) - }, - }, - { - label: ( -
- - {t('common.delete', 'Delete')} -
- ), - shouldDisable: (cluster) => cluster.isDefault, - onClick: (cluster) => { - setDeletingCluster(cluster) - }, - }, - ], - [t] + () => + isManaged + ? [] + : [ + { + label: ( + <> + + {t('common.edit', 'Edit')} + + ), + onClick: (cluster) => { + setEditingCluster(cluster) + setShowClusterDialog(true) + }, + }, + { + label: ( +
+ + {t('common.delete', 'Delete')} +
+ ), + shouldDisable: (cluster) => cluster.isDefault, + onClick: (cluster) => { + setDeletingCluster(cluster) + }, + }, + ], + [t, isManaged] ) const createMutation = useMutation({ @@ -282,6 +289,7 @@ export function ClusterManagement() { return (
+ {isManaged && }
@@ -291,16 +299,18 @@ export function ClusterManagement() { {t('clusterManagement.title', 'Cluster Management')}
- + {!isManaged && ( + + )}
diff --git a/ui/src/components/settings/managed-banner.tsx b/ui/src/components/settings/managed-banner.tsx new file mode 100644 index 00000000..2945099b --- /dev/null +++ b/ui/src/components/settings/managed-banner.tsx @@ -0,0 +1,20 @@ +import { IconInfoCircle } from '@tabler/icons-react' +import { useTranslation } from 'react-i18next' + +import { Alert, AlertDescription } from '@/components/ui/alert' + +export function ManagedBanner() { + const { t } = useTranslation() + + return ( + + + + {t( + 'settings.managedBanner', + 'This section is managed by configuration file and is read-only.' + )} + + + ) +} diff --git a/ui/src/components/settings/oauth-provider-management.tsx b/ui/src/components/settings/oauth-provider-management.tsx index 20a5207b..646688a8 100644 --- a/ui/src/components/settings/oauth-provider-management.tsx +++ b/ui/src/components/settings/oauth-provider-management.tsx @@ -14,12 +14,14 @@ import { updateOAuthProvider, useOAuthProviderList, } from '@/lib/api' +import { useManagedSections } from '@/lib/api/system' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { DeleteConfirmationDialog } from '@/components/delete-confirmation-dialog' import { Action, ActionTable } from '../action-table' +import { ManagedBanner } from './managed-banner' import { OAuthProviderDialog } from './oauth-provider-dialog' export function OAuthProviderManagement() { @@ -28,6 +30,8 @@ export function OAuthProviderManagement() { // Use real API to fetch OAuth providers const { data: providers = [], isLoading, error } = useOAuthProviderList() + const { data: managedSections } = useManagedSections() + const isManaged = !!managedSections?.oauth const [showProviderDialog, setShowProviderDialog] = useState(false) const [editingProvider, setEditingProvider] = useState( @@ -97,32 +101,35 @@ export function OAuthProviderManagement() { ) const actions = useMemo[]>( - () => [ - { - label: ( - <> - - {t('common.edit', 'Edit')} - - ), - onClick: (provider) => { - setEditingProvider(provider) - setShowProviderDialog(true) - }, - }, - { - label: ( -
- - {t('common.delete', 'Delete')} -
- ), - onClick: (provider) => { - setDeletingProvider(provider) - }, - }, - ], - [t] + () => + isManaged + ? [] + : [ + { + label: ( + <> + + {t('common.edit', 'Edit')} + + ), + onClick: (provider) => { + setEditingProvider(provider) + setShowProviderDialog(true) + }, + }, + { + label: ( +
+ + {t('common.delete', 'Delete')} +
+ ), + onClick: (provider) => { + setDeletingProvider(provider) + }, + }, + ], + [t, isManaged] ) // Create provider mutation @@ -254,6 +261,7 @@ export function OAuthProviderManagement() { return (
+ {isManaged && }
@@ -263,16 +271,18 @@ export function OAuthProviderManagement() { {t('oauthManagement.title', 'OAuth Provider Management')}
- + {!isManaged && ( + + )}
diff --git a/ui/src/components/settings/rbac-management.tsx b/ui/src/components/settings/rbac-management.tsx index e51f5d39..2b53471a 100644 --- a/ui/src/components/settings/rbac-management.tsx +++ b/ui/src/components/settings/rbac-management.tsx @@ -19,12 +19,14 @@ import { updateRole, useRoleList, } from '@/lib/api' +import { useManagedSections } from '@/lib/api/system' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { DeleteConfirmationDialog } from '@/components/delete-confirmation-dialog' import { Action, ActionTable } from '../action-table' import { Badge } from '../ui/badge' +import { ManagedBanner } from './managed-banner' import { RBACAssignmentDialog } from './rbac-assignment-dialog' import { RBACDialog } from './rbac-dialog' @@ -33,6 +35,8 @@ export function RBACManagement() { const queryClient = useQueryClient() const { data: roles = [], isLoading, error } = useRoleList() + const { data: managedSections } = useManagedSections() + const isManaged = !!managedSections?.rbac const [showDialog, setShowDialog] = useState(false) const [editingRole, setEditingRole] = useState(null) @@ -155,46 +159,49 @@ export function RBACManagement() { ) const actions = useMemo[]>( - () => [ - { - label: ( - <> - - {t('common.assign', 'Assign')} - - ), - onClick: (r) => { - setAssigningRole(r) - setShowAssignDialog(true) - }, - }, - { - label: ( - <> - - {t('common.edit', 'Edit')} - - ), - shouldDisable: (role) => !!role.isSystem, - onClick: (role) => { - setEditingRole(role) - setShowDialog(true) - }, - }, - { - label: ( -
- - {t('common.delete', 'Delete')} -
- ), - shouldDisable: (role) => !!role.isSystem, - onClick: (role) => { - setDeletingRole(role) - }, - }, - ], - [t] + () => + isManaged + ? [] + : [ + { + label: ( + <> + + {t('common.assign', 'Assign')} + + ), + onClick: (r) => { + setAssigningRole(r) + setShowAssignDialog(true) + }, + }, + { + label: ( + <> + + {t('common.edit', 'Edit')} + + ), + shouldDisable: (role) => !!role.isSystem, + onClick: (role) => { + setEditingRole(role) + setShowDialog(true) + }, + }, + { + label: ( +
+ + {t('common.delete', 'Delete')} +
+ ), + shouldDisable: (role) => !!role.isSystem, + onClick: (role) => { + setDeletingRole(role) + }, + }, + ], + [t, isManaged] ) const createMutation = useMutation({ @@ -326,6 +333,7 @@ export function RBACManagement() { return (
+ {isManaged && }
@@ -335,16 +343,18 @@ export function RBACManagement() { {t('rbac.title', 'Role Management')}
- + {!isManaged && ( + + )}
diff --git a/ui/src/lib/api/system.ts b/ui/src/lib/api/system.ts index 91d27399..83da8761 100644 --- a/ui/src/lib/api/system.ts +++ b/ui/src/lib/api/system.ts @@ -69,3 +69,18 @@ export const importClusters = async ( ): Promise => { await apiClient.post('/admin/clusters/import', request) } + +// Managed sections - tracks which config sections are managed by config file +export type ManagedSections = Record + +export const fetchManagedSections = (): Promise => { + return fetchAPI('/managed-sections') +} + +export const useManagedSections = () => { + return useQuery({ + queryKey: ['managed-sections'], + queryFn: fetchManagedSections, + staleTime: 1000 * 60 * 5, // 5 minutes - this rarely changes + }) +}