Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2191,7 +2191,7 @@ jobs:
cd test/e2e && \
GAMEPLANE_E2E_REUSE_CLUSTER=1 \
GAMEPLANE_E2E_CLUSTER="$CLUSTER" \
go test -tags=e2e -timeout 25m -v -run "$RUN_REGEX" ./...
go test -tags=e2e -timeout 35m -v -run "$RUN_REGEX" ./...
- name: dump cluster state on failure (e2e-upgrade)
if: failure()
uses: ./.github/actions/dump-cluster-state
Expand Down
12 changes: 10 additions & 2 deletions api/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,9 @@ func main() {
// 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, credentialsNS)
if cfg.remoteClusters {
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
Expand Down Expand Up @@ -394,7 +396,11 @@ func main() {
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, credentialsNS)
if cfg.remoteClusters {
handlers.MountClusters(p, reg, k8s, credentialsNS)
} else {
handlers.MountClusters(p, reg, nil, credentialsNS)
}
handlers.MountInstallation(p, cfg.standalone, Version)
handlers.MountEvents(p, reg)
handlers.MountDestinations(p, reg)
Expand Down Expand Up @@ -516,6 +522,7 @@ func main() {
}

type config struct {
remoteClusters bool
standalone bool
panelKeyFile string
panelKeyProvisioned bool
Expand Down Expand Up @@ -673,6 +680,7 @@ func (c *config) bindFlags(fs *flag.FlagSet) {
fs.IntVar(&c.captureDefaultMaxDurationS, "capture-default-max-duration", envOrInt("GAMEPLANE_CAPTURE_DEFAULT_MAX_DURATION", 300), "default max capture duration in seconds")
c.captureDefaultMaxSizeBytes = envOrInt64("GAMEPLANE_CAPTURE_DEFAULT_MAX_SIZE", 5368709120)
fs.BoolVar(&c.standalone, "standalone", envOr("GAMEPLANE_STANDALONE", "") == "true", "run without a local Kubernetes cluster")
fs.BoolVar(&c.remoteClusters, "remote-clusters", true, "load remote cluster credentials and connect to registered clusters")
fs.StringVar(&c.panelKeyFile, "panel-key-file", envOr("GAMEPLANE_PANEL_KEY_FILE", "/data/panel.key"), "persistent standalone credential encryption key file")
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")
Expand Down
9 changes: 8 additions & 1 deletion api/internal/handlers/clusters.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,14 @@ import (
"github.com/GameplanePanel/gameplane/api/internal/messages"
)

// MountClusters wires /clusters onto the supplied router.
// MountClusters registers only local discovery when no remote client is supplied.
func MountClusters(r chi.Router, reg *kube.Registry, k *kube.Client, ns string) {
h := clustersHandler{reg: reg, k: k, namespace: ns}
r.Route("/clusters", func(r chi.Router) {
r.Get("/", h.list)
if k == nil {
return
}
r.Post("/", h.create)
r.Delete("/{name}", h.delete)
r.Put("/{name}/kubeconfig", h.replaceKubeconfig)
Expand Down Expand Up @@ -122,6 +125,10 @@ func (h clustersHandler) list(w http.ResponseWriter, req *http.Request) {
out.Items = append(out.Items, clusterRegistryView{Name: local, Phase: "Healthy",
CanViewInventory: u.Can("cluster:read", true, local, "")})
}
if h.k == nil {
writeJSON(w, out)
return
}
// Persisted registrations remain discoverable when a kubeconfig cannot
// load; the client registry alone would silently drop those clusters.
registrations, err := h.k.Clusters().List(req.Context(), metav1.ListOptions{})
Expand Down
38 changes: 38 additions & 0 deletions api/internal/handlers/clusters_discovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,44 @@ func TestClusterDiscovery_FiltersRegistrationsAndInventoryCapability(t *testing.
}
}

func TestClusterDiscovery_LocalOnlyRoutes(t *testing.T) {
home := fakeKubeClientWithClusters(newCluster("remote", nil, nil))
reg := clusterTestRegistry(home)
router := chi.NewRouter()
MountClusters(router, reg, nil, "gameplane-system")
user := inventoryUser("*", "*", "cluster:read")

list := inventoryRequest(t, router, user, http.MethodGet, "/clusters/")
var got clustersListResp
if err := json.Unmarshal(list.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if list.Code != http.StatusOK || len(got.Items) != 1 || got.Items[0].Name != "local" || !got.Items[0].CanViewInventory {
t.Fatalf("local-only discovery: status=%d items=%+v", list.Code, got.Items)
}
if kubeClientCalls(t, home) != 0 {
t.Fatal("local-only discovery contacted Kubernetes for remote registrations")
}
for _, route := range []struct {
method, path string
status int
}{
{http.MethodPost, "/clusters/", http.StatusMethodNotAllowed},
{http.MethodDelete, "/clusters/remote", http.StatusNotFound},
{http.MethodPut, "/clusters/remote/kubeconfig", http.StatusNotFound},
{http.MethodPut, "/clusters/remote/gateway", http.StatusNotFound},
{http.MethodDelete, "/clusters/remote/gateway", http.StatusNotFound},
} {
response := inventoryRequest(t, router, user, route.method, route.path)
if response.Code != route.status {
t.Errorf("%s %s = %d, want %d", route.method, route.path, response.Code, route.status)
}
}
if kubeClientCalls(t, home) != 0 {
t.Fatal("local-only administration request contacted Kubernetes")
}
}

func TestClusterDiscovery_RetainsDisconnectedRegistration(t *testing.T) {
home := fakeKubeClientWithClusters(newCluster("offline", map[string]any{"displayName": "Offline cluster"}, map[string]any{"phase": "Healthy"}))
reg := clusterTestRegistry(home)
Expand Down
2 changes: 2 additions & 0 deletions api/specs.md
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,8 @@ A submitted stylesheet is rejected with 400 and a message naming the offending r

### RBAC roles (three built-in + custom)

Kubernetes authorizes the native panel's ServiceAccount separately from the user's Gameplane role. In local-only mode, `--remote-clusters=false` skips remote credential discovery, exposes only authorized local cluster discovery and omits remote registration and gateway-management routes. Native Helm grants and the upgrade contract are specified in [the Kubernetes permission matrix](../docs/native-rbac.md).

**Built-in roles:**
- **admin:** wildcard permission `*`; full access to all resources and config
- **operator:** read/write servers, backups, schedules, templates; read modules, destinations, cluster, roles (modules install/upgrade/uninstall requires `modules:manage`, seeded only to admin — see `api/internal/db/migrations/sqlite/003_roles.sql`)
Expand Down
5 changes: 5 additions & 0 deletions changelog/unreleased/900-native-rbac.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Native API and operator permissions

Administrators can disable native catalog writes and remote-cluster administration, and
scope snapshot permissions to deployments that use them. Existing native dashboard
permissions are preserved on upgrade. [PR #900](https://github.com/GameplanePanel/Gameplane/pull/900)
26 changes: 25 additions & 1 deletion charts/gameplane/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,26 @@ mergeOverwrite preserves explicit zero/empty values so validation can reject the
{{- uniq $namespaces | toJson -}}
{{- end -}}

{{- /* helm upgrade --reuse-values can omit a values map introduced by a
new chart. Preserve the privileges and watch behavior of that installation. */ -}}
{{- define "gameplane.nativeRBAC" -}}
{{- $defaults := dict "catalogAdmin" true "remoteClusters" true "gameSecretManagement" true "podAttach" true "operatorStopAttach" true "snapshotContentReclamation" true "runtimeSecretNames" (list) "additionalAPIGameNamespaces" (list) -}}
{{- $overrides := dict -}}
{{- if hasKey .Values "nativeRBAC" }}{{ $overrides = .Values.nativeRBAC }}{{ end -}}
{{- if not (kindIs "map" $overrides) }}{{ fail "nativeRBAC must be a map" }}{{ end -}}
{{- range $key := keys $overrides -}}
{{- if not (hasKey $defaults $key) }}{{ fail (printf "unknown nativeRBAC capability %q" $key) }}{{ end -}}
{{- end -}}
{{- $config := mergeOverwrite $defaults $overrides -}}
{{- range $key := list "catalogAdmin" "remoteClusters" "gameSecretManagement" "podAttach" "operatorStopAttach" "snapshotContentReclamation" -}}
{{- if not (kindIs "bool" (index $config $key)) }}{{ fail (printf "nativeRBAC.%s must be a boolean" $key) }}{{ end -}}
{{- end -}}
{{- range $key := list "additionalAPIGameNamespaces" "runtimeSecretNames" -}}
{{- if not (kindIs "slice" (index $config $key)) }}{{ fail (printf "nativeRBAC.%s must be a list" $key) }}{{ end -}}
{{- end -}}
{{- $config | toJson -}}
{{- end -}}

{{- /* Both native components resolve remote-cluster credentials here. Keep
the existing operator override for installations with a dedicated namespace. */ -}}
{{- define "gameplane.credentialsNamespace" -}}
Expand All @@ -201,8 +221,12 @@ the existing operator override for installations with a dedicated namespace. */
{{- end -}}

{{- define "gameplane.operatorSecretNamespaces" -}}
{{- $rbac := include "gameplane.nativeRBAC" . | fromJson -}}
{{- $namespaces := include "gameplane.operatorGameNamespaces" . | fromJsonArray -}}
{{- range list (include "gameplane.operatorClusterCredentialsNamespace" .) (.Values.operator.agentCASecretNamespace | default .Release.Namespace) (.Values.operator.moduleNamespace | default .Release.Namespace) -}}
{{- if $rbac.remoteClusters -}}
{{- $namespaces = append $namespaces (include "gameplane.operatorClusterCredentialsNamespace" .) -}}
{{- end -}}
{{- range list (.Values.operator.agentCASecretNamespace | default .Release.Namespace) (.Values.operator.moduleNamespace | default .Release.Namespace) -}}
{{- $namespaces = append $namespaces (include "gameplane.namespace" .) -}}
{{- end -}}
{{- uniq $namespaces | toJson -}}
Expand Down
12 changes: 12 additions & 0 deletions charts/gameplane/templates/api-credentials.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{{- $rbac := include "gameplane.nativeRBAC" . | fromJson -}}
{{- if and (eq (include "gameplane.apiEnabled" .) "true") (not .Values.api.standalone) }}
{{- $namespace := include "gameplane.credentialsNamespace" . }}
{{- if not (or .Values.api.credentialsNamespace .Values.operator.controlPlaneNamespace) }}
Expand All @@ -17,12 +18,23 @@ metadata:
namespace: {{ $namespace }}
labels: {{- include "gameplane.labels" . | nindent 4 }}
rules:
{{- if $rbac.remoteClusters }}
- apiGroups: [""]
resources: [secrets]
verbs: [get, create, update, delete]
{{- else if $rbac.runtimeSecretNames }}
- apiGroups: [""]
resources: [secrets]
verbs: [get]
resourceNames: {{ $rbac.runtimeSecretNames | toJson }}
{{- end }}
- apiGroups: [""]
resources: [configmaps]
{{- if $rbac.catalogAdmin }}
verbs: [get, list, create, update, delete]
{{- else }}
verbs: [get, list]
{{- end }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
Expand Down
49 changes: 23 additions & 26 deletions charts/gameplane/templates/api.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{{- $rbac := include "gameplane.nativeRBAC" . | fromJson -}}
{{- if not (eq (include "gameplane.apiEnabled" .) "true") }}
{{- $livePVC := lookup "v1" "PersistentVolumeClaim" .Release.Namespace "gameplane-api-data" }}
{{- if and $livePVC (ne (index ($livePVC.metadata.annotations | default dict) "helm.sh/resource-policy") "keep") }}
Expand All @@ -13,10 +14,8 @@ metadata:
labels: {{- include "gameplane.labels" . | nindent 4 }}
{{- if not .Values.api.standalone }}
---
# Cluster-wide READ access: templates (cluster-scoped), nodes, storage
# classes, and read-only watches on Gameplane CRDs for the /events stream.
# Note the absence of any write verb here — mutation permissions are
# bound per-namespace via Role + RoleBinding below.
# Cluster-wide discovery and dashboard reads. The optional catalog and remote
# registration writers have separate Roles and bindings.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
Expand All @@ -26,18 +25,11 @@ rules:
- apiGroups: ["gameplane.local"]
resources: [gameservers, gametemplates, backups, backupschedules, restores, modules, modulesources, networkcaptures]
verbs: [get, list, watch]
- apiGroups: ["gameplane.local"]
resources: [gametemplates]
verbs: [create, update, patch, delete] # templates are cluster-scoped; admin UI edits them
- apiGroups: ["gameplane.local"]
resources: [modules]
verbs: [create, update, patch, delete] # modules are cluster-scoped; install/upgrade/uninstall from the dashboard
- apiGroups: ["gameplane.local"]
resources: [modulesources]
verbs: [create, update, patch, delete] # source management (add/edit/remove sources) from the dashboard
{{- if $rbac.remoteClusters }}
- apiGroups: ["gameplane.local"]
resources: [clusters]
verbs: [get, list, watch, create, update, delete] # registration and gateway configuration from the dashboard
verbs: [get, list, watch]
{{- end }}
- apiGroups: [""]
resources: [nodes, events]
verbs: [get, list, watch]
Expand Down Expand Up @@ -76,16 +68,13 @@ subjects:
name: gameplane-api
namespace: {{ .Release.Namespace }}
---
# Namespace-scoped WRITE access: limited to the games namespace(s) the
# API is allowed to mutate. Expanding this to additional namespaces
# requires a new RoleBinding here AND adding them to
# GAMEPLANE_EXTRA_NAMESPACES on the API Deployment.
{{- range $ns := uniq (concat (list .Values.gamesNamespace) $rbac.additionalAPIGameNamespaces) }}
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: {{ .Release.Name }}-api-games
namespace: {{ .Values.gamesNamespace }}
labels: {{- include "gameplane.labels" . | nindent 4 }}
name: {{ $.Release.Name }}-api-games
namespace: {{ $ns }}
labels: {{- include "gameplane.labels" $ | nindent 4 }}
rules:
- apiGroups: ["gameplane.local"]
resources: [gameservers, backups, backupschedules, restores, networkcaptures]
Expand All @@ -112,9 +101,11 @@ rules:
- apiGroups: [""]
resources: [pods]
verbs: [get]
{{- if $rbac.podAttach }}
- apiGroups: [""]
resources: [pods/attach]
verbs: [get, create]
{{- end }}
# The startup-log stream (api/internal/ws/podlogs.go) follows the game
# container's stdout via the pod-log subresource so download/config
# output is visible before the game's own log file exists.
Expand All @@ -123,22 +114,25 @@ rules:
verbs: [get]
# Backup destinations are Secrets in the games namespace
# (api/internal/handlers/destinations.go).
{{- if $rbac.gameSecretManagement }}
- apiGroups: [""]
resources: [secrets]
verbs: [get, list, create, update, patch, delete]
{{- end }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: {{ .Release.Name }}-api-games
namespace: {{ .Values.gamesNamespace }}
labels: {{- include "gameplane.labels" . | nindent 4 }}
roleRef: { apiGroup: rbac.authorization.k8s.io, kind: Role, name: {{ .Release.Name }}-api-games }
name: {{ $.Release.Name }}-api-games
namespace: {{ $ns }}
labels: {{- include "gameplane.labels" $ | nindent 4 }}
roleRef: { apiGroup: rbac.authorization.k8s.io, kind: Role, name: {{ $.Release.Name }}-api-games }
subjects:
- kind: ServiceAccount
name: gameplane-api
namespace: {{ .Release.Namespace }}
namespace: {{ $.Release.Namespace }}
---
{{- end }}
# System logs are the only namespaced release resources the native API needs.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
Expand Down Expand Up @@ -259,6 +253,9 @@ spec:
image: {{ include "gameplane.apiImage" . }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
args:
{{- if and (not .Values.api.standalone) (not $rbac.remoteClusters) }}
- --remote-clusters=false
{{- end }}
{{- if .Values.api.standalone }}
- --standalone
- {{ printf "--panel-key-file=/keys/%s" ((.Values.api.panelKey | default dict).fileName | default "panel.key") | quote }}
Expand Down
49 changes: 49 additions & 0 deletions charts/gameplane/templates/native-admin-rbac.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{{- $rbac := include "gameplane.nativeRBAC" . | fromJson -}}
{{- if and (eq (include "gameplane.apiEnabled" .) "true") (not .Values.api.standalone) }}
{{- range $role := list
(dict "name" "api-catalog-admin" "enabled" $rbac.catalogAdmin "group" "gameplane.local" "resources" (list "gametemplates" "modules" "modulesources") "verbs" (list "create" "update" "patch" "delete"))
(dict "name" "api-cluster-registration" "enabled" $rbac.remoteClusters "group" "gameplane.local" "resources" (list "clusters") "verbs" (list "create" "update" "delete")) }}
{{- if $role.enabled }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: {{ $.Release.Name }}-{{ $role.name }}
labels: {{- include "gameplane.labels" $ | nindent 4 }}
rules:
- apiGroups: [{{ $role.group | quote }}]
resources: {{ $role.resources | toJson }}
verbs: {{ $role.verbs | toJson }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: {{ $.Release.Name }}-{{ $role.name }}
labels: {{- include "gameplane.labels" $ | nindent 4 }}
roleRef: { apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: {{ $.Release.Name }}-{{ $role.name }} }
subjects:
- { kind: ServiceAccount, name: gameplane-api, namespace: {{ $.Release.Namespace }} }
{{- end }}
{{- end }}
{{- end }}
{{- if and (eq (include "gameplane.operatorEnabled" .) "true") $rbac.snapshotContentReclamation }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: {{ .Release.Name }}-operator-snapshot-reclaim
labels: {{- include "gameplane.labels" . | nindent 4 }}
rules:
- apiGroups: [snapshot.storage.k8s.io]
resources: [volumesnapshotcontents]
verbs: [delete]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: {{ .Release.Name }}-operator-snapshot-reclaim
labels: {{- include "gameplane.labels" . | nindent 4 }}
roleRef: { apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: {{ .Release.Name }}-operator-snapshot-reclaim }
subjects:
- { kind: ServiceAccount, name: gameplane-operator, namespace: {{ .Release.Namespace }} }
{{- end }}
Loading
Loading