240 lines
13 KiB
Go
240 lines
13 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). It also Gets the world PVC before backup/restore
|
|
// (internal/api.k8scluster.WorldVolumeExists) so a never-started or reaped
|
|
// world is refused up front instead of leaving a Job Pending on a missing
|
|
// claim. felis-api uses a DIRECT client, so it needs no list/watch beyond the
|
|
// explicit List calls — and the PVC grant is get-only, mirroring that.
|
|
//
|
|
// 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"}),
|
|
// get-only: WorldVolumeExists does a single direct Get of the world PVC;
|
|
// nothing in felis-api lists or deletes PVCs.
|
|
rule([]string{groupCore}, []string{"persistentvolumeclaims"}, []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. Jobs are list-only,
|
|
// through the manager's uncached API reader: before scaling a server up from zero
|
|
// the operator checks that no restore/backup/file-write Job holds its world
|
|
// (internal/maintenance). 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"}),
|
|
// list only: an uncached List (no informer, so no watch) of the world-volume
|
|
// maintenance Jobs; the operator never creates or deletes a Job.
|
|
rule([]string{groupBatch}, []string{"jobs"}, []string{"list"}),
|
|
})
|
|
}
|
|
|
|
// 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}
|
|
}
|