diff --git a/FEATURES.md b/FEATURES.md index 5c994fc7e..294e9f230 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 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 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..a4a08e944 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,17 @@ type config struct { trustedProxies []string } +// managementNamespaces keeps native module ConfigMaps with API-managed Secrets. +// In standalone mode the first name scopes encrypted records on the panel, while +// --namespace locates module ConfigMaps on the selected remote cluster. +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 +677,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..4096e8a0b 100644 --- a/api/cmd/main_test.go +++ b/api/cmd/main_test.go @@ -1,11 +1,41 @@ package main import ( + "flag" "log/slog" "net/netip" "testing" ) +// TestManagementNamespaces pins the standalone lookup namespace used in existing +// credential records when an installation has no explicit credentials namespace. +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/config_redact_test.go b/api/internal/handlers/config_redact_test.go index 149b4c3d3..fdb0af486 100644 --- a/api/internal/handlers/config_redact_test.go +++ b/api/internal/handlers/config_redact_test.go @@ -451,7 +451,7 @@ func TestRedact_EventsStreamHonoursTemplateChangeBetweenEvents(t *testing.T) { map[string]any{"name": "TOKEN", "type": "string"}, }} k := fleetTestClient(tmpl) - fw := watch.NewFake() + fw := watch.NewFakeWithOptions(watch.FakeOptions{ChannelSize: 1}) k.Dynamic.(*dynamicfake.FakeDynamicClient).PrependWatchReactor("gameservers", func(_ clienttesting.Action) (bool, watch.Interface, error) { return true, fw, nil }) reg := kube.NewRegistry(scope.DefaultCluster) @@ -467,20 +467,36 @@ func TestRedact_EventsStreamHonoursTemplateChangeBetweenEvents(t *testing.T) { eventsHandler(reg)(w, req) }() - waitFrames := func(n int) { + waitServerEvent := func(eventType watch.EventType) string { t.Helper() deadline := time.Now().Add(5 * time.Second) - for strings.Count(w.String(), "data:") < n { - if time.Now().After(deadline) { - cancel() - t.Fatalf("want %d SSE frames, got: %s", n, w.String()) + for time.Now().Before(deadline) { + for line := range strings.SplitSeq(w.String(), "\n") { + data, ok := strings.CutPrefix(line, "data: ") + if !ok { + continue + } + var event struct { + Kind string `json:"kind"` + EventType watch.EventType `json:"eventType"` + } + if err := json.Unmarshal([]byte(data), &event); err != nil { + t.Fatalf("decode SSE frame: %v", err) + } + if event.Kind == "servers" && event.EventType == eventType { + return data + } } time.Sleep(10 * time.Millisecond) } + t.Fatalf("no %s server event in stream: %s", eventType, w.String()) + return "" } - go fw.Add(serverWithConfig("alpha", map[string]any{"TOKEN": "tok-first"})) - waitFrames(1) + fw.Add(serverWithConfig("alpha", map[string]any{"TOKEN": "tok-first"})) + if first := waitServerEvent(watch.Added); !strings.Contains(first, "tok-first") { + t.Fatalf("first server event lost the plain value: %s", first) + } updated := tmpl.DeepCopy() updated.Object["spec"] = map[string]any{"configSchema": []any{ @@ -491,14 +507,17 @@ func TestRedact_EventsStreamHonoursTemplateChangeBetweenEvents(t *testing.T) { t.Fatalf("update template: %v", err) } - go fw.Modify(serverWithConfig("alpha", map[string]any{"TOKEN": "tok-second"})) - waitFrames(2) + fw.Modify(serverWithConfig("alpha", map[string]any{"TOKEN": "tok-second"})) + out := waitServerEvent(watch.Modified) cancel() - <-done + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("events handler did not stop after cancellation") + } - out := w.String() - if strings.Contains(out, "tok-second") { - t.Fatalf("second event leaked a value that became a password: %s", out) + if strings.Contains(w.String(), "tok-second") { + t.Fatalf("stream leaked a value that became a password: %s", w.String()) } if !strings.Contains(out, configRedactedMarker) { t.Fatalf("second event has no redaction marker: %s", out) 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 469452d18..cf4f052a5 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/changelog/unreleased/886-native-credential-boundary.md b/changelog/unreleased/886-native-credential-boundary.md new file mode 100644 index 000000000..37470fdc4 --- /dev/null +++ b/changelog/unreleased/886-native-credential-boundary.md @@ -0,0 +1 @@ +The Gameplane chart now requires Kubernetes 1.30 or later. The native API stores its credentials and uploaded module ConfigMaps in a dedicated namespace. Before upgrading, migrate existing credentials, uploads and module locale ConfigMaps, remove legacy service-account token Secrets, and bind each private HTTP or Git module credential to its exact source URL. Follow the [native credential upgrade instructions](../../docs/install.md#upgrading-native-api-credentials). diff --git a/charts/gameplane/Chart.yaml b/charts/gameplane/Chart.yaml index ec4dd33a2..ba71cc1a8 100644 --- a/charts/gameplane/Chart.yaml +++ b/charts/gameplane/Chart.yaml @@ -4,6 +4,7 @@ description: Kubernetes-native game server control panel (open-source alternativ type: application version: 0.4.0 appVersion: "0.4.0" +kubeVersion: ">=1.30.0-0" home: https://github.com/GameplanePanel/gameplane sources: - https://github.com/GameplanePanel/gameplane diff --git a/charts/gameplane/templates/_helpers.tpl b/charts/gameplane/templates/_helpers.tpl index 44bf31ee3..ad18319b0 100644 --- a/charts/gameplane/templates/_helpers.tpl +++ b/charts/gameplane/templates/_helpers.tpl @@ -182,9 +182,27 @@ 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" -}} +{{- $generated := printf "%s-credentials" .Release.Namespace -}} +{{- if gt (len $generated) 63 -}} +{{- $generated = printf "%s-credentials-%s" (trunc 38 .Release.Namespace | trimSuffix "-") (sha256sum .Release.Namespace | trunc 12) -}} +{{- end -}} +{{- include "gameplane.namespace" (.Values.api.credentialsNamespace | default .Values.operator.controlPlaneNamespace | default $generated) -}} +{{- 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 8912750b2..9f0d206d5 100644 --- a/charts/gameplane/templates/operator.yaml +++ b/charts/gameplane/templates/operator.yaml @@ -383,8 +383,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..68c6d951f --- /dev/null +++ b/charts/gameplane/templates/validate-api-migration.yaml @@ -0,0 +1,78 @@ +{{- /* Discover the API and operator's live sources independently. +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" . }} +{{- $secretSources := dict }} +{{- $moduleSources := list }} +{{- $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 }} + {{- $_ := set $secretSources $previous (list "auth-provider" "notification-sink" "mod-registry" "cluster-kubeconfig" "agent-gateway-credentials") }} + {{- $moduleSources = append $moduleSources $previous }} + {{- end }} +{{- end }} +{{- $operator := lookup "apps/v1" "Deployment" .Release.Namespace "gameplane-operator" }} +{{- range $container := dig "spec" "template" "spec" "containers" list $operator }} + {{- if eq $container.name "manager" }} + {{- /* The v0.3 chart supplied POD_NAMESPACE via the downward API before + it passed --control-plane-namespace explicitly. */ -}} + {{- $clusterNS := $.Release.Namespace }} + {{- $moduleNS := "gameplane-system" }} + {{- $configNS := "" }} + {{- range $arg := $container.args }} + {{- if hasPrefix "--control-plane-namespace=" $arg }}{{ $clusterNS = trimPrefix "--control-plane-namespace=" $arg }}{{ end }} + {{- 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 }} + {{- if not (hasKey $secretSources $clusterNS) }} + {{- $_ := set $secretSources $clusterNS (list "cluster-kubeconfig") }} + {{- end }} + {{- $moduleSources = append $moduleSources ($configNS | default $moduleNS) }} + {{- end }} +{{- end }} +{{- range $previous, $features := omit $secretSources $target }} + {{- range $secret := (lookup "v1" "Secret" $previous "").items }} + {{- range $feature := $features }} + {{- $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) + (ne ($secret.immutable | default false) ($copy.immutable | default false)) + (not (deepEqual $secret.data $copy.data)) }} + {{- fail (printf "Migrate Secret %s/%s (%s) with its type, data, immutable setting 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 }} +{{- end }} +{{- range $source := without (uniq $moduleSources) $target }} + {{- 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) + (ne ($cm.immutable | default false) ($copy.immutable | default false)) + (not (deepEqual $cm.data $copy.data)) (not (deepEqual $cm.binaryData $copy.binaryData)) }} + {{- fail (printf "Migrate module ConfigMap %s/%s with its data, immutable setting, 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 }} diff --git a/charts/gameplane/templates/validate.yaml b/charts/gameplane/templates/validate.yaml index 580f8ae3d..e96f3bc26 100644 --- a/charts/gameplane/templates/validate.yaml +++ b/charts/gameplane/templates/validate.yaml @@ -31,6 +31,25 @@ {{- end }} {{- end }} {{- $diagnostics := include "gameplane.nodeDiagnostics" . | fromJson }} +{{- $nativeAPI := and (eq (include "gameplane.apiEnabled" .) "true") (not .Values.api.standalone) }} +{{- if or $nativeAPI (eq (include "gameplane.operatorEnabled" .) "true") }} +{{- $protected := list .Release.Namespace (.Values.operator.agentCASecretNamespace | default .Release.Namespace) (.Values.operator.moduleNamespace | default .Release.Namespace) "default" "kube-system" "kube-public" "kube-node-lease" }} +{{- $credentials := include "gameplane.operatorClusterCredentialsNamespace" . }} +{{- if $nativeAPI }} +{{- 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 }} +{{- if has $credentials $protected }} +{{- fail (printf "API-writable Secrets require a dedicated namespace. %s overlaps a protected namespace" $credentials) }} +{{- end }} +{{- end }} +{{- $protected = append $protected $credentials }} +{{- range $ns := include "gameplane.operatorGameNamespaces" . | fromJsonArray }} +{{- if has $ns $protected }} +{{- fail (printf "Game workloads require a dedicated namespace. %s overlaps a protected namespace" $ns) }} +{{- end }} +{{- 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 24db05d95..d20745fd9 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) @@ -180,6 +183,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. @@ -764,7 +771,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..9a3725453 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=(), kube_version=None): """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,9 @@ 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 []) + + (["--kube-version", kube_version] if kube_version 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..ad327f7cd 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,54 @@ 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") + # Operator-only releases apply admission and workload RBAC to every game namespace. + for values in ({"operator": {"gameNamespaces": ["kube-system"]}}, + {"gamesNamespace": "gameplane-system"}, + {"operator": {"gameNamespaces": ["cluster-creds"], "controlPlaneNamespace": "cluster-creds"}}, + {"operator": {"moduleNamespace": "gameplane-games"}}, + {"operator": {"agentCASecretNamespace": "gameplane-games"}}): + render({"api": {"enabled": False}, **values}, failure="dedicated namespace") + check({"api": {"enabled": False}, "operator": {"gameNamespaces": ["remote-games"]}}, + {"gameplane-games", "remote-games"}, {"gameplane-games", "remote-games", "gameplane-system"}) + # Valid release namespaces may already occupy the entire DNS label. + # Two sharing a long prefix must still get distinct credential stores. + long_targets = set() + for suffix in ("a", "b"): + release_namespace = "m" * 62 + suffix + objects = render(namespace=release_namespace) + target = one(objects, "Role", "gameplane-api-credentials")["metadata"]["namespace"] + assert len(target) <= 63 and target != release_namespace + one(objects, "Namespace", target) + long_targets.add(target) + assert len(long_targets) == 2 # 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..6114a81ff 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,58 @@ def check_namespace_separation(): one(objects, "Namespace", "gameplane-diagnostics") +def check_admission_policies(): + """Check that token protection covers credential stores even when game Pod restrictions are disabled.""" + 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"}} + render(values, kube_version="1.29.99", failure="kubeVersion: >=1.30.0-0") + objects = render(values, namespace="panel", kube_version="1.30.0") + 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 cluster at the minimum version must still serve the GA policy API. + render(kube_version="1.30.0", 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 803bf5085..68dfff30e 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,85 @@ 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 `-api-uploads` + RoleBinding and Role from the release namespace if present. 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`. + If the release already runs an operator, check its current + `--control-plane-namespace` for cluster kubeconfigs and + `--module-configmap-namespace` for uploaded bundles and locale maps. + The latter defaults to `--module-namespace`. On a v0.3 install both + locations are the release namespace. Check these sources even when enabling + the API for the first time on an operator-only release. + 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, `immutable` setting, 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`, `binaryData` and `immutable` setting. Module locale ConfigMaps + (`