Files
Felis/internal/platform/rbac.go
T
Lemon-miaow f650bf892a fix(operator): idle auto-stop couldn't write — patch the spec, and grant the patch
Two stacked blockers behind the frozen auto-stop, both found live after the
first two fixes let the timer finally tick:

- The stop used a whole-object Update while the same reconcile loop writes
  status; that risks clobbering a concurrent status write. Switch to the
  reaper's merge-patch pattern (spec.desiredState only; EmptySince is left for
  markStopped to clear).
- The operator Role never carried minecraftservers:patch, so the call failed
  closed with 403 (visible in the operator log as 'cannot update resource
  "minecraftservers"'). Grant patch and pin it in the RBAC scope test.

With all three layers fixed, the auto-stop path is: timer persists (schema),
wake-up fires (requeue), spec write allowed (RBAC).
2026-09-23 04:08:58 +08:00

228 lines
12 KiB
Go

package platform
import (
"felis.lolicon.best/internal/apis/felis/v1alpha1"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// API groups used by the rules. The felis group is sourced from v1alpha1 so the
// CRD's identity and its RBAC can never drift apart.
const (
groupCore = "" // core/v1: secrets, services, persistentvolumeclaims
groupApps = "apps"
groupBatch = "batch"
)
var groupFelis = v1alpha1.GroupName // "felis.lolicon.best"
// RBAC is the control-plane authorization bundle: one SA per identity and the
// namespaced Roles + RoleBindings that grant each exactly the verbs its code path
// exercises. There is deliberately no ClusterRole or ClusterRoleBinding anywhere.
type RBAC struct {
ServiceAccounts []*corev1.ServiceAccount
Roles []*rbacv1.Role
RoleBindings []*rbacv1.RoleBinding
}
// ControlPlaneRBAC assembles the full RBAC bundle for p.
//
// The reaper identity (felis-reaper SA + Role + RoleBinding) is rendered ONLY when
// the retention reaper CronJob is — both gate on reaperEnabled(p), the same storage
// trio (workloads.go). This coupling is deliberate least-privilege: the reaper's
// Role is the one and only place persistentvolumeclaims:delete appears in the whole
// bundle (world reclamation) — neither felis-api nor felis-operator can delete a
// PVC. Leaving that destructive grant standing in a deployment that never runs the
// reaper would widen the blast radius of a control-plane compromise for no benefit
// (a control-namespace foothold could mount felis-reaper and destroy world PVCs),
// since nothing would consume it. So the destructive identity exists exactly as
// long as its consumer does, and the manifests command's fail-loud trio check
// guarantees the CronJob and this RBAC are always rendered together or not at all.
func ControlPlaneRBAC(p Params) RBAC {
p = p.withDefaults()
rbac := RBAC{
ServiceAccounts: []*corev1.ServiceAccount{
controlPlaneServiceAccount(p.ControlNamespace, SAAPI, ComponentAPI),
controlPlaneServiceAccount(p.ControlNamespace, SAOperator, ComponentOperator),
},
Roles: []*rbacv1.Role{
APIMinecraftRole(p),
APIBuildRole(p),
OperatorRole(p),
},
// Each binding lives in the Role's namespace and names the subject SA in the
// control namespace (a RoleBinding may reference an SA from another namespace;
// its roleRef must be a Role in the binding's own namespace). The reaper
// binding below is the one exception: its CronJob runs in the Minecraft
// namespace, so both the SA and the subject live there.
RoleBindings: []*rbacv1.RoleBinding{
bindRole(p.MinecraftNamespace, "felis-api", p.ControlNamespace, SAAPI, ComponentAPI),
bindRole(p.BuildNamespace, "felis-api-builds", p.ControlNamespace, SAAPI, ComponentAPI),
bindRole(p.MinecraftNamespace, "felis-operator", p.ControlNamespace, SAOperator, ComponentOperator),
},
}
// The destructive fourth power is conditional on its consumer (see the doc above).
if reaperEnabled(p) {
// SAReaper lives in — and its binding subject resolves in — the MINECRAFT
// namespace, because the reaper CronJob runs there (its backup PVC is there;
// a Pod can only mount a PVC and use a ServiceAccount from its own namespace).
rbac.ServiceAccounts = append(rbac.ServiceAccounts,
controlPlaneServiceAccount(p.MinecraftNamespace, SAReaper, ComponentReaper))
rbac.Roles = append(rbac.Roles, ReaperRole(p))
rbac.RoleBindings = append(rbac.RoleBindings,
bindRole(p.MinecraftNamespace, "felis-reaper", p.MinecraftNamespace, SAReaper, ComponentReaper))
}
return rbac
}
// APIMinecraftRole grants felis-api exactly what it does in the minecraft
// namespace: drive MinecraftServer specs (internal/api.k8scluster — get/list/
// create/patch, never status), read RCON passwords for console writes
// (internal/api.console — secrets:get), manage the restore Job under its
// deterministic name (internal/restore — jobs:create, plus get/delete so a
// FINISHED Job whose name still blocks a retry can be replaced), and stream the
// live console for the read side (internal/api.logstream — pods:list to find
// the server's running pod, then pods/log:get to follow it; spec §8 读=pods/log
// follow). felis-api uses a DIRECT client, so it needs no list/watch beyond the
// explicit List calls.
//
// The read-side grant is deliberately minimal: pods:list + pods/log:get, NOT
// pods:get — the streamer lists pods by the server label then reads the chosen
// pod's log subresource, never Gets a pod object. Keeping pods:get out is the
// least-privilege line the rbac test asserts (a pod's full object can carry more
// than its logs).
func APIMinecraftRole(p Params) *rbacv1.Role {
p = p.withDefaults()
return role(p.MinecraftNamespace, "felis-api", ComponentAPI, []rbacv1.PolicyRule{
rule([]string{groupFelis}, []string{"minecraftservers"}, []string{"get", "list", "create", "patch"}),
rule([]string{groupCore}, []string{"secrets"}, []string{"get"}),
// list backs GET /servers/{name}/jobs — the async status outlet reads the
// backup/restore Jobs back by the server label (read-only).
rule([]string{groupBatch}, []string{"jobs"}, []string{"create", "get", "delete", "list"}),
// Read-side console (spec §8 读=pods/log follow): list pods to find the
// server's running pod, then read its log subresource. Two separate rules so
// the verbs stay tight — list on pods, get on pods/log, and nothing else.
rule([]string{groupCore}, []string{"pods"}, []string{"list"}),
rule([]string{groupCore}, []string{"pods/log"}, []string{"get"}),
})
}
// APIBuildRole grants felis-api the build-Job lifecycle in the build namespace
// (internal/build.k8sjobs — Create/Get/Delete) plus the read-side build-log
// stream (spec §16, §416 日志流复用 §8): list build Pods to find the build Job's
// Pod by build-id label, then read its log subresource. This is a SEPARATE
// namespace from the api's minecraft powers, so it is a separate Role +
// RoleBinding; the api SA reaches across both from the control namespace. The log
// grant mirrors felis-api's minecraft-ns console read (pods:list + pods/log:get,
// no pods:get) — read-only and least-privilege; it does NOT touch the build SA
// token or any secret.
func APIBuildRole(p Params) *rbacv1.Role {
p = p.withDefaults()
return role(p.BuildNamespace, "felis-api-builds", ComponentAPI, []rbacv1.PolicyRule{
rule([]string{groupBatch}, []string{"jobs"}, []string{"create", "get", "delete"}),
// Read-side build logs (spec §16): list build Pods to find the build Job's
// Pod, then read its log subresource — and nothing wider. No pods:get (the
// streamer lists then reads pods/log, never Gets a Pod object, whose full
// spec carries more than its logs).
rule([]string{groupCore}, []string{"pods"}, []string{"list"}),
rule([]string{groupCore}, []string{"pods/log"}, []string{"get"}),
})
}
// OperatorRole grants felis-operator what the reconciler exercises through the
// manager's CACHED client (internal/operator.reconciler). Because reads go
// through informers, every watched type needs list+watch even for a single Get;
// the manager's cache is namespace-scoped (see cmd/felis/operator.go), so a
// namespaced Role is sufficient. The operator owns StatefulSets and Services
// (Get/Create/Update — never patch or delete), writes only minecraftservers
// status (Status().Update — `update` only) and patches spec.desiredState to
// Stopped for idle auto-stop (spec §8 — the one spec field it may write, using
// the same merge patch as the reaper's Stop: without the grant the auto-stop
// call fails closed with a 403), and reads RCON Secrets. It never touches pods,
// PVCs, Events, or finalizers, so none appear here.
func OperatorRole(p Params) *rbacv1.Role {
p = p.withDefaults()
return role(p.MinecraftNamespace, "felis-operator", ComponentOperator, []rbacv1.PolicyRule{
rule([]string{groupFelis}, []string{"minecraftservers"}, []string{"get", "list", "watch", "patch"}),
rule([]string{groupFelis}, []string{"minecraftservers/status"}, []string{"update"}),
rule([]string{groupApps}, []string{"statefulsets"}, []string{"get", "list", "watch", "create", "update"}),
rule([]string{groupCore}, []string{"services"}, []string{"get", "list", "watch", "create", "update"}),
// create is here for the per-server RCON password Secret the operator
// provisions on first reconcile (internal/operator.ensureRconSecret). It is a
// smaller grant than it looks: this identity already holds get/list/watch on
// every Secret in this namespace, so being able to add one grants no read it
// did not already have. No update/delete — the password is written once and
// removed by garbage collection through its controller reference.
rule([]string{groupCore}, []string{"secrets"}, []string{"get", "list", "watch", "create"}),
})
}
// ReaperRole grants felis-reaper its two destructive, disjoint powers
// (internal/reaper.k8scluster): patch a MinecraftServer to Stop it and delete its
// world PVC. Candidate servers come from the Postgres store, not a cluster List,
// so no list/watch is needed; the reaper uses a direct client. It can read+patch
// minecraftservers but cannot create them, and holds no power over StatefulSets,
// Services, or Secrets — those belong to the operator and api.
//
// persistentvolumeclaims also carries get: resolving where a world lives
// (cmd/felis/reaper.resolveWorldDir) reads the PVC's volumeName to derive the
// stock local-path directory name. get is strictly weaker than the delete the
// same rule already grants, so it widens nothing.
//
// Note no identity anywhere holds minecraftservers:delete. That is intentional, not
// a missing grant: reaping releases a server by flipping desiredState=Stopped and
// reclaiming the world PVC (k8scluster.go does "nothing else"), leaving the CR in
// place so a former owner can re-claim it within the retention window (spec §466).
// The MinecraftServer CR is the lifecycle source of truth and is retained, never
// hard-deleted, so the delete verb is deliberately absent from every Role.
func ReaperRole(p Params) *rbacv1.Role {
p = p.withDefaults()
return role(p.MinecraftNamespace, "felis-reaper", ComponentReaper, []rbacv1.PolicyRule{
rule([]string{groupFelis}, []string{"minecraftservers"}, []string{"get", "patch"}),
rule([]string{groupCore}, []string{"persistentvolumeclaims"}, []string{"get", "delete"}),
})
}
// controlPlaneServiceAccount renders a control-plane SA. Unlike the weak
// build/restore SAs, these identities legitimately call the K8s API, so the token
// mounts (via their Deployment) — AutomountServiceAccountToken is left nil
// (cluster default = mount) rather than false.
func controlPlaneServiceAccount(ns, name, component string) *corev1.ServiceAccount {
return &corev1.ServiceAccount{
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "ServiceAccount"},
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns, Labels: controlPlanePodLabels(component)},
}
}
func role(ns, name, component string, rules []rbacv1.PolicyRule) *rbacv1.Role {
return &rbacv1.Role{
TypeMeta: metav1.TypeMeta{APIVersion: "rbac.authorization.k8s.io/v1", Kind: "Role"},
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns, Labels: controlPlanePodLabels(component)},
Rules: rules,
}
}
// bindRole binds the Role named roleName (in roleNS) to the ServiceAccount saName
// in saNS. The RoleBinding lives in roleNS; the subject SA may live elsewhere.
func bindRole(roleNS, roleName, saNS, saName, component string) *rbacv1.RoleBinding {
return &rbacv1.RoleBinding{
TypeMeta: metav1.TypeMeta{APIVersion: "rbac.authorization.k8s.io/v1", Kind: "RoleBinding"},
ObjectMeta: metav1.ObjectMeta{Name: roleName, Namespace: roleNS, Labels: controlPlanePodLabels(component)},
Subjects: []rbacv1.Subject{{
Kind: rbacv1.ServiceAccountKind,
Name: saName,
Namespace: saNS,
}},
RoleRef: rbacv1.RoleRef{
APIGroup: rbacv1.GroupName,
Kind: "Role",
Name: roleName,
},
}
}
func rule(apiGroups, resources, verbs []string) rbacv1.PolicyRule {
return rbacv1.PolicyRule{APIGroups: apiGroups, Resources: resources, Verbs: verbs}
}