@@ -20,6 +20,7 @@ package ssa
2020import (
2121 "context"
2222 "encoding/base64"
23+ "errors"
2324 "fmt"
2425 "sort"
2526 "strings"
@@ -29,11 +30,14 @@ import (
2930 "github.com/google/go-cmp/cmp"
3031 corev1 "k8s.io/api/core/v1"
3132 rbacv1 "k8s.io/api/rbac/v1"
33+ apierrors "k8s.io/apimachinery/pkg/api/errors"
3234 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3335 "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
36+ "k8s.io/apimachinery/pkg/runtime/schema"
3437 "k8s.io/client-go/rest"
3538 "sigs.k8s.io/controller-runtime/pkg/client"
3639
40+ ssaerrors "github.com/fluxcd/pkg/ssa/errors"
3741 "github.com/fluxcd/pkg/ssa/normalize"
3842 "github.com/fluxcd/pkg/ssa/utils"
3943)
@@ -1473,3 +1477,186 @@ func TestResourceManager_ApplyAllStaged_CRDWebhookCABundle(t *testing.T) {
14731477 }
14741478 })
14751479}
1480+
1481+ func TestApplyAllStaged_AppliesRoleAndRoleBinding (t * testing.T ) {
1482+ timeout := 10 * time .Second
1483+ ctx , cancel := context .WithTimeout (context .Background (), timeout )
1484+ defer cancel ()
1485+
1486+ id := generateName ("custom-stage" )
1487+
1488+ // Create a non-cluster-admin client to ensure dry-run checks are not bypassed.
1489+ customStageNS := & corev1.Namespace {
1490+ ObjectMeta : metav1.ObjectMeta {
1491+ Name : id ,
1492+ },
1493+ }
1494+ if err := manager .client .Create (ctx , customStageNS ); err != nil {
1495+ t .Fatal (err )
1496+ }
1497+ customStageSA := & corev1.ServiceAccount {
1498+ ObjectMeta : metav1.ObjectMeta {
1499+ Name : id ,
1500+ Namespace : id ,
1501+ },
1502+ }
1503+ if err := manager .client .Create (ctx , customStageSA ); err != nil {
1504+ t .Fatal (err )
1505+ }
1506+ customStageCR := & rbacv1.ClusterRole {
1507+ ObjectMeta : metav1.ObjectMeta {
1508+ Name : id ,
1509+ },
1510+ Rules : []rbacv1.PolicyRule {
1511+ {
1512+ APIGroups : []string {"rbac.authorization.k8s.io" },
1513+ Resources : []string {"roles" , "rolebindings" },
1514+ Verbs : []string {"create" , "update" , "delete" , "get" , "list" , "watch" , "patch" },
1515+ },
1516+ // Grant the same permissions that the test Role will grant,
1517+ // so RBAC escalation prevention allows creating the Role and
1518+ // RoleBinding.
1519+ {
1520+ APIGroups : []string {"" },
1521+ Resources : []string {"configmaps" },
1522+ Verbs : []string {"get" , "list" },
1523+ },
1524+ },
1525+ }
1526+ if err := manager .client .Create (ctx , customStageCR ); err != nil {
1527+ t .Fatal (err )
1528+ }
1529+ customStageCRB := & rbacv1.ClusterRoleBinding {
1530+ ObjectMeta : metav1.ObjectMeta {
1531+ Name : id ,
1532+ },
1533+ RoleRef : rbacv1.RoleRef {
1534+ APIGroup : rbacv1 .GroupName ,
1535+ Kind : "ClusterRole" ,
1536+ Name : customStageCR .Name ,
1537+ },
1538+ Subjects : []rbacv1.Subject {{
1539+ Kind : "ServiceAccount" ,
1540+ Name : customStageSA .Name ,
1541+ Namespace : customStageSA .Namespace ,
1542+ }},
1543+ }
1544+ if err := manager .client .Create (ctx , customStageCRB ); err != nil {
1545+ t .Fatal (err )
1546+ }
1547+
1548+ // Create a manager with the non-cluster-admin client
1549+ customStageManager := * manager
1550+ customStageCfg := rest .CopyConfig (cfg )
1551+ customStageCfg .Impersonate = rest.ImpersonationConfig {
1552+ UserName : fmt .Sprintf ("system:serviceaccount:%s:%s" , customStageSA .Namespace , customStageSA .Name ),
1553+ }
1554+ customStageClient , err := client .New (customStageCfg , client.Options {
1555+ Mapper : restMapper ,
1556+ })
1557+ if err != nil {
1558+ t .Fatal (err )
1559+ }
1560+ customStageManager .client = customStageClient
1561+
1562+ // Create a Role and RoleBinding that references the Role.
1563+ role := & unstructured.Unstructured {
1564+ Object : map [string ]any {
1565+ "apiVersion" : "rbac.authorization.k8s.io/v1" ,
1566+ "kind" : "Role" ,
1567+ "metadata" : map [string ]any {
1568+ "name" : "role" ,
1569+ "namespace" : id ,
1570+ },
1571+ "rules" : []any {
1572+ map [string ]any {
1573+ "apiGroups" : []any {"" },
1574+ "resources" : []any {"configmaps" },
1575+ "verbs" : []any {"get" , "list" },
1576+ },
1577+ },
1578+ },
1579+ }
1580+
1581+ roleBinding := & unstructured.Unstructured {
1582+ Object : map [string ]any {
1583+ "apiVersion" : "rbac.authorization.k8s.io/v1" ,
1584+ "kind" : "RoleBinding" ,
1585+ "metadata" : map [string ]any {
1586+ "name" : "role-binding" ,
1587+ "namespace" : id ,
1588+ },
1589+ "roleRef" : map [string ]any {
1590+ "apiGroup" : "rbac.authorization.k8s.io" ,
1591+ "kind" : "Role" ,
1592+ "name" : "role" ,
1593+ },
1594+ "subjects" : []any {
1595+ map [string ]any {
1596+ "kind" : "ServiceAccount" ,
1597+ "name" : "default" ,
1598+ "namespace" : id ,
1599+ },
1600+ },
1601+ },
1602+ }
1603+
1604+ objects := []* unstructured.Unstructured {roleBinding , role }
1605+
1606+ t .Run ("does not apply Role and RoleBinding together without custom stage" , func (t * testing.T ) {
1607+ opts := DefaultApplyOptions ()
1608+
1609+ _ , err := customStageManager .ApplyAllStaged (ctx , objects , opts )
1610+ if err == nil {
1611+ t .Fatal ("Expected error when applying RoleBinding before Role, got none" )
1612+ }
1613+
1614+ // Assert the error is a DryRunErr
1615+ var dryRunErr * ssaerrors.DryRunErr
1616+ if ! errors .As (err , & dryRunErr ) {
1617+ t .Fatalf ("Expected error to be *errors.DryRunErr, got %T" , err )
1618+ }
1619+
1620+ // Assert the underlying error is NotFound
1621+ if ! apierrors .IsNotFound (dryRunErr .Unwrap ()) {
1622+ t .Errorf ("Expected underlying error to be NotFound, got: %v" , dryRunErr .Unwrap ())
1623+ }
1624+
1625+ // Assert the NotFound is for the Role that the RoleBinding references
1626+ var statusErr * apierrors.StatusError
1627+ if ! errors .As (dryRunErr .Unwrap (), & statusErr ) {
1628+ t .Fatalf ("Expected underlying error to be *apierrors.StatusError, got %T" , dryRunErr .Unwrap ())
1629+ }
1630+ if statusErr .ErrStatus .Details == nil || statusErr .ErrStatus .Details .Name != "role" {
1631+ t .Errorf ("Expected NotFound to be for the Role named 'role', got: %+v" , statusErr .ErrStatus .Details )
1632+ }
1633+
1634+ // Assert the involved object is the RoleBinding
1635+ if dryRunErr .InvolvedObject ().GetKind () != "RoleBinding" {
1636+ t .Errorf ("Expected involved object to be RoleBinding, got %s" , dryRunErr .InvolvedObject ().GetKind ())
1637+ }
1638+ })
1639+
1640+ t .Run ("applies Role and RoleBinding together with Role in custom stage" , func (t * testing.T ) {
1641+ opts := DefaultApplyOptions ()
1642+ opts .CustomStageKinds = map [schema.GroupKind ]struct {}{
1643+ {Group : "rbac.authorization.k8s.io" , Kind : "Role" }: {},
1644+ }
1645+
1646+ changeSet , err := customStageManager .ApplyAllStaged (ctx , objects , opts )
1647+ if err != nil {
1648+ t .Fatal (err )
1649+ }
1650+
1651+ // Verify both objects were created
1652+ if len (changeSet .Entries ) != 2 {
1653+ t .Errorf ("Expected 2 entries, got %d" , len (changeSet .Entries ))
1654+ }
1655+
1656+ for _ , entry := range changeSet .Entries {
1657+ if diff := cmp .Diff (entry .Action , CreatedAction ); diff != "" {
1658+ t .Errorf ("Mismatch from expected value (-want +got):\n %s" , diff )
1659+ }
1660+ }
1661+ })
1662+ }
0 commit comments