From ea15b5cb58c7fb922e30a442c4df36708ea721ec Mon Sep 17 00:00:00 2001 From: "c1-dev-bot[bot]" <2740113+c1-dev-bot[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 20:59:56 +0000 Subject: [PATCH 1/2] feat: add write provisioning (Grant/Revoke) for ClusterRole bindings Implement Grant and Revoke methods on the clusterRoleBuilder to enable JIT access provisioning via ClusterRoleBindings and namespace-scoped RoleBindings. Grant creates a new ClusterRoleBinding (cluster-scoped) or RoleBinding (namespace-scoped) with a deterministic name and managed-by label. Supports kube_user, kube_group, and service_account principals. Revoke first tries to delete the baton-managed binding by deterministic name. If not found, it searches all bindings for the role and removes the subject (handles both baton-created and pre-existing bindings). Both operations are idempotent: Grant handles already-exists, Revoke handles not-found gracefully. Fixes: CXH-1529 --- baton_capabilities.json | 6 +- pkg/connector/clusterrole.go | 279 +++++++++++++++++++++++++++++++++++ 2 files changed, 283 insertions(+), 2 deletions(-) diff --git a/baton_capabilities.json b/baton_capabilities.json index c2a05251..5c849644 100644 --- a/baton_capabilities.json +++ b/baton_capabilities.json @@ -10,7 +10,8 @@ ] }, "capabilities": [ - "CAPABILITY_SYNC" + "CAPABILITY_SYNC", + "CAPABILITY_PROVISION" ], "permissions": {} }, @@ -151,7 +152,8 @@ } ], "connectorCapabilities": [ - "CAPABILITY_SYNC" + "CAPABILITY_SYNC", + "CAPABILITY_PROVISION" ], "credentialDetails": {} } \ No newline at end of file diff --git a/pkg/connector/clusterrole.go b/pkg/connector/clusterrole.go index 75303f0a..ae454a43 100644 --- a/pkg/connector/clusterrole.go +++ b/pkg/connector/clusterrole.go @@ -3,10 +3,13 @@ package connector import ( "context" "fmt" + "regexp" + "strings" "sync" "time" rbacv1 "k8s.io/api/rbac/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" @@ -14,6 +17,7 @@ import ( "github.com/conductorone/baton-sdk/pkg/annotations" "github.com/conductorone/baton-sdk/pkg/pagination" "github.com/conductorone/baton-sdk/pkg/types/entitlement" + sdkGrant "github.com/conductorone/baton-sdk/pkg/types/grant" rs "github.com/conductorone/baton-sdk/pkg/types/resource" "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "go.uber.org/zap" @@ -252,6 +256,281 @@ func (c *clusterRoleBuilder) cacheNamespaces(ctx context.Context) error { return nil } +var k8sNameRegexp = regexp.MustCompile(`[^a-z0-9-]`) + +const ( + maxK8sNameLen = 253 + batonLabel = "app.kubernetes.io/managed-by" + batonLabelVal = "baton-kubernetes" +) + +func sanitizeK8sName(s string) string { + s = strings.ToLower(s) + s = k8sNameRegexp.ReplaceAllString(s, "-") + s = strings.Trim(s, "-") + if len(s) > maxK8sNameLen { + s = s[:maxK8sNameLen] + } + return s +} + +func bindingName(clusterRole, subjectKind, subjectName string) string { + return sanitizeK8sName(fmt.Sprintf("baton-%s-%s-%s", clusterRole, subjectKind, subjectName)) +} + +func principalToK8sSubject(principal *v2.Resource) (rbacv1.Subject, error) { + switch principal.GetId().GetResourceType() { + case ResourceTypeKubeUser.Id: + return rbacv1.Subject{ + Kind: SubjectKindUser, + Name: principal.GetId().GetResource(), + APIGroup: RBACAPIGroup, + }, nil + case ResourceTypeKubeGroup.Id: + return rbacv1.Subject{ + Kind: SubjectKindGroup, + Name: principal.GetId().GetResource(), + APIGroup: RBACAPIGroup, + }, nil + case ResourceTypeServiceAccount.Id: + parts := strings.SplitN(principal.GetId().GetResource(), "/", 2) + if len(parts) != 2 { + return rbacv1.Subject{}, fmt.Errorf("baton-kubernetes: invalid service account ID %q, expected namespace/name", principal.GetId().GetResource()) + } + return rbacv1.Subject{ + Kind: SubjectKindServiceAccount, + Name: parts[1], + Namespace: parts[0], + }, nil + default: + return rbacv1.Subject{}, fmt.Errorf("baton-kubernetes: unsupported principal type %q", principal.GetId().GetResourceType()) + } +} + +// Grant creates a ClusterRoleBinding (cluster-scoped) or RoleBinding (namespace-scoped) +// to bind the principal to the ClusterRole. +func (c *clusterRoleBuilder) Grant(ctx context.Context, principal *v2.Resource, ent *v2.Entitlement) ([]*v2.Grant, annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + + clusterRoleName := ent.GetResource().GetId().GetResource() + slug := ent.GetSlug() + + subject, err := principalToK8sSubject(principal) + if err != nil { + return nil, nil, err + } + + l.Info("granting cluster role", + zap.String("cluster_role", clusterRoleName), + zap.String("slug", slug), + zap.String("subject_kind", subject.Kind), + zap.String("subject_name", subject.Name), + ) + + labels := map[string]string{ + batonLabel: batonLabelVal, + } + + if slug == clusterScopedMember { + name := bindingName(clusterRoleName, subject.Kind, subject.Name) + binding := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: labels, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: RBACAPIGroup, + Kind: "ClusterRole", + Name: clusterRoleName, + }, + Subjects: []rbacv1.Subject{subject}, + } + + _, err := c.client.RbacV1().ClusterRoleBindings().Create(ctx, binding, metav1.CreateOptions{}) + if err != nil { + if k8serrors.IsAlreadyExists(err) { + g := sdkGrant.NewGrant(ent.GetResource(), slug, principal.GetId()) + return []*v2.Grant{g}, nil, nil + } + return nil, nil, fmt.Errorf("baton-kubernetes: failed to create cluster role binding: %w", err) + } + } else { + parts := strings.SplitN(slug, ":", 2) + if len(parts) != 2 { + return nil, nil, fmt.Errorf("baton-kubernetes: invalid entitlement slug %q", slug) + } + namespace := parts[0] + + name := bindingName(clusterRoleName, subject.Kind, subject.Name) + binding := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: labels, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: RBACAPIGroup, + Kind: "ClusterRole", + Name: clusterRoleName, + }, + Subjects: []rbacv1.Subject{subject}, + } + + _, err := c.client.RbacV1().RoleBindings(namespace).Create(ctx, binding, metav1.CreateOptions{}) + if err != nil { + if k8serrors.IsAlreadyExists(err) { + g := sdkGrant.NewGrant(ent.GetResource(), slug, principal.GetId()) + return []*v2.Grant{g}, nil, nil + } + return nil, nil, fmt.Errorf("baton-kubernetes: failed to create role binding in namespace %s: %w", namespace, err) + } + } + + g := sdkGrant.NewGrant(ent.GetResource(), slug, principal.GetId()) + return []*v2.Grant{g}, nil, nil +} + +// Revoke removes the principal's binding to the ClusterRole. +// It first tries to delete a baton-managed binding by deterministic name. +// If not found, it searches all bindings for the role and removes the subject. +func (c *clusterRoleBuilder) Revoke(ctx context.Context, grantObj *v2.Grant) (annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + + clusterRoleName := grantObj.GetEntitlement().GetResource().GetId().GetResource() + slug := grantObj.GetEntitlement().GetSlug() + + subject, err := principalToK8sSubject(grantObj.GetPrincipal()) + if err != nil { + return nil, err + } + + l.Info("revoking cluster role", + zap.String("cluster_role", clusterRoleName), + zap.String("slug", slug), + zap.String("subject_kind", subject.Kind), + zap.String("subject_name", subject.Name), + ) + + if slug == clusterScopedMember { + return c.revokeClusterScoped(ctx, clusterRoleName, subject) + } + + parts := strings.SplitN(slug, ":", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("baton-kubernetes: invalid entitlement slug %q", slug) + } + return c.revokeNamespaceScoped(ctx, clusterRoleName, parts[0], subject) +} + +func (c *clusterRoleBuilder) revokeClusterScoped(ctx context.Context, clusterRoleName string, subject rbacv1.Subject) (annotations.Annotations, error) { + name := bindingName(clusterRoleName, subject.Kind, subject.Name) + + err := c.client.RbacV1().ClusterRoleBindings().Delete(ctx, name, metav1.DeleteOptions{}) + if err == nil { + return nil, nil + } + if !k8serrors.IsNotFound(err) { + return nil, fmt.Errorf("baton-kubernetes: failed to delete cluster role binding %s: %w", name, err) + } + + return c.revokeSubjectFromClusterRoleBindings(ctx, clusterRoleName, subject) +} + +func (c *clusterRoleBuilder) revokeSubjectFromClusterRoleBindings(ctx context.Context, clusterRoleName string, subject rbacv1.Subject) (annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + + var continueToken string + for { + bindings, err := c.client.RbacV1().ClusterRoleBindings().List(ctx, metav1.ListOptions{ + Limit: ResourcesPageSize, + Continue: continueToken, + }) + if err != nil { + return nil, fmt.Errorf("baton-kubernetes: failed to list cluster role bindings: %w", err) + } + + for _, binding := range bindings.Items { + if binding.RoleRef.Kind != "ClusterRole" || binding.RoleRef.Name != clusterRoleName { + continue + } + if updated, found := removeSubject(binding.Subjects, subject); found { + if len(updated) == 0 { + if err := c.client.RbacV1().ClusterRoleBindings().Delete(ctx, binding.Name, metav1.DeleteOptions{}); err != nil && !k8serrors.IsNotFound(err) { + return nil, fmt.Errorf("baton-kubernetes: failed to delete cluster role binding %s: %w", binding.Name, err) + } + } else { + binding.Subjects = updated + if _, err := c.client.RbacV1().ClusterRoleBindings().Update(ctx, &binding, metav1.UpdateOptions{}); err != nil { + return nil, fmt.Errorf("baton-kubernetes: failed to update cluster role binding %s: %w", binding.Name, err) + } + } + l.Info("revoked subject from cluster role binding", zap.String("binding", binding.Name)) + return nil, nil + } + } + + if bindings.Continue == "" { + break + } + continueToken = bindings.Continue + } + + return nil, nil +} + +func (c *clusterRoleBuilder) revokeNamespaceScoped(ctx context.Context, clusterRoleName, namespace string, subject rbacv1.Subject) (annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + name := bindingName(clusterRoleName, subject.Kind, subject.Name) + + err := c.client.RbacV1().RoleBindings(namespace).Delete(ctx, name, metav1.DeleteOptions{}) + if err == nil { + return nil, nil + } + if !k8serrors.IsNotFound(err) { + return nil, fmt.Errorf("baton-kubernetes: failed to delete role binding %s in namespace %s: %w", name, namespace, err) + } + + bindings, err := c.client.RbacV1().RoleBindings(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("baton-kubernetes: failed to list role bindings in namespace %s: %w", namespace, err) + } + + for _, binding := range bindings.Items { + if binding.RoleRef.Kind != "ClusterRole" || binding.RoleRef.Name != clusterRoleName { + continue + } + if updated, found := removeSubject(binding.Subjects, subject); found { + if len(updated) == 0 { + if err := c.client.RbacV1().RoleBindings(namespace).Delete(ctx, binding.Name, metav1.DeleteOptions{}); err != nil && !k8serrors.IsNotFound(err) { + return nil, fmt.Errorf("baton-kubernetes: failed to delete role binding %s: %w", binding.Name, err) + } + } else { + binding.Subjects = updated + if _, err := c.client.RbacV1().RoleBindings(namespace).Update(ctx, &binding, metav1.UpdateOptions{}); err != nil { + return nil, fmt.Errorf("baton-kubernetes: failed to update role binding %s: %w", binding.Name, err) + } + } + l.Info("revoked subject from role binding", zap.String("binding", binding.Name), zap.String("namespace", namespace)) + return nil, nil + } + } + + return nil, nil +} + +func removeSubject(subjects []rbacv1.Subject, target rbacv1.Subject) ([]rbacv1.Subject, bool) { + var result []rbacv1.Subject + found := false + for _, s := range subjects { + if s.Kind == target.Kind && s.Name == target.Name && s.Namespace == target.Namespace { + found = true + continue + } + result = append(result, s) + } + return result, found +} + // newClusterRoleBuilder creates a new cluster role builder. func newClusterRoleBuilder(client kubernetes.Interface, bindingProvider ClusterRoleBindingProvider) *clusterRoleBuilder { return &clusterRoleBuilder{ From 39be3622253919f59b9644da293480f2e67f8779 Mon Sep 17 00:00:00 2001 From: "c1-dev-bot[bot]" <2740113+c1-dev-bot[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 21:08:02 +0000 Subject: [PATCH 2/2] fix: extract ClusterRole/Role string literals to constants for goconst lint Fixes goconst lint failure by replacing repeated "ClusterRole" and "Role" string literals with package-level constants RoleRefKindClusterRole and RoleRefKindRole. --- pkg/connector/clusterrole.go | 8 ++++---- pkg/connector/connector.go | 8 +++++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/pkg/connector/clusterrole.go b/pkg/connector/clusterrole.go index ae454a43..354687f7 100644 --- a/pkg/connector/clusterrole.go +++ b/pkg/connector/clusterrole.go @@ -340,7 +340,7 @@ func (c *clusterRoleBuilder) Grant(ctx context.Context, principal *v2.Resource, }, RoleRef: rbacv1.RoleRef{ APIGroup: RBACAPIGroup, - Kind: "ClusterRole", + Kind: RoleRefKindClusterRole, Name: clusterRoleName, }, Subjects: []rbacv1.Subject{subject}, @@ -370,7 +370,7 @@ func (c *clusterRoleBuilder) Grant(ctx context.Context, principal *v2.Resource, }, RoleRef: rbacv1.RoleRef{ APIGroup: RBACAPIGroup, - Kind: "ClusterRole", + Kind: RoleRefKindClusterRole, Name: clusterRoleName, }, Subjects: []rbacv1.Subject{subject}, @@ -450,7 +450,7 @@ func (c *clusterRoleBuilder) revokeSubjectFromClusterRoleBindings(ctx context.Co } for _, binding := range bindings.Items { - if binding.RoleRef.Kind != "ClusterRole" || binding.RoleRef.Name != clusterRoleName { + if binding.RoleRef.Kind != RoleRefKindClusterRole || binding.RoleRef.Name != clusterRoleName { continue } if updated, found := removeSubject(binding.Subjects, subject); found { @@ -496,7 +496,7 @@ func (c *clusterRoleBuilder) revokeNamespaceScoped(ctx context.Context, clusterR } for _, binding := range bindings.Items { - if binding.RoleRef.Kind != "ClusterRole" || binding.RoleRef.Name != clusterRoleName { + if binding.RoleRef.Kind != RoleRefKindClusterRole || binding.RoleRef.Name != clusterRoleName { continue } if updated, found := removeSubject(binding.Subjects, subject); found { diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index e94b4425..e3ddfcd1 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -25,6 +25,8 @@ const ( ResourceTypeRoleBinding = "rolebinding" SubjectTypeGroup = "Group" SubjectTypeUser = "User" + RoleRefKindClusterRole = "ClusterRole" + RoleRefKindRole = "Role" ) // Resource type definitions. @@ -348,7 +350,7 @@ func (k *Kubernetes) GetMatchingRoleBindings(ctx context.Context, namespace, rol var result []rbacv1.RoleBinding for _, binding := range k.roleBindingsCache { - if binding.Namespace == namespace && binding.RoleRef.Kind == "Role" && binding.RoleRef.Name == roleName { + if binding.Namespace == namespace && binding.RoleRef.Kind == RoleRefKindRole && binding.RoleRef.Name == roleName { result = append(result, binding) } } @@ -369,14 +371,14 @@ func (k *Kubernetes) GetMatchingBindingsForClusterRole(ctx context.Context, clus var roleBindings []rbacv1.RoleBinding for _, binding := range k.roleBindingsCache { - if binding.RoleRef.Kind == "ClusterRole" && binding.RoleRef.Name == clusterRoleName { + if binding.RoleRef.Kind == RoleRefKindClusterRole && binding.RoleRef.Name == clusterRoleName { roleBindings = append(roleBindings, binding) } } var clusterRoleBindings []rbacv1.ClusterRoleBinding for _, binding := range k.clusterRoleBindingsCache { - if binding.RoleRef.Kind == "ClusterRole" && binding.RoleRef.Name == clusterRoleName { + if binding.RoleRef.Kind == RoleRefKindClusterRole && binding.RoleRef.Name == clusterRoleName { clusterRoleBindings = append(clusterRoleBindings, binding) } }