Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0b479c7
feat(teams): create Role and RoleBinding for support-group SA token r…
Zaggy21 Jun 1, 2026
a5f7b99
Merge branch 'main' into feat/enable-token-request-for-team-service-a…
Zaggy21 Jun 1, 2026
a4e425c
docs: add section about requesting a token for team sa
Zaggy21 Jun 1, 2026
b35faa1
fix: correct invalid rbac apiGroup marker in cluster controller
Zaggy21 Jun 1, 2026
4975d10
fix: move support group resources deletion before scim check, fix log…
Zaggy21 Jun 2, 2026
044e346
fix function name typo
Zaggy21 Jun 2, 2026
cbf50e4
Merge branch 'main' into feat/enable-token-request-for-team-service-a…
Zaggy21 Jun 10, 2026
b98827e
fix(teams): watch owned Role, RoleBinding, ServiceAccount and rename …
Zaggy21 Jun 10, 2026
65f8887
feat(teams): add TokenRequest mutating webhook to cap SA token expira…
Zaggy21 Jun 10, 2026
6bd6a3f
docs(teams): clarify token lifetime is capped at 90 days by Greenhouse
Zaggy21 Jun 10, 2026
9cc7f55
Merge branch 'main' into feat/enable-token-request-for-team-service-a…
Zaggy21 Jun 10, 2026
b935141
fix(teams): preserve original error from SA lookup in TokenRequest we…
Zaggy21 Jun 10, 2026
606da84
fix(teams): exclude system namespaces from TokenRequest webhook via n…
Zaggy21 Jun 10, 2026
6c48697
fix(teams): log actual expiration value instead of pointer address in…
Zaggy21 Jun 10, 2026
53e972a
Merge branch 'main' into feat/enable-token-request-for-team-service-a…
Zaggy21 Jun 11, 2026
d5d3c4e
Merge branch 'main' into feat/enable-token-request-for-team-service-a…
Zaggy21 Jun 11, 2026
433213d
feat: remove TokenRequest webhook
Zaggy21 Jun 16, 2026
fda5ab0
Merge branch 'main' into feat/enable-token-request-for-team-service-a…
Zaggy21 Jun 16, 2026
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
6 changes: 3 additions & 3 deletions charts/manager/templates/rbac/manager-role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -187,9 +187,10 @@ rules:
- update
- watch
- apiGroups:
- rbac
- rbac.authorization.k8s.io
resources:
- clusterrolebindings
- clusterroles
verbs:
- create
- get
Expand All @@ -200,12 +201,11 @@ rules:
- apiGroups:
- rbac.authorization.k8s.io
resources:
- clusterrolebindings
- clusterroles
- rolebindings
- roles
verbs:
- create
- delete
- get
- list
- patch
Expand Down
10 changes: 10 additions & 0 deletions docs/user-guides/team/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ Use this ServiceAccount for CI/CD pipelines, custom controllers, or scheduled jo
kubectl get serviceaccount my-team-sa -n my-organization
```

### Requesting a token

Greenhouse creates a Role and RoleBinding that allow both support-group members and the ServiceAccount itself to request tokens. Use `kubectl create token` to obtain a credential for CI/CD use:

```bash
kubectl create token my-team-sa -n my-organization --duration 2160h
```

The token can be used as a `Bearer` token when authenticating against the Greenhouse API server.

> **Note**: The ServiceAccount is created during Team reconciliation, which requires `spec.mappedIdPGroup` to be set and the Organization's SCIM integration to be configured. See [Setting up Team members synchronization](../../organization/creation#setting-up-team-members-synchronization-with-greenhouse) for SCIM configuration details.

> **Note**: The `greenhouse.sap/owned-by` label on the ServiceAccount is immutable once set β€” it cannot be changed or removed.
Expand Down
2 changes: 1 addition & 1 deletion internal/controller/cluster/cluster_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ type RemoteClusterReconciler struct {
//+kubebuilder:rbac:groups="",resources=serviceaccounts,verbs=get;list;watch;update;patch;create
//+kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch;update;patch;create;delete
//+kubebuilder:rbac:groups="events.k8s.io",resources=events,verbs=get;list;watch;update;patch
//+kubebuilder:rbac:groups="rbac",resources=clusterrolebindings,verbs=get;list;watch;update;patch;create
//+kubebuilder:rbac:groups="rbac.authorization.k8s.io",resources=clusterrolebindings,verbs=get;list;watch;update;patch;create

// SetupWithManager sets up the controller with the Manager.
func (r *RemoteClusterReconciler) SetupWithManager(name string, mgr ctrl.Manager) error {
Expand Down
157 changes: 142 additions & 15 deletions internal/controller/team/team_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

"github.com/pkg/errors"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/wait"
Expand Down Expand Up @@ -53,6 +54,8 @@ type TeamController struct {
//+kubebuilder:rbac:groups=greenhouse.sap,resources=organizations,verbs=get
//+kubebuilder:rbac:groups="events.k8s.io",resources=events,verbs=get;list;watch;update;patch
//+kubebuilder:rbac:groups="",resources=serviceaccounts,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups="rbac.authorization.k8s.io",resources=roles,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups="rbac.authorization.k8s.io",resources=rolebindings,verbs=get;list;watch;create;update;patch;delete

// SetupWithManager sets up the controller with the Manager.
func (r *TeamController) SetupWithManager(name string, mgr ctrl.Manager) error {
Expand All @@ -62,6 +65,9 @@ func (r *TeamController) SetupWithManager(name string, mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
Named(name).
For(&greenhousev1alpha1.Team{}).
Owns(&rbacv1.Role{}).
Owns(&rbacv1.RoleBinding{}).
Owns(&corev1.ServiceAccount{}).
// If an Organization's .Spec was changed, reconcile relevant Teams.
Watches(&greenhousev1alpha1.Organization{}, handler.EnqueueRequestsFromMapFunc(r.enqueueAllTeamsForOrganization),
builder.WithPredicates(
Expand Down Expand Up @@ -117,6 +123,13 @@ func (r *TeamController) EnsureCreated(ctx context.Context, object lifecycle.Run

initTeamStatus(team)

// Clean up support-group resources immediately if the label is not set, regardless of SCIM readiness.
if team.Labels[greenhouseapis.LabelKeySupportGroup] != "true" {
if err := r.ensureSupportGroupResourcesDeleted(ctx, team); err != nil {
return ctrl.Result{}, lifecycle.Failed, err
Comment thread
Zaggy21 marked this conversation as resolved.
}
}

var organization = new(greenhousev1alpha1.Organization)
if err := r.Get(ctx, types.NamespacedName{Name: object.GetNamespace()}, organization); err != nil {
return ctrl.Result{}, lifecycle.Failed, client.IgnoreNotFound(err)
Expand Down Expand Up @@ -163,14 +176,15 @@ func (r *TeamController) EnsureCreated(ctx context.Context, object lifecycle.Run

updateTeamMembersCountMetric(team, len(users))

// Reconcile ServiceAccount for support group teams
// Reconcile ServiceAccount, Role, and RoleBinding for support-group teams.
if team.Labels[greenhouseapis.LabelKeySupportGroup] == "true" {
if err := r.reconcileSupportGroupServiceAccount(ctx, team); err != nil {
return ctrl.Result{}, lifecycle.Failed, err
}
} else {
// If the support-group label was removed, delete the SA if it still exists.
if err := r.deleteSupportGroupServiceAccountIfExists(ctx, team); err != nil {
if err := r.reconcileSupportGroupRole(ctx, team); err != nil {
return ctrl.Result{}, lifecycle.Failed, err
}
if err := r.reconcileSupportGroupRoleBinding(ctx, team); err != nil {
return ctrl.Result{}, lifecycle.Failed, err
}
}
Expand Down Expand Up @@ -282,20 +296,133 @@ func (r *TeamController) reconcileSupportGroupServiceAccount(ctx context.Context
return nil
}

func (r *TeamController) deleteSupportGroupServiceAccountIfExists(ctx context.Context, team *greenhousev1alpha1.Team) error {
serviceAccount := &corev1.ServiceAccount{}
err := r.Get(ctx, types.NamespacedName{
Name: team.Name + "-sa",
Namespace: team.Namespace,
}, serviceAccount)
func (r *TeamController) reconcileSupportGroupRole(ctx context.Context, team *greenhousev1alpha1.Team) error {
saName := team.Name + "-sa"
role := &rbacv1.Role{
ObjectMeta: metav1.ObjectMeta{
Name: team.Name + "-sa-token-request",
Namespace: team.Namespace,
},
}

result, err := clientutil.CreateOrPatch(ctx, r.Client, role, func() error {
role.Rules = []rbacv1.PolicyRule{
{
APIGroups: []string{""},
Resources: []string{"serviceaccounts/token"},
Verbs: []string{"create"},
ResourceNames: []string{saName},
},
}
return controllerutil.SetControllerReference(team, role, r.Scheme())
})
if err != nil {
return err
}

switch result {
case clientutil.OperationResultCreated:
log.FromContext(ctx).Info("created support group role", "name", role.Name, "namespace", role.Namespace)
r.recorder.Eventf(team, role, corev1.EventTypeNormal, "CreatedRole", "reconciling support group role", "Created Role %s/%s", role.Namespace, role.Name)
case clientutil.OperationResultUpdated:
log.FromContext(ctx).Info("updated support group role", "name", role.Name, "namespace", role.Namespace)
r.recorder.Eventf(team, role, corev1.EventTypeNormal, "UpdatedRole", "reconciling support group role", "Updated Role %s/%s", role.Namespace, role.Name)
}

return nil
}

func (r *TeamController) reconcileSupportGroupRoleBinding(ctx context.Context, team *greenhousev1alpha1.Team) error {
saName := team.Name + "-sa"
roleName := team.Name + "-sa-token-request"
roleBinding := &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: roleName,
Namespace: team.Namespace,
},
}

result, err := clientutil.CreateOrPatch(ctx, r.Client, roleBinding, func() error {
roleBinding.RoleRef = rbacv1.RoleRef{
APIGroup: rbacv1.GroupName,
Kind: "Role",
Name: roleName,
}
roleBinding.Subjects = []rbacv1.Subject{
{
APIGroup: rbacv1.GroupName,
Kind: rbacv1.GroupKind,
Name: "support-group:" + team.Name,
},
{
Kind: rbacv1.ServiceAccountKind,
Name: saName,
Namespace: team.Namespace,
},
}
return controllerutil.SetControllerReference(team, roleBinding, r.Scheme())
})
if err != nil {
return client.IgnoreNotFound(err)
return err
}

switch result {
case clientutil.OperationResultCreated:
log.FromContext(ctx).Info("created support group role binding", "name", roleBinding.Name, "namespace", roleBinding.Namespace)
r.recorder.Eventf(team, roleBinding, corev1.EventTypeNormal, "CreatedRoleBinding", "reconciling support group role binding", "Created RoleBinding %s/%s", roleBinding.Namespace, roleBinding.Name)
case clientutil.OperationResultUpdated:
log.FromContext(ctx).Info("updated support group role binding", "name", roleBinding.Name, "namespace", roleBinding.Namespace)
r.recorder.Eventf(team, roleBinding, corev1.EventTypeNormal, "UpdatedRoleBinding", "reconciling support group role binding", "Updated RoleBinding %s/%s", roleBinding.Namespace, roleBinding.Name)
}

return nil
}

func (r *TeamController) ensureSupportGroupResourcesDeleted(ctx context.Context, team *greenhousev1alpha1.Team) error {
resourceName := team.Name + "-sa-token-request"

roleBinding := &rbacv1.RoleBinding{}
if err := r.Get(ctx, types.NamespacedName{Name: resourceName, Namespace: team.Namespace}, roleBinding); err == nil {
if err := r.Delete(ctx, roleBinding); err != nil {
if client.IgnoreNotFound(err) != nil {
return err
}
} else {
log.FromContext(ctx).Info("deleted support group role binding", "name", roleBinding.Name, "namespace", roleBinding.Namespace)
r.recorder.Eventf(team, roleBinding, corev1.EventTypeNormal, "DeletedRoleBinding", "support-group label removed", "Deleted RoleBinding %s/%s", roleBinding.Namespace, roleBinding.Name)
}
} else if client.IgnoreNotFound(err) != nil {
return err
}
Comment thread
Zaggy21 marked this conversation as resolved.

role := &rbacv1.Role{}
if err := r.Get(ctx, types.NamespacedName{Name: resourceName, Namespace: team.Namespace}, role); err == nil {
if err := r.Delete(ctx, role); err != nil {
if client.IgnoreNotFound(err) != nil {
return err
}
} else {
log.FromContext(ctx).Info("deleted support group role", "name", role.Name, "namespace", role.Namespace)
r.recorder.Eventf(team, role, corev1.EventTypeNormal, "DeletedRole", "support-group label removed", "Deleted Role %s/%s", role.Namespace, role.Name)
}
} else if client.IgnoreNotFound(err) != nil {
return err
}
Comment thread
Zaggy21 marked this conversation as resolved.
if err := r.Delete(ctx, serviceAccount); err != nil {
return client.IgnoreNotFound(err)

serviceAccount := &corev1.ServiceAccount{}
if err := r.Get(ctx, types.NamespacedName{Name: team.Name + "-sa", Namespace: team.Namespace}, serviceAccount); err == nil {
if err := r.Delete(ctx, serviceAccount); err != nil {
if client.IgnoreNotFound(err) != nil {
return err
}
} else {
log.FromContext(ctx).Info("deleted support group service account", "name", serviceAccount.Name, "namespace", serviceAccount.Namespace)
r.recorder.Eventf(team, serviceAccount, corev1.EventTypeNormal, "DeletedServiceAccount", "support-group label removed", "Deleted ServiceAccount %s/%s", serviceAccount.Namespace, serviceAccount.Name)
}
} else if client.IgnoreNotFound(err) != nil {
return err
}
Comment thread
Zaggy21 marked this conversation as resolved.
log.FromContext(ctx).Info("deleted support group service account", "name", serviceAccount.Name, "namespace", serviceAccount.Namespace)
r.recorder.Eventf(team, serviceAccount, corev1.EventTypeNormal, "DeletedServiceAccount", "support-group label removed", "Deleted ServiceAccount %s/%s", serviceAccount.Namespace, serviceAccount.Name)

return nil
}

Expand Down
82 changes: 70 additions & 12 deletions internal/controller/team/team_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
. "github.com/onsi/gomega"
"github.com/onsi/gomega/gbytes"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
Expand Down Expand Up @@ -273,37 +274,94 @@ var _ = Describe("TeamController", Ordered, func() {
}).Should(Succeed(), "ServiceAccount should be created for support-group team")
})

It("should delete the ServiceAccount when support-group label is removed from Team", func() {
It("should create a Role and RoleBinding for a support-group Team", func() {
By("creating a support-group team")
team := setup.CreateTeam(test.Ctx, supportGroupTeamName,
test.WithMappedIDPGroup(validIdpGroupName),
test.WithTeamLabel(greenhouseapis.LabelKeySupportGroup, "true"),
)

expectedRBACName := team.Name + "-sa-token-request"
expectedSAName := team.Name + "-sa"

By("waiting for the ServiceAccount to be created")
Eventually(func(g Gomega) {
sa := &corev1.ServiceAccount{}
role := &rbacv1.Role{}
err := setup.Get(test.Ctx, types.NamespacedName{
Name: expectedRBACName,
Namespace: setup.Namespace(),
}, role)
g.Expect(err).ShouldNot(HaveOccurred(), "Role should have been created")
g.Expect(role.Rules).To(HaveLen(1), "Role should have exactly one rule")
g.Expect(role.Rules[0].APIGroups).To(ConsistOf(""), "rule should target core API group")
g.Expect(role.Rules[0].Resources).To(ConsistOf("serviceaccounts/token"), "rule should target serviceaccounts/token")
g.Expect(role.Rules[0].Verbs).To(ConsistOf("create"), "rule should allow create verb")
g.Expect(role.Rules[0].ResourceNames).To(ConsistOf(expectedSAName), "rule should be scoped to the team SA")
g.Expect(role.OwnerReferences).To(HaveLen(1), "Role should have exactly one owner reference")
g.Expect(role.OwnerReferences[0].Name).To(Equal(team.Name), "Role owner reference should point to the Team")
}).Should(Succeed(), "Role should be created for support-group team")

Eventually(func(g Gomega) {
rb := &rbacv1.RoleBinding{}
err := setup.Get(test.Ctx, types.NamespacedName{
Name: expectedRBACName,
Namespace: setup.Namespace(),
}, rb)
g.Expect(err).ShouldNot(HaveOccurred(), "RoleBinding should have been created")
g.Expect(rb.RoleRef.Kind).To(Equal("Role"), "RoleRef should reference a Role")
g.Expect(rb.RoleRef.Name).To(Equal(expectedRBACName), "RoleRef should reference the correct Role")
g.Expect(rb.Subjects).To(HaveLen(2), "RoleBinding should have exactly two subjects")
g.Expect(rb.Subjects).To(ContainElement(rbacv1.Subject{
APIGroup: rbacv1.GroupName,
Kind: rbacv1.GroupKind,
Name: "support-group:" + team.Name,
}), "RoleBinding should include the support-group group")
g.Expect(rb.Subjects).To(ContainElement(rbacv1.Subject{
Kind: rbacv1.ServiceAccountKind,
Name: expectedSAName,
Namespace: setup.Namespace(),
}, sa)
g.Expect(err).ShouldNot(HaveOccurred(), "ServiceAccount should have been created")
}).Should(Succeed(), "ServiceAccount should be created for support-group team")
}), "RoleBinding should include the team ServiceAccount")
g.Expect(rb.OwnerReferences).To(HaveLen(1), "RoleBinding should have exactly one owner reference")
g.Expect(rb.OwnerReferences[0].Name).To(Equal(team.Name), "RoleBinding owner reference should point to the Team")
}).Should(Succeed(), "RoleBinding should be created for support-group team")
})

It("should delete the ServiceAccount, Role, and RoleBinding when support-group label is removed from Team", func() {
By("creating a support-group team")
team := setup.CreateTeam(test.Ctx, supportGroupTeamName,
test.WithMappedIDPGroup(validIdpGroupName),
test.WithTeamLabel(greenhouseapis.LabelKeySupportGroup, "true"),
)

expectedSAName := team.Name + "-sa"
expectedRBACName := team.Name + "-sa-token-request"

By("waiting for the ServiceAccount, Role, and RoleBinding to be created")
Eventually(func(g Gomega) {
sa := &corev1.ServiceAccount{}
g.Expect(setup.Get(test.Ctx, types.NamespacedName{Name: expectedSAName, Namespace: setup.Namespace()}, sa)).To(Succeed())
role := &rbacv1.Role{}
g.Expect(setup.Get(test.Ctx, types.NamespacedName{Name: expectedRBACName, Namespace: setup.Namespace()}, role)).To(Succeed())
rb := &rbacv1.RoleBinding{}
g.Expect(setup.Get(test.Ctx, types.NamespacedName{Name: expectedRBACName, Namespace: setup.Namespace()}, rb)).To(Succeed())
}).Should(Succeed(), "SA, Role, and RoleBinding should be created for support-group team")

By("removing the support-group label from the Team")
test.MustRemoveLabel(test.Ctx, setup.Client, team, greenhouseapis.LabelKeySupportGroup)

By("ensuring the ServiceAccount is deleted")
By("ensuring the ServiceAccount, Role, and RoleBinding are deleted")
Eventually(func(g Gomega) {
sa := &corev1.ServiceAccount{}
err := setup.Get(test.Ctx, types.NamespacedName{
Name: expectedSAName,
Namespace: setup.Namespace(),
}, sa)
err := setup.Get(test.Ctx, types.NamespacedName{Name: expectedSAName, Namespace: setup.Namespace()}, sa)
g.Expect(apierrors.IsNotFound(err)).To(BeTrue(), "ServiceAccount should have been deleted after label removal")
}).Should(Succeed(), "ServiceAccount should be deleted when support-group label is removed")

role := &rbacv1.Role{}
err = setup.Get(test.Ctx, types.NamespacedName{Name: expectedRBACName, Namespace: setup.Namespace()}, role)
g.Expect(apierrors.IsNotFound(err)).To(BeTrue(), "Role should have been deleted after label removal")

rb := &rbacv1.RoleBinding{}
err = setup.Get(test.Ctx, types.NamespacedName{Name: expectedRBACName, Namespace: setup.Namespace()}, rb)
g.Expect(apierrors.IsNotFound(err)).To(BeTrue(), "RoleBinding should have been deleted after label removal")
}).Should(Succeed(), "SA, Role, and RoleBinding should be deleted when support-group label is removed")
})
})

Expand Down
Loading