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
38 changes: 37 additions & 1 deletion dc-api/internal/agentrbac/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,36 @@ func RenderAgentRBAC(caps []clusteraccess.AgentCapability) []byte {
// rule (nodes/pods get,list,watch — serves the agent's hard-coded get_inventory
// op) followed by one rule per capability that grants RBAC (non-empty
// AgentVerbs), grouped by apiGroup and sorted deterministically.
//
// A capability rule FULLY covered by a base rule is NOT emitted: RBAC rules are
// additive (union), so the duplicate would grant nothing — and a subset rule
// sitting next to its superset reads like a failed attempt to narrow the grant
// (review-flagged on the pods rule). Subsumption is computed at render time, so
// if a base rule is ever removed (the least-privilege pass), regeneration
// re-emits the capability rule that now carries the grant.
func capabilityRules(caps []clusteraccess.AgentCapability) []rule {
// Base inventory rule is a generator constant, NOT registry-driven: the
// agent's get_inventory reads nodes/pods via the typed clientset.
rules := []rule{
{apiGroup: "", resources: []string{"nodes", "pods"}, verbs: []string{"get", "list", "watch"}},
}

// Merge capability verbs per (apiGroup, resource).
// Coverage granted by the base rules, per (apiGroup, resource).
type key struct{ group, resource string }
baseCover := map[key]map[string]bool{}
for _, r := range rules {
for _, res := range r.resources {
k := key{group: r.apiGroup, resource: res}
if baseCover[k] == nil {
baseCover[k] = map[string]bool{}
}
for _, v := range r.verbs {
baseCover[k][v] = true
}
}
}

// Merge capability verbs per (apiGroup, resource).
verbsByKey := map[key]map[string]bool{}
for _, c := range caps {
if len(c.AgentVerbs) == 0 {
Expand Down Expand Up @@ -130,6 +151,21 @@ func capabilityRules(caps []clusteraccess.AgentCapability) []rule {
})

for _, k := range keys {
// Skip a rule the base already fully grants (see the doc comment). A
// PARTIALLY covered rule is emitted whole — splitting verbs across rules
// would obscure which capability asked for what.
if cover, ok := baseCover[k]; ok {
subsumed := true
for v := range verbsByKey[k] {
if !cover[v] {
subsumed = false
break
}
}
if subsumed {
continue
}
}
rules = append(rules, rule{
apiGroup: k.group,
resources: []string{k.resource},
Expand Down
35 changes: 35 additions & 0 deletions dc-api/internal/agentrbac/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"

"k8s.io/apimachinery/pkg/runtime/schema"

"github.com/wso2/dc-api/internal/providers/clusteraccess"
)

Expand Down Expand Up @@ -50,3 +53,35 @@ func TestRBACByteStable(t *testing.T) {
t.Fatalf("RenderAgentRBAC is not byte-stable:\n--- run 1 ---\n%s\n--- run 2 ---\n%s", a, b)
}
}

// TestSubsumedCapabilityRuleNotEmitted pins the render-time subsumption rule:
// a capability whose k8s verbs the fixed base inventory rule already fully
// grants (pods get/list ⊂ nodes/pods get/list/watch) emits NO separate
// ClusterRole rule — RBAC is additive, so the duplicate would grant nothing and
// only reads as a failed narrowing. A capability with any verb OUTSIDE the base
// coverage still emits its full rule.
func TestSubsumedCapabilityRuleNotEmitted(t *testing.T) {
podsReadOnly := clusteraccess.AgentCapability{
GVR: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"},
APIVersion: "v1", Kind: "Pod", Namespaced: true,
AgentVerbs: []clusteraccess.Verb{clusteraccess.VerbGet, clusteraccess.VerbList},
}
out := string(RenderAgentRBAC([]clusteraccess.AgentCapability{podsReadOnly}))
if strings.Contains(out, `resources: ["pods"]`) {
t.Errorf("fully-subsumed pods rule was emitted:\n%s", out)
}
if !strings.Contains(out, `resources: ["nodes", "pods"]`) {
t.Errorf("base inventory rule missing:\n%s", out)
}

// Widen one verb beyond the base rule (create) → the rule MUST be emitted.
podsWithCreate := podsReadOnly
podsWithCreate.AgentVerbs = []clusteraccess.Verb{clusteraccess.VerbGet, clusteraccess.VerbList, clusteraccess.VerbCreate}
out = string(RenderAgentRBAC([]clusteraccess.AgentCapability{podsWithCreate}))
if !strings.Contains(out, `resources: ["pods"]`) {
t.Errorf("partially-covered pods rule was NOT emitted:\n%s", out)
}
if !strings.Contains(out, `verbs: ["get", "list", "create"]`) {
t.Errorf("partially-covered pods rule lost verbs:\n%s", out)
}
}
129 changes: 122 additions & 7 deletions dc-api/internal/providers/clusteraccess/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,19 +227,127 @@ var (
// Secret (create for the direct POST, patch because the agent create is SSA).
AgentVerbs: []Verb{VerbGet, VerbCreate, VerbApply},
}
// providerNetworkCapability onboards the ProviderNetwork create that
// EnsureExternalNetworkBootstrap routes through the kubeovn seam (the F15 NAT
// slice). Cluster-scoped: binds a host bridge to the KubeOVN SDN. The GVR
// matches kubeovn.providerNetworkGVR exactly.
providerNetworkCapability = AgentCapability{
GVR: schema.GroupVersionResource{Group: "kubeovn.io", Version: "v1", Resource: "provider-networks"},
APIVersion: "kubeovn.io/v1",
Kind: "ProviderNetwork",
Namespaced: false,
// dc-api routes only the idempotent bootstrap create — nothing reads,
// lists, or deletes a ProviderNetwork through the seam.
RouteVerbs: []Verb{VerbCreate},
// SA grant: create + patch, because the AgentBacked create is a server-side
// apply (VerbApply→patch). Mirrors serviceAccountCapability's write grant.
AgentVerbs: []Verb{VerbCreate, VerbApply},
}
// vlanCapability onboards the Vlan create that EnsureExternalNetworkBootstrap
// routes through the kubeovn seam (the F15 NAT slice). Cluster-scoped:
// references the ProviderNetwork and carries the external VLAN id.
vlanCapability = AgentCapability{
GVR: schema.GroupVersionResource{Group: "kubeovn.io", Version: "v1", Resource: "vlans"},
APIVersion: "kubeovn.io/v1",
Kind: "Vlan",
Namespaced: false,
// dc-api routes only the idempotent bootstrap create (same shape as
// providerNetworkCapability).
RouteVerbs: []Verb{VerbCreate},
// SA grant: create + patch (SSA is the agent's create mechanism).
AgentVerbs: []Verb{VerbCreate, VerbApply},
}
// vpcNatGatewayCapability onboards the VpcNatGateway lifecycle that
// EnsureVpcNAT/DeleteVpcNAT/IsVpcNATPresent route through the kubeovn seam
// (the F15 NAT slice). Cluster-scoped in KubeOVN (the gateway CRD carries no
// namespace; only the StatefulSet pod it spawns lives in kube-system).
vpcNatGatewayCapability = AgentCapability{
GVR: schema.GroupVersionResource{Group: "kubeovn.io", Version: "v1", Resource: "vpc-nat-gateways"},
APIVersion: "kubeovn.io/v1",
Kind: "VpcNatGateway",
Namespaced: false,
// dc-api routes the create (EnsureVpcNAT), the presence read
// (IsVpcNATPresent), and the delete (DeleteVpcNAT). No list/apply — no NAT
// op lists gateways or applies a spec field, and RouteVerbs never widen
// ahead of the verb actually being routed.
RouteVerbs: []Verb{VerbGet, VerbCreate, VerbDelete},
// SA grant: get for the presence read, create + patch for the create (the
// agent create is SSA), delete for the teardown.
AgentVerbs: []Verb{VerbGet, VerbCreate, VerbApply, VerbDelete},
// Explicitly false (the documented rule: only the VM family degrades to a
// status-only read on an old agent; every other family errors clearly).
StatusFallbackOK: false,
}
// iptablesEIPCapability onboards the IptablesEIP lifecycle that EnsureVpcNAT/
// DeleteVpcNAT/IsVpcNATPresent route through the kubeovn seam. Cluster-scoped.
// The seam Get here is a FULL-object consumer in spirit (waitForEIPReady and
// readEIPAssignedIP read .status.ready/.status.ip), but StatusFallbackOK stays
// false per the documented rule: only the VM family sets it.
iptablesEIPCapability = AgentCapability{
GVR: schema.GroupVersionResource{Group: "kubeovn.io", Version: "v1", Resource: "iptables-eips"},
APIVersion: "kubeovn.io/v1",
Kind: "IptablesEIP",
Namespaced: false,
// Same routable set as vpcNatGatewayCapability: Get (presence check +
// readiness poll + assigned-IP read), Create, Delete.
RouteVerbs: []Verb{VerbGet, VerbCreate, VerbDelete},
AgentVerbs: []Verb{VerbGet, VerbCreate, VerbApply, VerbDelete},
// Explicitly false — see the capability doc comment above: a status-only
// degrade WOULD serve these .status readers, but only the VM family opts
// in; an old agent errors clearly instead.
StatusFallbackOK: false,
}
// iptablesSnatRuleCapability onboards the IptablesSnatRule lifecycle that
// EnsureVpcNAT/DeleteVpcNAT route through the kubeovn seam. Cluster-scoped.
iptablesSnatRuleCapability = AgentCapability{
GVR: schema.GroupVersionResource{Group: "kubeovn.io", Version: "v1", Resource: "iptables-snat-rules"},
APIVersion: "kubeovn.io/v1",
Kind: "IptablesSnatRule",
Namespaced: false,
// Same routable set as the other two NAT families: Get (readiness poll),
// Create, Delete.
RouteVerbs: []Verb{VerbGet, VerbCreate, VerbDelete},
AgentVerbs: []Verb{VerbGet, VerbCreate, VerbApply, VerbDelete},
// Explicitly false (the documented rule: only the VM family opts in).
StatusFallbackOK: false,
}
// podCapability onboards the READ-ONLY pod ops the NAT lifecycle needs:
// waitForPodRunning polls the gateway pod's status.phase (Get) and
// WaitVpcNATPodsGone polls the gateway StatefulSet's pods by label selector
// (List). Deliberately NO write verbs — dc-api never mutates a pod through the
// seam, and declaring only reads keeps the routable surface (and the agent's
// RBAC) least-privilege. Note the emitted RBAC rule (get,list) is a subset of
// the generator's fixed base inventory rule (nodes/pods get,list,watch), so
// this capability grants the agent's SA nothing it did not already have — it
// exists for GVK mapping and the routing allow-set.
podCapability = AgentCapability{
GVR: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"},
APIVersion: "v1",
Kind: "Pod",
Namespaced: true,
RouteVerbs: []Verb{VerbGet, VerbList},
AgentVerbs: []Verb{VerbGet, VerbList},
// Explicitly false (the documented rule: only the VM family opts in —
// waitForPodRunning reads .status.phase but an old agent errors clearly).
StatusFallbackOK: false,
}
)

// AgentCapabilities is THE registry: one declaration per resource family.
//
// Onboarded (RouteVerbs + AgentVerbs set): vmCapability, the three network
// families nadCapability/vpcCapability/subnetCapability (the kubeovn CRD CRUD
// slice), vmImageCapability (read/list path + image create), and the three
// slice), vmImageCapability (read/list path + image create), the three
// cloud-provider SA bootstrap families serviceAccountCapability/
// roleBindingCapability/secretCapability (F32 — EnsureCloudProviderSA routed
// through the harvester seam). vmiCapability stays a GVK-only pre-seeded entry
// that keeps the wire mapper a superset without granting any routing or RBAC.
// New families are added here (one struct each) in later phases — never by
// editing the mapper, the allow-set switch, or the RBAC YAML by hand.
// through the harvester seam), and the six NAT-slice families
// providerNetworkCapability/vlanCapability/vpcNatGatewayCapability/
// iptablesEIPCapability/iptablesSnatRuleCapability/podCapability (F15 — the
// per-VPC NAT lifecycle routed through the kubeovn seam; pods read-only).
// vmiCapability stays a GVK-only pre-seeded entry that keeps the wire mapper a
// superset without granting any routing or RBAC. New families are added here
// (one struct each) in later phases — never by editing the mapper, the
// allow-set switch, or the RBAC YAML by hand.
var AgentCapabilities = []AgentCapability{
vmCapability,
vmiCapability,
Expand All @@ -250,6 +358,12 @@ var AgentCapabilities = []AgentCapability{
serviceAccountCapability,
roleBindingCapability,
secretCapability,
providerNetworkCapability,
vlanCapability,
vpcNatGatewayCapability,
iptablesEIPCapability,
iptablesSnatRuleCapability,
podCapability,
}

// Registry returns the declared capabilities.
Expand Down Expand Up @@ -297,8 +411,9 @@ func buildDerived() {
// routed through the Vpc/Subnet families; the image family also routes
// VerbCreate for CreateImage, but VM/NAD already contribute it so the union is
// unchanged). The SA/RoleBinding/Secret families (F32) route only Get/Create,
// both already in the union, so the union is unchanged by them too.
// agentDecision consults this for
// both already in the union, so the union is unchanged by them too — as are the
// six NAT-slice families (F15), whose Get/List/Create/Delete are all already
// members. agentDecision consults this for
// membership, then gates reads vs writes by the per-family env toggles
// (VerbList falls on the read side — see IsReadVerb).
//
Expand Down
102 changes: 100 additions & 2 deletions dc-api/internal/providers/clusteraccess/capabilities_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,10 +221,102 @@ func TestRoutableVerbs_CloudProviderSAFamilies(t *testing.T) {
}
}

// TestRoutableVerbs_NATFamilies pins the routable sets for the five kubeovn
// families the F15 NAT slice onboards: ProviderNetwork and Vlan route exactly
// {Create} (the idempotent external-network bootstrap creates); VpcNatGateway,
// IptablesEIP, and IptablesSnatRule route exactly {Get, Create, Delete} (the
// per-VPC NAT lifecycle: presence/readiness reads, creates, teardown deletes).
// No NAT family routes Apply/Update/List/Watch — no NAT op applies a spec field
// or lists these CRDs, and RouteVerbs never widen ahead of the routed verb. All
// keep StatusFallbackOK=false (only the VM family sets it): the EIP readiness/
// assigned-IP reads DO consume status, but the documented rule stands and an
// old agent yields a clear error instead of a silent partial.
func TestRoutableVerbs_NATFamilies(t *testing.T) {
bootstrapOnly := []schema.GroupVersionResource{
{Group: "kubeovn.io", Version: "v1", Resource: "provider-networks"},
{Group: "kubeovn.io", Version: "v1", Resource: "vlans"},
}
for _, gvr := range bootstrapOnly {
verbs, ok := RoutableVerbs(gvr)
if !ok {
t.Errorf("%s must be an onboarded routable family", gvr.Resource)
continue
}
if !verbs[VerbCreate] {
t.Errorf("%s must route VerbCreate, got %v", gvr.Resource, verbs)
}
if len(verbs) != 1 {
t.Errorf("%s routable set = %v, want exactly {Create}", gvr.Resource, verbs)
}
if StatusFallbackOK(gvr) {
t.Errorf("%s must keep StatusFallbackOK=false", gvr.Resource)
}
}

lifecycle := []schema.GroupVersionResource{
{Group: "kubeovn.io", Version: "v1", Resource: "vpc-nat-gateways"},
{Group: "kubeovn.io", Version: "v1", Resource: "iptables-eips"},
{Group: "kubeovn.io", Version: "v1", Resource: "iptables-snat-rules"},
}
for _, gvr := range lifecycle {
verbs, ok := RoutableVerbs(gvr)
if !ok {
t.Errorf("%s must be an onboarded routable family", gvr.Resource)
continue
}
for _, v := range []Verb{VerbGet, VerbCreate, VerbDelete} {
if !verbs[v] {
t.Errorf("%s must route %v, got %v", gvr.Resource, v, verbs)
}
}
for _, v := range []Verb{VerbList, VerbApply, VerbUpdate, VerbWatch} {
if verbs[v] {
t.Errorf("%s must NOT route %v", gvr.Resource, v)
}
}
if len(verbs) != 3 {
t.Errorf("%s routable set = %v, want exactly {Get, Create, Delete}", gvr.Resource, verbs)
}
if StatusFallbackOK(gvr) {
t.Errorf("%s must keep StatusFallbackOK=false", gvr.Resource)
}
}
}

// TestRoutableVerbs_PodsReadOnly pins the pod family (F15 NAT slice) to exactly
// {Get, List} — the gateway-pod status poll and the pods-gone label-selector
// poll. NO write verb may ever be routable for pods: dc-api never mutates a pod
// through the seam, and this pin is the regression guard against a future slice
// accidentally widening a core-group workload family into the write path.
func TestRoutableVerbs_PodsReadOnly(t *testing.T) {
podGVR := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}
verbs, ok := RoutableVerbs(podGVR)
if !ok {
t.Fatal("pods must be an onboarded routable family")
}
for _, v := range []Verb{VerbGet, VerbList} {
if !verbs[v] {
t.Errorf("pods must route %v, got %v", v, verbs)
}
}
for _, v := range []Verb{VerbCreate, VerbApply, VerbUpdate, VerbDelete, VerbWatch} {
if verbs[v] {
t.Errorf("pods must NOT route %v — the family is READ-ONLY", v)
}
}
if len(verbs) != 2 {
t.Errorf("pods routable set = %v, want exactly {Get, List}", verbs)
}
if StatusFallbackOK(podGVR) {
t.Error("pods must keep StatusFallbackOK=false (only the VM family sets it)")
}
}

// TestDefaultGVKMapperResolvesKnownGVRs pins the derived GVK table to the
// onboarded entries (and same APIVersion/Kind). The six original entries plus the
// onboarded entries (and same APIVersion/Kind). The six original entries, the
// three cloud-provider-SA bootstrap families (serviceaccounts, rolebindings,
// secrets — F32).
// secrets — F32), and the six NAT-slice families (provider-networks, vlans,
// vpc-nat-gateways, iptables-eips, iptables-snat-rules, pods — F15).
func TestDefaultGVKMapperResolvesKnownGVRs(t *testing.T) {
m := DefaultGVKMapper()
cases := []struct {
Expand All @@ -241,6 +333,12 @@ func TestDefaultGVKMapperResolvesKnownGVRs(t *testing.T) {
{schema.GroupVersionResource{Group: "", Version: "v1", Resource: "serviceaccounts"}, "v1", "ServiceAccount"},
{schema.GroupVersionResource{Group: "rbac.authorization.k8s.io", Version: "v1", Resource: "rolebindings"}, "rbac.authorization.k8s.io/v1", "RoleBinding"},
{schema.GroupVersionResource{Group: "", Version: "v1", Resource: "secrets"}, "v1", "Secret"},
{schema.GroupVersionResource{Group: "kubeovn.io", Version: "v1", Resource: "provider-networks"}, "kubeovn.io/v1", "ProviderNetwork"},
{schema.GroupVersionResource{Group: "kubeovn.io", Version: "v1", Resource: "vlans"}, "kubeovn.io/v1", "Vlan"},
{schema.GroupVersionResource{Group: "kubeovn.io", Version: "v1", Resource: "vpc-nat-gateways"}, "kubeovn.io/v1", "VpcNatGateway"},
{schema.GroupVersionResource{Group: "kubeovn.io", Version: "v1", Resource: "iptables-eips"}, "kubeovn.io/v1", "IptablesEIP"},
{schema.GroupVersionResource{Group: "kubeovn.io", Version: "v1", Resource: "iptables-snat-rules"}, "kubeovn.io/v1", "IptablesSnatRule"},
{schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}, "v1", "Pod"},
}
for _, c := range cases {
av, k, ok := m.GVK(c.gvr)
Expand Down
Loading
Loading