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
6 changes: 4 additions & 2 deletions baton_capabilities.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
]
},
"capabilities": [
"CAPABILITY_SYNC"
"CAPABILITY_SYNC",
"CAPABILITY_PROVISION"
],
"permissions": {}
},
Expand Down Expand Up @@ -151,7 +152,8 @@
}
],
"connectorCapabilities": [
"CAPABILITY_SYNC"
"CAPABILITY_SYNC",
"CAPABILITY_PROVISION"
],
"credentialDetails": {}
}
279 changes: 279 additions & 0 deletions pkg/connector/clusterrole.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,21 @@
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"

v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
"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"
Expand Down Expand Up @@ -88,11 +92,11 @@
func clusterRoleResource(clusterRole *rbacv1.ClusterRole) (*v2.Resource, error) {
// Prepare profile with standard metadata
profile := map[string]interface{}{
"name": clusterRole.Name,

Check failure on line 95 in pkg/connector/clusterrole.go

View workflow job for this annotation

GitHub Actions / go-lint

string `name` has 8 occurrences, make it a constant (goconst)
"uid": string(clusterRole.UID),

Check failure on line 96 in pkg/connector/clusterrole.go

View workflow job for this annotation

GitHub Actions / go-lint

string `uid` has 6 occurrences, make it a constant (goconst)
"creationTimestamp": clusterRole.CreationTimestamp.String(),

Check failure on line 97 in pkg/connector/clusterrole.go

View workflow job for this annotation

GitHub Actions / go-lint

string `creationTimestamp` has 5 occurrences, make it a constant (goconst)
"labels": StringMapToAnyMap(clusterRole.Labels),

Check failure on line 98 in pkg/connector/clusterrole.go

View workflow job for this annotation

GitHub Actions / go-lint

string `labels` has 4 occurrences, make it a constant (goconst)
"annotations": StringMapToAnyMap(clusterRole.Annotations),

Check failure on line 99 in pkg/connector/clusterrole.go

View workflow job for this annotation

GitHub Actions / go-lint

string `annotations` has 4 occurrences, make it a constant (goconst)
}

// Add aggregation rule if present
Expand Down Expand Up @@ -252,6 +256,281 @@
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
}
Comment on lines +269 to +275

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: strings.Trim(s, "-") runs before the length truncation, so if the name exceeds 253 characters, s[:maxK8sNameLen] can produce a trailing -, which is invalid per K8s RFC 1123 subdomain naming. Move the trim after (or add a second trim after) the truncation:

Suggested change
s = k8sNameRegexp.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
if len(s) > maxK8sNameLen {
s = s[:maxK8sNameLen]
}
return s
}
s = strings.ToLower(s)
s = k8sNameRegexp.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
if len(s) > maxK8sNameLen {
s = s[:maxK8sNameLen]
s = strings.TrimRight(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)
Comment on lines +332 to +335

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: When AlreadyExists is detected, the code returns a grant but omits the GrantAlreadyExists annotation. The same applies to the namespace-scoped branch below. Returning the annotation informs the platform that this was an idempotent no-op rather than a new creation:

if k8serrors.IsAlreadyExists(err) {
    g := sdkGrant.NewGrant(ent.GetResource(), slug, principal.GetId())
    return []*v2.Grant{g}, annotations.New(&v2.GrantAlreadyExists{}), nil
}

Similarly, the Revoke paths that return nil, nil when the binding is not found should return annotations.New(&v2.GrantAlreadyRevoked{}), nil.

binding := &rbacv1.ClusterRoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Labels: labels,
},
RoleRef: rbacv1.RoleRef{
APIGroup: RBACAPIGroup,
Kind: RoleRefKindClusterRole,
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: RoleRefKindClusterRole,
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 != RoleRefKindClusterRole || 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
}
}

Comment on lines +468 to +471

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Unlike revokeSubjectFromClusterRoleBindings which paginates with Limit and Continue, this List call fetches all RoleBindings in one request without pagination. While namespace-scoped counts are typically small, adding pagination here would be consistent and more defensive against large namespaces.

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 != RoleRefKindClusterRole || 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{
Expand Down
8 changes: 5 additions & 3 deletions pkg/connector/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@
ResourceTypeRoleBinding = "rolebinding"
SubjectTypeGroup = "Group"
SubjectTypeUser = "User"
RoleRefKindClusterRole = "ClusterRole"
RoleRefKindRole = "Role"
)

// Resource type definitions.
var (
ResourceTypeNamespace = &v2.ResourceType{Id: "namespace", DisplayName: "Namespace"}

Check failure on line 34 in pkg/connector/connector.go

View workflow job for this annotation

GitHub Actions / go-lint

string `namespace` has 4 occurrences, make it a constant (goconst)
ResourceTypeServiceAccount = &v2.ResourceType{Id: "service_account", DisplayName: "Service Account", Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_USER}}
ResourceTypeRole = &v2.ResourceType{Id: "role", DisplayName: "Role", Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_ROLE}}

Check failure on line 36 in pkg/connector/connector.go

View workflow job for this annotation

GitHub Actions / go-lint

string `Role` has 3 occurrences, but such constant `RoleRefKindRole` already exists (goconst)
ResourceTypeClusterRole = &v2.ResourceType{Id: "cluster_role", DisplayName: "Cluster Role", Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_ROLE}}
ResourceTypeSecret = &v2.ResourceType{Id: "secret", DisplayName: "Secret", Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_SECRET}}
ResourceTypeConfigMap = &v2.ResourceType{Id: "configmap", DisplayName: "Config Map"}
Expand All @@ -43,8 +45,8 @@
ResourceTypeKubeUser = &v2.ResourceType{Id: "kube_user", DisplayName: "Kubernetes User", Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_USER}}
ResourceTypeKubeGroup = &v2.ResourceType{Id: "kube_group", DisplayName: "Kubernetes Group", Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_GROUP}}
ResourceTypeBinding = &v2.ResourceType{Id: "binding", DisplayName: "Binding", Description: "Internal type for processing RBAC bindings"}
ResourceTypeUser = &v2.ResourceType{Id: "user", DisplayName: "User", Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_USER}}

Check failure on line 48 in pkg/connector/connector.go

View workflow job for this annotation

GitHub Actions / go-lint

string `User` has 4 occurrences, but such constant `SubjectTypeUser` already exists (goconst)
ResourceTypeGroup = &v2.ResourceType{Id: "group", DisplayName: "Group", Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_GROUP}}

Check failure on line 49 in pkg/connector/connector.go

View workflow job for this annotation

GitHub Actions / go-lint

string `Group` has 3 occurrences, but such constant `SubjectTypeGroup` already exists (goconst)
)

// Configuration options.
Expand Down Expand Up @@ -348,7 +350,7 @@

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)
}
}
Expand All @@ -369,14 +371,14 @@

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)
}
}
Expand Down
Loading