From 4059afb0ae9a3185a65f79c05dd30f0aa151902e Mon Sep 17 00:00:00 2001 From: Baylem Date: Tue, 10 Nov 2026 20:26:07 -0500 Subject: [PATCH 01/15] fix(security): isolate native API credentials and enforce game admission Signed-off-by: Baylem --- FEATURES.md | 2 +- api/cmd/main.go | 40 +++-- api/cmd/main_test.go | 28 +++ api/internal/auth/registry.go | 2 +- api/internal/handlers/clusters.go | 2 +- api/internal/handlers/module_upload.go | 2 +- api/internal/handlers/modules.go | 4 +- api/internal/handlers/modules_locales.go | 4 +- api/internal/handlers/secrets_managed.go | 2 +- api/internal/notify/sinks.go | 2 +- api/internal/ws/gateway_client.go | 2 +- api/specs.md | 11 +- charts/gameplane/templates/_helpers.tpl | 16 +- .../templates/admission-policies.yaml | 99 +++++++++++ .../gameplane/templates/api-credentials.yaml | 64 +++++++ charts/gameplane/templates/api.yaml | 33 +--- charts/gameplane/templates/namespaces.yaml | 4 + charts/gameplane/templates/operator.yaml | 5 +- .../templates/validate-api-migration.yaml | 60 +++++++ charts/gameplane/templates/validate.yaml | 16 ++ charts/gameplane/values.yaml | 15 +- charts/tests/check_gateway.py | 5 +- charts/tests/check_operator_secrets.py | 30 +++- charts/tests/check_workload_security.py | 48 +++++- deploy/kind/e2e.sh | 3 + docs/install.md | 82 ++++++++- docs/module-authoring.md | 9 +- docs/multicluster-agent-gateway.md | 6 +- docs/notifications.md | 8 +- docs/oidc.md | 2 +- docs/security.md | 18 +- operator/cmd/main.go | 18 +- .../internal/controller/cluster_controller.go | 4 +- .../internal/controller/module_locales.go | 8 +- .../controller/module_locales_test.go | 2 +- .../controller/modulesource_controller.go | 7 +- operator/internal/modsrc/fetcher.go | 9 +- operator/internal/modsrc/fetcher_test.go | 30 ++++ operator/internal/modsrc/git.go | 6 +- operator/internal/modsrc/http.go | 6 +- operator/internal/modsrc/upload.go | 2 +- operator/internal/modsrc/upload_test.go | 13 +- operator/internal/oci/auth.go | 7 +- operator/internal/secretscope/module.go | 22 +++ operator/internal/verify/verify.go | 10 +- operator/internal/verify/verify_test.go | 24 +++ operator/specs.md | 6 +- test/e2e/api_module_upload_budget_e2e_test.go | 7 +- test/e2e/game_admission_e2e_test.go | 161 ++++++++++++++++++ test/e2e/gatewayparity_e2e_test.go | 30 ++-- test/e2e/i18n_module_locales_e2e_test.go | 7 + test/e2e/module_toolkit_e2e_test.go | 14 +- test/e2e/modulesource_upload_e2e_test.go | 7 +- test/e2e/multicluster_e2e_test.go | 17 +- test/e2e/operator_secrets_e2e_test.go | 6 +- test/e2e/restricted_workloads_e2e_test.go | 6 +- test/e2e/upgrade_e2e_test.go | 109 +++++++++++- 57 files changed, 987 insertions(+), 175 deletions(-) create mode 100644 charts/gameplane/templates/admission-policies.yaml create mode 100644 charts/gameplane/templates/api-credentials.yaml create mode 100644 charts/gameplane/templates/validate-api-migration.yaml create mode 100644 operator/internal/secretscope/module.go create mode 100644 test/e2e/game_admission_e2e_test.go diff --git a/FEATURES.md b/FEATURES.md index 85f90e730..26728216c 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -185,7 +185,7 @@ See [docs/roadmap.md](docs/roadmap.md) for what is planned and in progress. | Ship audit events to syslog or S3-compatible storage | beta | off | The bundled audit-syslog-bridge turns events into RFC 5424 records sent over TCP, TCP with TLS or UDP, with TCP recommended, and api.audit.s3 batches NDJSON objects flushed at 100 events, 1 MiB or 5 seconds. | [audit-syslog-bridge](audit-syslog-bridge/README.md#transport-prefer-tcp) | | Prune audit events past a retention age | beta | off | api.audit.retentionDays removes events older than N days, and the default of 0 keeps events forever. | [install](docs/install.md#audit-log) | | Resolve client IPs through trusted proxies | beta | on | The API reads X-Forwarded-For only from peers inside api.trustedProxies, so audit records and per-IP limits use the real client address. | [security](docs/security.md#client-ip-extraction-from-forwarded-headers) | -| Run Gameplane pods hardened and optionally enforce restricted Pod Security | beta | on | Operator, API, agent and optional bridge pods run as uid 65532 with read-only root filesystems, RuntimeDefault seccomp and all capabilities dropped, and podSecurity.enforceRestricted, off by default, applies the restricted Pod Security Standard to the games namespace. | [security](docs/security.md#pod-security) | +| Run Gameplane pods with Restricted Pod Security | beta | on | Operator, API, agent and optional bridge pods run as uid 65532 with read-only root filesystems, RuntimeDefault seccomp and all capabilities dropped, and new installations enforce the Restricted Pod Security Standard in managed game namespaces by default. | [security](docs/security.md#pod-security) | | Redact password values and confine credentials to labelled Secrets | beta | on | API responses replace non-empty GameServer password values with a redaction marker and fail closed if the template cannot be read, and the API reads and deletes only Secrets carrying its own labels for auth providers and notification sinks. | [security](docs/security.md#gameserver-config-passwords) | | Encrypt agent and capture control traffic with mTLS | stable | on | Operator-managed agent and capture-sidecar control connections use mutual TLS 1.2 or newer with a verified client certificate. | [security](docs/security.md#authentication) | | Isolate pods with NetworkPolicies | stable | on | Applies NetworkPolicies that restrict ingress to game servers and give the API pod an egress policy that allows private ranges. | [security](docs/security.md#networkpolicies) | diff --git a/api/cmd/main.go b/api/cmd/main.go index b1bd15b53..4cf7cdfbd 100644 --- a/api/cmd/main.go +++ b/api/cmd/main.go @@ -1,6 +1,7 @@ package main import ( + "cmp" "context" "errors" "flag" @@ -223,8 +224,9 @@ func main() { if err != nil && cfg.oidcIssuer != "" { logger.Warn("oidc disabled", "err", err) } + credentialsNS, moduleConfigMapNS := cfg.managementNamespaces() authRegistry := auth.NewRegistry(store, - auth.NewK8sSecretReader(k8s, cfg.namespace), oidcAuth, cfg.oidcDisplayName, helmPolicy) + auth.NewK8sSecretReader(k8s, credentialsNS), oidcAuth, cfg.oidcDisplayName, helmPolicy) var auditOpts []audit.Option var webhookDone <-chan struct{} @@ -276,14 +278,14 @@ func main() { // backup/restore outcomes) and push matching events to the sinks // configured in Admin Settings. Best-effort mirror — never gates // reconciliation or requests. - notifier := notify.New(store, k8s, cfg.namespace) + notifier := notify.New(store, k8s, credentialsNS) go notifier.RunWithRegistry(ctx, reg) // Cluster registry: watch Cluster CRDs and populate the client registry // with remote cluster connections. Single-cluster installs only have the // "local" cluster (the control plane); this watcher loads remote clusters // from kubeconfigs stored as Secrets. - go kube.WatchClusters(ctx, k8s, reg, cfg.namespace) + go kube.WatchClusters(ctx, k8s, reg, credentialsNS) // Optional Steam display-name lookup for player lists. Off unless the // key is set; the resolver is then the only holder of the key and of @@ -388,18 +390,18 @@ func main() { telemetrySettings := handlers.TelemetrySettings{Dest: telemetryDest, Interval: cfg.telemetryInterval, Deps: telemetryDeps} handlers.MountConfigWithTelemetry(p, store, auditor, oidcAuth != nil, cfg.gameDataStorageClass, helmPolicy, telemetrySettings) handlers.MountTelemetry(p, store, telemetrySettings) - handlers.MountNotifications(p, notifier, k8s, cfg.namespace) - handlers.MountAuthProviderSecrets(p, k8s, cfg.namespace) + handlers.MountNotifications(p, notifier, k8s, credentialsNS) + handlers.MountAuthProviderSecrets(p, k8s, credentialsNS) handlers.MountCluster(p, reg, store, Version, cfg.clusterOps, cfg.updateChannel) handlers.MountClusterActions(p, k8s, cfg.clusterOps, cfg.clusterExternalAddress) - handlers.MountClusters(p, reg, k8s, cfg.namespace) + handlers.MountClusters(p, reg, k8s, credentialsNS) handlers.MountInstallation(p, cfg.standalone, Version) handlers.MountEvents(p, reg) handlers.MountDestinations(p, reg) handlers.MountTunnelCredentials(p, reg) handlers.MountSystemLogs(p, k8s, cfg.namespace) - handlers.MountModulesWithRegistry(p, reg, cfg.namespace) - handlers.MountRegistrySecrets(p, k8s, cfg.namespace) + handlers.MountModulesWithRegistry(p, reg, moduleConfigMapNS) + handlers.MountRegistrySecrets(p, k8s, credentialsNS) // Precedence: a runtime, admin-configured key (DB row + labelled // Secret, resolved lazily per registry.DBKeyFunc) always wins over // the startup --curseforge-api-key flag, so adding/rotating a key @@ -408,13 +410,13 @@ func main() { // air-gapped/GitOps installs that never touch admin config keep // working exactly as before. regSet := registry.NewSet(Version, registry.FallbackKeys( - registry.DBKeyFunc(store, registry.NewK8sSecretReader(k8s, cfg.namespace)), + registry.DBKeyFunc(store, registry.NewK8sSecretReader(k8s, credentialsNS)), registry.StaticKeys(map[string]string{"curseforge": cfg.curseforgeAPIKey}), )) handlers.MountRegistryWithRegistry(p, reg, regSet) // Internal reads use the same selected cluster and credentials as // browser-facing agent routes, including remote-only management installs. - agentLister := ws.NewClusterAgentClient(reg, cfg.namespace, cfg.agentCABundle, cfg.agentClientCert, cfg.agentClientKey) + agentLister := ws.NewClusterAgentClient(reg, credentialsNS, cfg.agentCABundle, cfg.agentClientCert, cfg.agentClientKey) handlers.MountModUpdatesWithRegistry(p, reg, regSet, agentLister) // ID-managed mods (ARK CurseForge ids, Project Zomboid MOD_IDS, // Steam Workshop lists): the API only writes GameServer.spec.mods.ids; @@ -422,15 +424,15 @@ func main() { handlers.MountModIDsWithRegistry(p, reg) captureConfig := handlers.CaptureConfig{ FeatureEnabled: cfg.captureFeatureEnabled, - GatewayNamespace: cfg.namespace, + GatewayNamespace: credentialsNS, DefaultRetentionSeconds: cfg.captureDefaultRetentionSecs, MaxRetentionSeconds: cfg.captureMaxRetentionSecs, DefaultMaxDurationSecs: cfg.captureDefaultMaxDurationS, DefaultMaxSizeBytes: cfg.captureDefaultMaxSizeBytes, } - handlers.MountServerCapabilities(p, reg, ws.NewCaptureGatewayClient(reg, cfg.namespace), captureConfig) + handlers.MountServerCapabilities(p, reg, ws.NewCaptureGatewayClient(reg, credentialsNS), captureConfig) handlers.MountCapture(p, reg, auditor, captureConfig, cfg.agentCABundle, cfg.agentClientCert, cfg.agentClientKey) - ws.MountWithPlayerNames(p, reg, cfg.agentCABundle, cfg.agentClientCert, cfg.agentClientKey, playerNames, ws.AgentGatewayOptions{Namespace: cfg.namespace}) + ws.MountWithPlayerNames(p, reg, cfg.agentCABundle, cfg.agentClientCert, cfg.agentClientKey, playerNames, ws.AgentGatewayOptions{Namespace: credentialsNS}) }) // Usage telemetry. Run returns at once when the resolved destination is @@ -581,6 +583,16 @@ type config struct { trustedProxies []string } +// Standalone keeps encrypted credentials on the panel and reads module +// ConfigMaps from the selected remote cluster's operator namespace. +func (c config) managementNamespaces() (credentials, moduleConfigMaps string) { + credentials = cmp.Or(os.Getenv("GAMEPLANE_CREDENTIALS_NAMESPACE"), c.namespace) + if c.standalone { + return credentials, c.namespace + } + return credentials, credentials +} + func (c *config) bindFlags(fs *flag.FlagSet) { fs.StringVar(&c.addr, "addr", ":8000", "HTTP listen address") fs.StringVar(&c.metricsAddr, "metrics-addr", envOr("GAMEPLANE_METRICS_ADDR", ":9090"), @@ -664,7 +676,7 @@ func (c *config) bindFlags(fs *flag.FlagSet) { fs.BoolVar(&c.panelKeyProvisioned, "panel-key-provisioned", envOr("GAMEPLANE_PANEL_KEY_PROVISIONED", "") == "true", "require an existing read-only standalone credential key; never generate it") fs.StringVar(&c.remoteAllowedCIDRs, "remote-allowed-cidrs", envOr("GAMEPLANE_REMOTE_ALLOWED_CIDRS", ""), "optional comma-separated CIDRs allowed for standalone Kubernetes and gateway connections") fs.StringVar(&c.namespace, "namespace", envOr("GAMEPLANE_NAMESPACE", "gameplane-system"), - "namespace the control plane runs in (module upload ConfigMaps are stored here)") + "control-plane namespace (in standalone mode, the remote operator's module ConfigMap namespace)") fs.StringVar(&c.gameDataStorageClass, "game-data-storage-class", envOr("GAMEPLANE_GAME_DATA_STORAGE_CLASS", ""), "StorageClass name for game data PVCs (empty = cluster default)") diff --git a/api/cmd/main_test.go b/api/cmd/main_test.go index 2f1db62d7..2cda94201 100644 --- a/api/cmd/main_test.go +++ b/api/cmd/main_test.go @@ -1,11 +1,39 @@ package main import ( + "flag" "log/slog" "net/netip" "testing" ) +func TestManagementNamespaces(t *testing.T) { + t.Setenv("GAMEPLANE_NAMESPACE", "panel") + for _, tc := range []struct { + name, credentials, wantCredentials, wantConfigMaps string + args []string + }{ + {name: "existing environment", wantCredentials: "panel", wantConfigMaps: "panel"}, + {name: "existing standalone store", args: []string{"--standalone", "--namespace=legacy-panel"}, wantCredentials: "legacy-panel", wantConfigMaps: "legacy-panel"}, + {name: "standalone remote operator", args: []string{"--standalone", "--namespace=remote-modules"}, credentials: "panel-records", wantCredentials: "panel-records", wantConfigMaps: "remote-modules"}, + {name: "isolated native credentials and uploads", args: []string{"--namespace=release"}, credentials: "isolated", wantCredentials: "isolated", wantConfigMaps: "isolated"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("GAMEPLANE_CREDENTIALS_NAMESPACE", tc.credentials) + var cfg config + fs := flag.NewFlagSet("serve", flag.ContinueOnError) + cfg.bindFlags(fs) + if err := fs.Parse(tc.args); err != nil { + t.Fatal(err) + } + credentials, configMaps := cfg.managementNamespaces() + if credentials != tc.wantCredentials || configMaps != tc.wantConfigMaps { + t.Errorf("namespaces = %q, %q; want %q, %q", credentials, configMaps, tc.wantCredentials, tc.wantConfigMaps) + } + }) + } +} + func TestParseLogLevel(t *testing.T) { cases := map[string]slog.Level{ "debug": slog.LevelDebug, diff --git a/api/internal/auth/registry.go b/api/internal/auth/registry.go index de5c69936..2b79dfb76 100644 --- a/api/internal/auth/registry.go +++ b/api/internal/auth/registry.go @@ -95,7 +95,7 @@ func (p Provider) Label() string { } // SecretReader fetches a named credential Secret's data. The production -// implementation reads labelled Secrets from the control-plane namespace; +// implementation reads labelled Secrets from the credentials namespace; // tests substitute a map. type SecretReader func(ctx context.Context, name string) (map[string][]byte, error) diff --git a/api/internal/handlers/clusters.go b/api/internal/handlers/clusters.go index bd3e6dcaa..110b8cea9 100644 --- a/api/internal/handlers/clusters.go +++ b/api/internal/handlers/clusters.go @@ -193,7 +193,7 @@ func (h clustersHandler) create(w http.ResponseWriter, req *http.Request) { return } - // Create the kubeconfig Secret in the control-plane namespace. The + // Create the kubeconfig Secret in the credentials namespace. The // managed-by label marks it as created by the API, which is what lets // DELETE /clusters/{name} remove it again. secretName := clusterKubeconfigSecretName(in.Name) diff --git a/api/internal/handlers/module_upload.go b/api/internal/handlers/module_upload.go index 442943fb7..2c7c725e1 100644 --- a/api/internal/handlers/module_upload.go +++ b/api/internal/handlers/module_upload.go @@ -6,7 +6,7 @@ // The body is a tar.gz or zip holding one module directory (module.yaml // + template.yaml [+ README.md, icon.png, locales/.json]). After validation the files // are written into a ConfigMap labeled gameplane.local/module-upload=true in -// the operator namespace — the exact shape an upload-type ModuleSource +// the module ConfigMap namespace — the exact shape an upload-type ModuleSource // indexes, and the same thing `kubectl apply` of such a ConfigMap // produces, so the operator stays authoritative. ?dryRun=true validates // and returns the parsed metadata without storing anything. diff --git a/api/internal/handlers/modules.go b/api/internal/handlers/modules.go index b602a204c..0f31ba220 100644 --- a/api/internal/handlers/modules.go +++ b/api/internal/handlers/modules.go @@ -32,8 +32,8 @@ import ( "github.com/GameplanePanel/gameplane/api/internal/kube" ) -// MountModules wires the /modules surface onto r. namespace is the -// operator namespace, where uploaded bundle ConfigMaps live. +// MountModules wires the /modules surface onto r. namespace holds the +// uploaded bundle and translation ConfigMaps. func MountModules(r chi.Router, k *kube.Client, namespace string) { h := modulesHandler{k: k, namespace: namespace} r.Route("/modules", func(r chi.Router) { diff --git a/api/internal/handlers/modules_locales.go b/api/internal/handlers/modules_locales.go index a81851202..7a6e2b365 100644 --- a/api/internal/handlers/modules_locales.go +++ b/api/internal/handlers/modules_locales.go @@ -14,8 +14,8 @@ import ( ) // localesConfigMapSuffix names the ConfigMap the operator writes a module's -// validated translations to: + this suffix, in the operator -// namespace. Keys are ".json" holding an app.json style entry map. +// validated translations to: + this suffix, in the module +// ConfigMap namespace. Keys are ".json" holding an app.json style entry map. const localesConfigMapSuffix = "-locales" // getLocales serves GET /modules/{name}/locales/{tag}: the module's diff --git a/api/internal/handlers/secrets_managed.go b/api/internal/handlers/secrets_managed.go index dea686999..aeb8f7b2d 100644 --- a/api/internal/handlers/secrets_managed.go +++ b/api/internal/handlers/secrets_managed.go @@ -1,7 +1,7 @@ // API-managed credential Secrets. Admin Settings lets an admin type a // sink's (and later a provider's) secret material directly into the // dashboard; the API persists it as a labelled Secret in the -// control-plane namespace so the consuming subsystem (notify, auth) can +// credentials namespace so the consuming subsystem (notify, auth) can // keep its existing "read only labelled Secrets" contract. // // Two labels bound what this surface may touch: diff --git a/api/internal/notify/sinks.go b/api/internal/notify/sinks.go index ea77d1c87..1f14cfb94 100644 --- a/api/internal/notify/sinks.go +++ b/api/internal/notify/sinks.go @@ -63,7 +63,7 @@ func sinkMatches(s Sink, t EventType) bool { return false } -// sinkSecret fetches the sink's credential Secret from the control-plane +// sinkSecret fetches the sink's credential Secret from the credentials // namespace, refusing any Secret not labelled SinkSecretLabel=true. func (n *Notifier) sinkSecret(ctx context.Context, name string) (map[string][]byte, error) { sec, err := n.k.Secrets(n.controlNS).Get(ctx, name, metav1.GetOptions{}) diff --git a/api/internal/ws/gateway_client.go b/api/internal/ws/gateway_client.go index b5c217f42..cadaddf2a 100644 --- a/api/internal/ws/gateway_client.go +++ b/api/internal/ws/gateway_client.go @@ -30,7 +30,7 @@ const gatewayCredentialsLabel = "gameplane.local/agent-gateway-credentials" var errGatewayUnavailable = errors.New("agent gateway not configured") // AgentGatewayOptions enables registered-cluster agent routes. Secrets are read -// only from the namespace where the central API runs. +// only from the central API's configured credentials namespace. type AgentGatewayOptions struct{ Namespace string } type agentGatewayResolver struct { diff --git a/api/specs.md b/api/specs.md index ad8318a19..446c71ce2 100644 --- a/api/specs.md +++ b/api/specs.md @@ -235,8 +235,17 @@ headers retain the existing allowlist, so browser cookies, authorization and CSR material do not reach agents. Proxy body limits and JSON response limits remain at the caller boundary. +The API reads and writes dashboard-managed OIDC, registry and notification +credentials, remote kubeconfigs and gateway TLS Secrets in +`GAMEPLANE_CREDENTIALS_NAMESPACE`. When unset or empty, this defaults to the +resolved `--namespace` value, preserving existing standalone database keys. +Native installs store module uploads and translations in that same namespace. +System logs use `--namespace`. In standalone mode `--namespace` selects the +remote operator's module ConfigMap namespace independently of the panel's +credential storage. + Registered clusters may additionally configure `spec.agentGateway.url` and a -labeled `tlsSecretRef` in the central API namespace. Each remote request resolves +labeled `tlsSecretRef` in the credentials namespace. Each remote request resolves its Kubernetes client, GameServer UID and gateway mTLS credentials independently; unknown/missing routes never fall back locally. HTTP/WebSocket agent operations, RCON module actions and internal mod-update reads use the versioned gateway diff --git a/charts/gameplane/templates/_helpers.tpl b/charts/gameplane/templates/_helpers.tpl index 44bf31ee3..10f4fb4fe 100644 --- a/charts/gameplane/templates/_helpers.tpl +++ b/charts/gameplane/templates/_helpers.tpl @@ -182,9 +182,23 @@ mergeOverwrite preserves explicit zero/empty values so validation can reject the {{- uniq $namespaces | toJson -}} {{- end -}} +{{- /* Both native components resolve remote-cluster credentials here. Keep +the existing operator override for installations with a dedicated namespace. */ -}} +{{- define "gameplane.credentialsNamespace" -}} +{{- include "gameplane.namespace" (.Values.api.credentialsNamespace | default .Values.operator.controlPlaneNamespace | default (printf "%s-credentials" .Release.Namespace)) -}} +{{- end -}} + +{{- define "gameplane.operatorClusterCredentialsNamespace" -}} +{{- if and (eq (include "gameplane.apiEnabled" .) "true") (not .Values.api.standalone) -}} +{{- include "gameplane.credentialsNamespace" . -}} +{{- else -}} +{{- .Values.operator.controlPlaneNamespace | default .Release.Namespace -}} +{{- end -}} +{{- end -}} + {{- define "gameplane.operatorSecretNamespaces" -}} {{- $namespaces := include "gameplane.operatorGameNamespaces" . | fromJsonArray -}} -{{- range list (.Values.operator.controlPlaneNamespace | default .Release.Namespace) (.Values.operator.agentCASecretNamespace | default .Release.Namespace) (.Values.operator.moduleNamespace | default .Release.Namespace) -}} +{{- range list (include "gameplane.operatorClusterCredentialsNamespace" .) (.Values.operator.agentCASecretNamespace | default .Release.Namespace) (.Values.operator.moduleNamespace | default .Release.Namespace) -}} {{- $namespaces = append $namespaces (include "gameplane.namespace" .) -}} {{- end -}} {{- uniq $namespaces | toJson -}} diff --git a/charts/gameplane/templates/admission-policies.yaml b/charts/gameplane/templates/admission-policies.yaml new file mode 100644 index 000000000..35c3eb2ff --- /dev/null +++ b/charts/gameplane/templates/admission-policies.yaml @@ -0,0 +1,99 @@ +{{- $operator := eq (include "gameplane.operatorEnabled" .) "true" -}} +{{- $nativeAPI := and (eq (include "gameplane.apiEnabled" .) "true") (not .Values.api.standalone) -}} +{{- if or $operator $nativeAPI -}} +{{- /* Helm 3 client renders (even template --dry-run=server) can have only +group/versions in Capabilities. Always emit the policies for GitOps too. */ -}} +{{- if and (.Capabilities.APIVersions.Has "v1/Namespace") (not (.Capabilities.APIVersions.Has "admissionregistration.k8s.io/v1/ValidatingAdmissionPolicy")) -}} +{{- fail "native Gameplane requires Kubernetes 1.30+ with ValidatingAdmissionPolicy enabled" -}} +{{- end -}} +{{- $games := include "gameplane.operatorGameNamespaces" . | fromJsonArray -}} +{{- $protected := $games -}} +{{- if $operator -}} +{{- $protected = include "gameplane.operatorSecretNamespaces" . | fromJsonArray -}} +{{- else if $nativeAPI -}} +{{- $protected = append $protected (include "gameplane.credentialsNamespace" .) -}} +{{- end -}} +{{- $protected = append $protected .Release.Namespace -}} +{{- $tokens := printf "bound-tokens.%s.%s" .Release.Name .Release.Namespace -}} +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingAdmissionPolicy +metadata: + name: {{ $tokens }} + labels: {{- include "gameplane.labels" . | nindent 4 }} +spec: + failurePolicy: Fail + matchConstraints: + resourceRules: + - apiGroups: [""] + apiVersions: ["v1"] + operations: ["CREATE", "UPDATE"] + resources: ["secrets"] + scope: Namespaced + validations: + - expression: "!has(object.type) || object.type != 'kubernetes.io/service-account-token'" + message: "Use projected, expiring ServiceAccount tokens in Gameplane namespaces." + reason: Forbidden +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingAdmissionPolicyBinding +metadata: + name: {{ $tokens }} + labels: {{- include "gameplane.labels" . | nindent 4 }} +spec: + policyName: {{ $tokens }} + validationActions: [Deny] + matchResources: + namespaceSelector: + matchExpressions: + - key: kubernetes.io/metadata.name + operator: In + values: {{ uniq $protected | toJson }} +{{- if and $operator .Values.podSecurity.enforceRestricted }} +{{- $restricted := printf "restricted-games.%s.%s" .Release.Name .Release.Namespace }} +--- +# Additional managed namespaces belong to their administrator. Refuse game +# Pods if their PSA labels are missing or drift after the operator gains RBAC. +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingAdmissionPolicy +metadata: + name: {{ $restricted }} + labels: {{- include "gameplane.labels" . | nindent 4 }} +spec: + failurePolicy: Fail + matchConstraints: + resourceRules: + - apiGroups: [""] + apiVersions: ["v1"] + operations: ["CREATE"] + resources: ["pods"] + scope: Namespaced + - apiGroups: [""] + apiVersions: ["v1"] + operations: ["UPDATE"] + resources: ["pods/ephemeralcontainers"] + scope: Namespaced + validations: + - expression: >- + 'pod-security.kubernetes.io/enforce' in namespaceObject.metadata.labels && + namespaceObject.metadata.labels['pod-security.kubernetes.io/enforce'] == 'restricted' && + 'pod-security.kubernetes.io/enforce-version' in namespaceObject.metadata.labels && + namespaceObject.metadata.labels['pod-security.kubernetes.io/enforce-version'] == {{ .Values.podSecurity.version | default "v1.34" | quote }} + message: "Label the game namespace with Restricted Pod Security and the chart's configured enforce-version." + reason: Forbidden +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingAdmissionPolicyBinding +metadata: + name: {{ $restricted }} + labels: {{- include "gameplane.labels" . | nindent 4 }} +spec: + policyName: {{ $restricted }} + validationActions: [Deny] + matchResources: + namespaceSelector: + matchExpressions: + - key: kubernetes.io/metadata.name + operator: In + values: {{ $games | toJson }} +{{- end }} +{{- end }} diff --git a/charts/gameplane/templates/api-credentials.yaml b/charts/gameplane/templates/api-credentials.yaml new file mode 100644 index 000000000..5173394b3 --- /dev/null +++ b/charts/gameplane/templates/api-credentials.yaml @@ -0,0 +1,64 @@ +{{- if and (eq (include "gameplane.apiEnabled" .) "true") (not .Values.api.standalone) }} +{{- $namespace := include "gameplane.credentialsNamespace" . }} +{{- if not (or .Values.api.credentialsNamespace .Values.operator.controlPlaneNamespace) }} +apiVersion: v1 +kind: Namespace +metadata: + name: {{ $namespace }} + labels: {{- include "gameplane.labels" . | nindent 4 }} + annotations: + helm.sh/resource-policy: keep +--- +{{- end }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ .Release.Name }}-api-credentials + namespace: {{ $namespace }} + labels: {{- include "gameplane.labels" . | nindent 4 }} +rules: + - apiGroups: [""] + resources: [secrets] + verbs: [get, create, update, delete] + - apiGroups: [""] + resources: [configmaps] + verbs: [get, list, create, update, delete] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ .Release.Name }}-api-credentials + namespace: {{ $namespace }} + labels: {{- include "gameplane.labels" . | nindent 4 }} +roleRef: { apiGroup: rbac.authorization.k8s.io, kind: Role, name: {{ .Release.Name }}-api-credentials } +subjects: + - kind: ServiceAccount + name: gameplane-api + namespace: {{ .Release.Namespace }} +{{- if eq (include "gameplane.operatorEnabled" .) "true" }} +--- +# The operator creates, replaces and removes translated module ConfigMaps. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ .Release.Name }}-operator-module-configmaps + namespace: {{ $namespace }} + labels: {{- include "gameplane.labels" . | nindent 4 }} +rules: + - apiGroups: [""] + resources: [configmaps] + verbs: [create, update, delete] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ .Release.Name }}-operator-module-configmaps + namespace: {{ $namespace }} + labels: {{- include "gameplane.labels" . | nindent 4 }} +roleRef: { apiGroup: rbac.authorization.k8s.io, kind: Role, name: {{ .Release.Name }}-operator-module-configmaps } +subjects: + - kind: ServiceAccount + name: gameplane-operator + namespace: {{ .Release.Namespace }} +{{- end }} +{{- end }} diff --git a/charts/gameplane/templates/api.yaml b/charts/gameplane/templates/api.yaml index 1ba3e8209..2dc4b937f 100644 --- a/charts/gameplane/templates/api.yaml +++ b/charts/gameplane/templates/api.yaml @@ -139,35 +139,14 @@ subjects: name: gameplane-api namespace: {{ .Release.Namespace }} --- -# Module upload bundles are ConfigMaps in the control-plane namespace -# (api/internal/handlers/module_upload.go); the operator indexes them -# via upload-type ModuleSources. +# System logs are the only namespaced release resources the native API needs. apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: - name: {{ .Release.Name }}-api-uploads + name: {{ .Release.Name }}-api-logs namespace: {{ .Release.Namespace }} labels: {{- include "gameplane.labels" . | nindent 4 }} rules: - - apiGroups: [""] - resources: [configmaps] - verbs: [get, list, create, update, delete] - # Notification sink credentials, auth-provider clientSecrets, and - # mod-registry API keys (CurseForge/Steam/Nexus) all live as Secrets - # here in the control-plane namespace, under their own feature label - # (gameplane.local/notification-sink, gameplane.local/auth-provider, - # gameplane.local/mod-registry respectively). The API reads them at - # use time and — since each feature's Add/Set form accepts the secret - # value directly — also creates/updates/deletes them. RBAC can't - # filter by label, so the real boundary is in code: reads and updates - # refuse Secrets not labelled for the calling feature, and deletes - # additionally require gameplane.local/managed-by=gameplane-api so - # kubectl-/GitOps-created Secrets are never deleted over HTTP - # (api/internal/notify/sinks.go, api/internal/auth/registry.go, - # api/internal/registry/keys.go, api/internal/handlers/secrets_managed.go). - - apiGroups: [""] - resources: [secrets] - verbs: [get, list, create, update, patch, delete] # System logs admin endpoint (api/internal/handlers/systemlogs.go) # streams operator and API pod logs from the control-plane namespace. - apiGroups: [""] @@ -180,10 +159,10 @@ rules: apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: - name: {{ .Release.Name }}-api-uploads + name: {{ .Release.Name }}-api-logs namespace: {{ .Release.Namespace }} labels: {{- include "gameplane.labels" . | nindent 4 }} -roleRef: { apiGroup: rbac.authorization.k8s.io, kind: Role, name: {{ .Release.Name }}-api-uploads } +roleRef: { apiGroup: rbac.authorization.k8s.io, kind: Role, name: {{ .Release.Name }}-api-logs } subjects: - kind: ServiceAccount name: gameplane-api @@ -395,6 +374,10 @@ spec: - name: GAMEPLANE_NAMESPACE valueFrom: fieldRef: { fieldPath: metadata.namespace } + {{- if not .Values.api.standalone }} + - name: GAMEPLANE_CREDENTIALS_NAMESPACE + value: {{ include "gameplane.credentialsNamespace" . | quote }} + {{- end }} # Mirror --db-driver/--db-dsn for subcommands run with # `kubectl exec deploy/gameplane-api -- /api bootstrap-admin`: # they don't see the container args, so without these they fall diff --git a/charts/gameplane/templates/namespaces.yaml b/charts/gameplane/templates/namespaces.yaml index b73cce0bd..cf6204696 100644 --- a/charts/gameplane/templates/namespaces.yaml +++ b/charts/gameplane/templates/namespaces.yaml @@ -8,6 +8,10 @@ metadata: {{- if .Values.podSecurity.enforceRestricted }} pod-security.kubernetes.io/enforce: restricted pod-security.kubernetes.io/enforce-version: {{ .Values.podSecurity.version | default "v1.34" | quote }} + pod-security.kubernetes.io/warn: restricted + pod-security.kubernetes.io/warn-version: {{ .Values.podSecurity.version | default "v1.34" | quote }} + pod-security.kubernetes.io/audit: restricted + pod-security.kubernetes.io/audit-version: {{ .Values.podSecurity.version | default "v1.34" | quote }} {{- end }} annotations: # F-212: exclude the games Namespace from Helm's ownership so diff --git a/charts/gameplane/templates/operator.yaml b/charts/gameplane/templates/operator.yaml index 8e9427327..30c9f047c 100644 --- a/charts/gameplane/templates/operator.yaml +++ b/charts/gameplane/templates/operator.yaml @@ -377,8 +377,11 @@ spec: - --agent-client-key=/etc/gameplane/agent-client/tls.key - --agent-ca-secret-name={{ .Values.api.agentMTLS.caSecretRef.name }} - --game-namespaces={{ join "," $gameNamespaces }} - - --control-plane-namespace={{ .Values.operator.controlPlaneNamespace | default .Release.Namespace }} + - --control-plane-namespace={{ include "gameplane.operatorClusterCredentialsNamespace" . }} - --agent-ca-secret-namespace={{ .Values.operator.agentCASecretNamespace | default .Release.Namespace }} + {{- if and (eq (include "gameplane.apiEnabled" .) "true") (not .Values.api.standalone) }} + - --module-configmap-namespace={{ include "gameplane.credentialsNamespace" . }} + {{- end }} - --module-namespace={{ .Values.operator.moduleNamespace | default .Release.Namespace }} - --restricted-workloads={{ .Values.podSecurity.enforceRestricted }} - --agent-token-expiration-seconds={{ .Values.operator.agentTokenExpirationSeconds | default 3600 }} diff --git a/charts/gameplane/templates/validate-api-migration.yaml b/charts/gameplane/templates/validate-api-migration.yaml new file mode 100644 index 000000000..6e3424b7b --- /dev/null +++ b/charts/gameplane/templates/validate-api-migration.yaml @@ -0,0 +1,60 @@ +{{- /* Only inspect source objects while a live native API changes namespace. +Never put credential material in rendered manifests or error messages. */ -}} +{{- if and .Release.IsUpgrade (eq (include "gameplane.apiEnabled" .) "true") (not .Values.api.standalone) }} +{{- $target := include "gameplane.credentialsNamespace" . }} +{{- $previous := $target }} +{{- $api := lookup "apps/v1" "Deployment" .Release.Namespace "gameplane-api" }} +{{- range $container := dig "spec" "template" "spec" "containers" list $api }} + {{- if and (eq $container.name "api") (not (has "--standalone" ($container.args | default list))) }} + {{- $previous = $.Release.Namespace }} + {{- range $env := $container.env }} + {{- if eq $env.name "GAMEPLANE_CREDENTIALS_NAMESPACE" }}{{ $previous = $env.value }}{{ end }} + {{- end }} + {{- end }} +{{- end }} +{{- if ne $previous $target }} + {{- range $secret := (lookup "v1" "Secret" $previous "").items }} + {{- range $feature := list "auth-provider" "notification-sink" "mod-registry" "cluster-kubeconfig" "agent-gateway-credentials" }} + {{- $label := printf "gameplane.local/%s" $feature }} + {{- if eq (index ($secret.metadata.labels | default dict) $label) "true" }} + {{- $copy := lookup "v1" "Secret" $target $secret.metadata.name }} + {{- $labels := dig "metadata" "labels" dict $copy }} + {{- if or (not $copy) (ne (index $labels $label) "true") (ne (index $secret.metadata.labels "gameplane.local/managed-by") (index $labels "gameplane.local/managed-by")) (ne $secret.type $copy.type) (not (deepEqual $secret.data $copy.data)) }} + {{- fail (printf "Migrate Secret %s/%s (%s) with its type, data and management labels to %s before upgrading; see docs/install.md#upgrading-native-api-credentials" $previous $secret.metadata.name $feature $target) }} + {{- end }} + {{- end }} + {{- end }} + {{- end }} + {{- $sources := list $previous }} + {{- $operator := lookup "apps/v1" "Deployment" .Release.Namespace "gameplane-operator" }} + {{- range $container := dig "spec" "template" "spec" "containers" list $operator }} + {{- $moduleNS := "" }} + {{- $configNS := "" }} + {{- range $arg := $container.args }} + {{- if hasPrefix "--module-namespace=" $arg }}{{ $moduleNS = trimPrefix "--module-namespace=" $arg }}{{ end }} + {{- if hasPrefix "--module-configmap-namespace=" $arg }}{{ $configNS = trimPrefix "--module-configmap-namespace=" $arg }}{{ end }} + {{- end }} + {{- with ($configNS | default $moduleNS) }}{{ $sources = append $sources . }}{{ end }} + {{- end }} + {{- range $source := uniq $sources }} + {{- range $cm := (lookup "v1" "ConfigMap" $source "").items }} + {{- $upload := eq (index ($cm.metadata.labels | default dict) "gameplane.local/module-upload") "true" }} + {{- $locale := false }} + {{- range $owner := $cm.metadata.ownerReferences }} + {{- if and $owner.controller (hasPrefix "gameplane.local/" $owner.apiVersion) (eq $owner.kind "GameTemplate") (eq $cm.metadata.name (printf "%s-locales" $owner.name)) }} + {{- $locale = true }} + {{- end }} + {{- end }} + {{- if or $upload $locale }} + {{- $copy := lookup "v1" "ConfigMap" $target $cm.metadata.name }} + {{- $labels := dig "metadata" "labels" dict $copy }} + {{- $uploadLabelsMatch := or (not $upload) (and (eq (index $labels "gameplane.local/module-upload") "true") (eq (index $labels "gameplane.local/module-name") (index $cm.metadata.labels "gameplane.local/module-name"))) }} + {{- $localeOwnerMatches := or (not $locale) (deepEqual $cm.metadata.ownerReferences (dig "metadata" "ownerReferences" list $copy)) }} + {{- if or (not $copy) (not $uploadLabelsMatch) (not $localeOwnerMatches) (not (deepEqual $cm.data $copy.data)) (not (deepEqual $cm.binaryData $copy.binaryData)) }} + {{- fail (printf "Migrate module ConfigMap %s/%s with its data, upload labels or template ownership to %s before upgrading; see docs/install.md#upgrading-native-api-credentials" $source $cm.metadata.name $target) }} + {{- end }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} +{{- end }} diff --git a/charts/gameplane/templates/validate.yaml b/charts/gameplane/templates/validate.yaml index 580f8ae3d..536c18c47 100644 --- a/charts/gameplane/templates/validate.yaml +++ b/charts/gameplane/templates/validate.yaml @@ -31,6 +31,22 @@ {{- end }} {{- end }} {{- $diagnostics := include "gameplane.nodeDiagnostics" . | fromJson }} +{{- if and (eq (include "gameplane.apiEnabled" .) "true") (not .Values.api.standalone) }} +{{- $credentials := include "gameplane.credentialsNamespace" . }} +{{- if and .Values.api.credentialsNamespace .Values.operator.controlPlaneNamespace (ne .Values.api.credentialsNamespace .Values.operator.controlPlaneNamespace) }} +{{- fail "api.credentialsNamespace and operator.controlPlaneNamespace must reference the same dedicated namespace for cluster credentials" }} +{{- end }} +{{- $games := include "gameplane.operatorGameNamespaces" . | fromJsonArray }} +{{- $protected := list .Release.Namespace (.Values.operator.agentCASecretNamespace | default .Release.Namespace) (.Values.operator.moduleNamespace | default .Release.Namespace) "default" "kube-system" "kube-public" "kube-node-lease" }} +{{- range $ns := append $games $credentials }} +{{- if has $ns $protected }} +{{- fail (printf "API-writable Secrets require a dedicated namespace; %s overlaps a protected namespace" $ns) }} +{{- end }} +{{- end }} +{{- if has $credentials $games }} +{{- fail "api.credentialsNamespace must be a dedicated namespace separate from gamesNamespace and operator.gameNamespaces" }} +{{- end }} +{{- end }} {{- if $diagnostics.enabled }} {{- if or (not .Values.capture.enabled) (ne (include "gameplane.operatorEnabled" .) "true") }} {{- fail "capture.nodeDiagnostics requires capture.enabled and the operator" }} diff --git a/charts/gameplane/values.yaml b/charts/gameplane/values.yaml index 503c95481..03cef2ac8 100644 --- a/charts/gameplane/values.yaml +++ b/charts/gameplane/values.yaml @@ -68,13 +68,16 @@ operator: priorityClassName: "" pdb: { enabled: false } # optional minAvailable OR maxUnavailable; defaults to maxUnavailable: 1 # Additional managed game namespaces, beyond gamesNamespace. They must already - # exist. The chart grants namespace-local workload permissions and includes - # their Secrets in the operator cache. A Helm upgrade restarts the operator. + # exist. Label each with Restricted enforcement and the podSecurity.version + # pin before enabling workloads. The chart grants namespace-local workload + # permissions and includes their Secrets in the operator cache. gameNamespaces: [] + # Remote-cluster kubeconfigs. A native API shares api.credentialsNamespace. + # With no native API, empty uses the release namespace. + controlPlaneNamespace: "" # Empty uses the release namespace. Non-default namespaces must already exist; # provision the referenced Secrets there before upgrading. These settings move # the operator's API reads, not the API pod or its mounted mTLS client material. - controlPlaneNamespace: "" agentCASecretNamespace: "" moduleNamespace: "" logLevel: info # zap log level: debug|info|error (warn not supported by zap's --zap-log-level) @@ -168,6 +171,10 @@ api: # Requires operator.enabled=false, which excludes bundled native CRDs. # Standalone mode also skips the CRD apply hook and its RBAC. standalone: false + # Native API management Secrets. Empty creates -credentials + # unless operator.controlPlaneNamespace already names an external namespace. + # An explicit namespace must already exist and be dedicated to these Secrets. + credentialsNamespace: "" # Must be 1 in every enabled API install, including PostgreSQL. Audit-chain # and user-management locks are per-process. Recreate rollouts stop the old # API before starting its replacement and accept a brief panel outage. @@ -752,7 +759,7 @@ grafanaDashboards: grafana_dashboard: "1" podSecurity: - enforceRestricted: false # require compatible nonroot templates and enforce Restricted game pods + enforceRestricted: true # require compatible nonroot templates and enforce Restricted game pods version: v1.34 # pinned admission policy; change after testing workload compatibility # Default ModuleSource shipped with the chart. Disable when you manage diff --git a/charts/tests/check_gateway.py b/charts/tests/check_gateway.py index 819e46979..c38f272df 100644 --- a/charts/tests/check_gateway.py +++ b/charts/tests/check_gateway.py @@ -9,7 +9,7 @@ CHART = ROOT / "charts" / "gameplane" -def render(values=None, failure=None, upgrade=False, namespace="gameplane-system", release="gameplane", include_crds=False, chart=CHART): +def render(values=None, failure=None, upgrade=False, namespace="gameplane-system", release="gameplane", include_crds=False, chart=CHART, api_versions=()): """Return rendered objects, or assert the expected Helm validation failure. Accept a source chart or packaged archive; native CRDs are opt-in to match @@ -21,7 +21,8 @@ def render(values=None, failure=None, upgrade=False, namespace="gameplane-system result = subprocess.run( ["helm", "template", release, str(chart), "--namespace", namespace, "--values", str(values_path)] + (["--is-upgrade"] if upgrade else []) - + (["--include-crds"] if include_crds else []), + + (["--include-crds"] if include_crds else []) + + [arg for version in api_versions for arg in ("--api-versions", version)], capture_output=True, text=True, check=False, ) if failure: diff --git a/charts/tests/check_operator_secrets.py b/charts/tests/check_operator_secrets.py index d248a52de..edb2b7b33 100644 --- a/charts/tests/check_operator_secrets.py +++ b/charts/tests/check_operator_secrets.py @@ -37,7 +37,7 @@ def check(values, expected_games, expected_secrets, namespace="gameplane-system" def main(): - check({}, {"gameplane-games"}, {"gameplane-games", "gameplane-system"}) + check({}, {"gameplane-games"}, {"gameplane-games", "gameplane-system", "gameplane-system-credentials"}) check({"gamesNamespace": "games-one", "operator": {"gameNamespaces": ["games-two", "games-one", "games-two"], "controlPlaneNamespace": "credentials", "agentCASecretNamespace": "certificates", "moduleNamespace": "catalog"}}, {"games-one", "games-two"}, {"games-one", "games-two", "credentials", "certificates", "catalog"}, namespace="management") @@ -45,6 +45,34 @@ def main(): render({"operator": {"gameNamespaces": [invalid]}}, failure="explicit DNS-label namespace") render({"gamesNamespace": ""}, failure="explicit DNS-label namespace") render({"operator": {"moduleNamespace": "bad/name"}}, failure="explicit DNS-label namespace") + for values, expected_namespace in [({}, "management-credentials"), + ({"api": {"credentialsNamespace": "panel-creds"}}, "panel-creds")]: + objects = render(values, namespace="management") + api = one(objects, "Deployment", "gameplane-api")["spec"]["template"]["spec"]["containers"][0] + assert {"name": "GAMEPLANE_CREDENTIALS_NAMESPACE", "value": expected_namespace} in api["env"] + operator = one(objects, "Deployment", "gameplane-operator")["spec"]["template"]["spec"]["containers"][0] + assert "--control-plane-namespace=" + expected_namespace in operator["args"] + assert "--module-configmap-namespace=" + expected_namespace in operator["args"] + assert all(set(rule["resources"]) <= {"pods", "pods/log"} for rule in one(objects, "Role", "gameplane-api-logs")["rules"]) + role = one(objects, "Role", "gameplane-api-credentials") + assert role["metadata"]["namespace"] == expected_namespace + assert role["rules"] == [ + {"apiGroups": [""], "resources": ["secrets"], "verbs": ["get", "create", "update", "delete"]}, + {"apiGroups": [""], "resources": ["configmaps"], "verbs": ["get", "list", "create", "update", "delete"]}, + ] + binding = one(objects, "RoleBinding", "gameplane-api-credentials") + assert binding["metadata"]["namespace"] == expected_namespace + assert binding["roleRef"]["name"] == role["metadata"]["name"] + assert binding["subjects"] == [{"kind": "ServiceAccount", "name": "gameplane-api", "namespace": "management"}] + namespaces = {obj["metadata"]["name"] for obj in objects if obj["kind"] == "Namespace"} + assert (expected_namespace in namespaces) == (not values) + for values in ({"api": {"credentialsNamespace": "gameplane-system"}}, + {"operator": {"gameNamespaces": ["gameplane-system"]}}, + {"operator": {"agentCASecretNamespace": "gameplane-games"}}, + {"operator": {"agentCASecretNamespace": "gameplane-system-credentials"}}, + {"api": {"credentialsNamespace": "gameplane-games"}}, + {"api": {"credentialsNamespace": "kube-system"}}): + render(values, failure="dedicated namespace") # The development install must not regain global Secret access after codegen. root = Path(__file__).resolve().parents[2] dev = list(yaml.safe_load_all((root / "operator/config/rbac/role.yaml").read_text())) diff --git a/charts/tests/check_workload_security.py b/charts/tests/check_workload_security.py index 9c64ce9d2..897d04aab 100644 --- a/charts/tests/check_workload_security.py +++ b/charts/tests/check_workload_security.py @@ -1,4 +1,4 @@ -"""CI-only chart checks for the opt-in Restricted workload and node exception.""" +"""CI-only chart checks for game workload and credential admission.""" from pathlib import Path import shutil import tempfile @@ -47,16 +47,56 @@ def check_namespace_separation(): one(objects, "Namespace", "gameplane-diagnostics") +def check_admission_policies(): + def bound_namespaces(objects, name, resource_rules): + policy = one(objects, "ValidatingAdmissionPolicy", name) + assert policy["apiVersion"] == "admissionregistration.k8s.io/v1" + assert policy["spec"]["failurePolicy"] == "Fail" + assert policy["spec"]["matchConstraints"]["resourceRules"] == resource_rules + binding = one(objects, "ValidatingAdmissionPolicyBinding", name)["spec"] + assert binding["policyName"] == name and binding["validationActions"] == ["Deny"] + requirement, = binding["matchResources"]["namespaceSelector"]["matchExpressions"] + assert requirement["key"] == "kubernetes.io/metadata.name" and requirement["operator"] == "In" + return set(requirement["values"]) + + games = {"games-main", "games-eu"} + values = {"gamesNamespace": "games-main", "operator": {"gameNamespaces": ["games-eu", "games-main"], + "agentCASecretNamespace": "certificates", "moduleNamespace": "modules"}} + objects = render(values, namespace="panel") + secret_rule = [{"apiGroups": [""], "apiVersions": ["v1"], "operations": ["CREATE", "UPDATE"], + "resources": ["secrets"], "scope": "Namespaced"}] + protected = games | {"panel", "certificates", "modules"} + assert bound_namespaces(objects, "bound-tokens.gameplane.panel", secret_rule) == protected | {"panel-credentials"} + assert bound_namespaces(objects, "restricted-games.gameplane.panel", [ + {"apiGroups": [""], "apiVersions": ["v1"], "operations": ["CREATE"], + "resources": ["pods"], "scope": "Namespaced"}, + {"apiGroups": [""], "apiVersions": ["v1"], "operations": ["UPDATE"], + "resources": ["pods/ephemeralcontainers"], "scope": "Namespaced"}, + ]) == games + assert not any(o["kind"] == "Namespace" and o["metadata"]["name"] == "games-eu" for o in objects) + + # A legacy workload profile can still be used during a data migration. + # Persistent token issuance must stay blocked there, including remote operators. + legacy = render({**values, "api": {"enabled": False}, "podSecurity": {"enforceRestricted": False}}, namespace="panel") + assert bound_namespaces(legacy, "bound-tokens.gameplane.panel", secret_rule) == protected + assert "pod-security.kubernetes.io/enforce" not in one(legacy, "Namespace", "games-main")["metadata"]["labels"] + assert not any(o["metadata"]["name"] == "restricted-games.gameplane.panel" for o in legacy) + # A live pre-1.30 cluster reports Namespace discovery without the GA policy. + render(api_versions=["v1/Namespace"], failure="requires Kubernetes 1.30+") + central = render({"api": {"standalone": True}, "operator": {"enabled": False}}, api_versions=["v1/Namespace"]) + assert not any(o["kind"].startswith("ValidatingAdmissionPolicy") for o in central) + + def main(): + check_admission_policies() check_reuse_values() check_namespace_separation() defaults = render() assert not any(o["metadata"]["name"] == "gameplane-diagnostics" for o in defaults) - objects = render({"podSecurity": {"enforceRestricted": True}}) - games = one(objects, "Namespace", "gameplane-games") + games = one(defaults, "Namespace", "gameplane-games") assert games["metadata"]["labels"]["pod-security.kubernetes.io/enforce"] == "restricted" assert games["metadata"]["labels"]["pod-security.kubernetes.io/enforce-version"] == "v1.34" - args = one(objects, "Deployment", "gameplane-operator")["spec"]["template"]["spec"]["containers"][0]["args"] + args = one(defaults, "Deployment", "gameplane-operator")["spec"]["template"]["spec"]["containers"][0]["args"] assert "--restricted-workloads=true" in args # even when capture is disabled assert not any(a.startswith("--diagnostics-namespace=") for a in args) values = {"podSecurity": {"enforceRestricted": True, "version": "v1.35"}, diff --git a/deploy/kind/e2e.sh b/deploy/kind/e2e.sh index 0830fbf19..cb65e17b9 100755 --- a/deploy/kind/e2e.sh +++ b/deploy/kind/e2e.sh @@ -423,6 +423,8 @@ EOF # would only add minutes, and `--wait` would block on an unloaded image. # Deploy the bundled telemetry receiver and point the API at it: never let # CI or dev installs reach the project provider (spec 022 R17). + # The legacy fixtures exercise Hostport, in-pod capture and root init. + # The restricted-workloads bucket upgrades this same installation to PSA. helm upgrade --install gameplane "${CHART_DIR}" \ --namespace gameplane-system --create-namespace \ --set "image.registry=gameplane-test" \ @@ -431,6 +433,7 @@ EOF --set "web.enabled=false" \ --set "operator.agentImage=gameplane-test/agent:${TAG}" \ --set "operator.sentinelImage=gameplane-test/sentinel:${TAG}" \ + --set "podSecurity.enforceRestricted=false" \ --set "capture.enabled=true" \ --set "capture.image=gameplane-test/capture-sidecar:${TAG}" \ --set "api.resources.limits.memory=1Gi" \ diff --git a/docs/install.md b/docs/install.md index f0b5767d7..a9a0b83c3 100644 --- a/docs/install.md +++ b/docs/install.md @@ -7,7 +7,7 @@ installation, with an operator and a local game cluster. ## Prerequisites -- Kubernetes 1.28+ +- Kubernetes 1.30+ with ValidatingAdmissionPolicy and PodSecurity admission enabled - Helm 3.13+ - A default StorageClass (any RWO CSI driver works) - Optional: an ingress controller (nginx-ingress by default) + cert-manager for TLS @@ -106,6 +106,73 @@ password, promote them to `admin`, and end their existing sessions. Open `https://` and log in. +## Upgrading native API credentials + +The native API now reads dashboard OIDC client secrets, registry keys, notification +credentials, remote kubeconfigs and gateway client credentials from a dedicated +namespace. The release namespace holds the operator identity, the agent signer and +the Secrets referenced by pod volumes or environment variables (including Helm's +OIDC client secret). Backup repositories and tunnel credentials belong to their +game namespace. Standalone panels continue to use their encrypted SQL store. + +For an existing native installation: + +1. Schedule a panel maintenance window and keep cluster-admin access and a tested + local panel login available. Freeze credential edits. Before running Helm's + privileged pre-upgrade hooks, delete the old release RoleBinding + `-api-uploads` and its Role in the release namespace. This revokes + the old API's access to release Secrets and upload ConfigMaps during cutover. + Helm recreates the narrower grants. Check for additional grants with + `kubectl auth can-i get secret/gameplane-agent-ca -n gameplane-system --as=system:serviceaccount:gameplane-system:gameplane-api`; + the answer must be `no`. Substitute your release namespace and signer name. +2. Inventory long-lived tokens with + `kubectl get secrets -A --field-selector type=kubernetes.io/service-account-token -o custom-columns=NAMESPACE:.metadata.namespace,SECRET:.metadata.name`. + Replace consumers with projected or TokenRequest credentials, then delete each + old token Secret in the release, credential, signer, module and managed game + namespaces. Admission prevents new token Secrets and updates to existing ones. + If the old API credential was compromised, rotate previously readable + credentials, including the agent CA and its issued certificates. +3. Inventory the five feature labels in the old API namespace. For a standard + release, these commands show the names without printing credential values: + + ```sh + for feature in auth-provider mod-registry notification-sink cluster-kubeconfig agent-gateway-credentials; do + kubectl -n gameplane-system get secret -l "gameplane.local/$feature=true" -o name + done + ``` + + Also inventory uploaded bundles with + `kubectl -n gameplane-system get configmap -l gameplane.local/module-upload=true -o name`. + Check the old `operator.moduleNamespace` as well if you overrode it. + If you have objects to move, pre-create a dedicated namespace, set + `api.credentialsNamespace` to its name, and keep that value on future + upgrades. Provision each reviewed Secret there with the same name, type, + data, feature labels and `gameplane.local/managed-by` label. Review individually + any Secret also used by a pod. Keep signing keys, unrelated credentials, + Helm release history, workloads and privileged ServiceAccounts outside + this namespace. Copy uploaded ConfigMaps with their original names, labels, + `data` and `binaryData`. Module locale ConfigMaps (`