diff --git a/README.md b/README.md index fdaa90bb..2a882113 100644 --- a/README.md +++ b/README.md @@ -298,6 +298,36 @@ The following authentication methods are **not supported** for group membership RBAC bindings to groups are fully visible. If `ClusterRole:admin` is bound to group `developers`, that grant is synced. However, **the list of users in `developers` is only complete if those users authenticate via x509 client certificates stored as kubeconfig Secrets in the cluster**. Users authenticating via OIDC or webhook will appear as grant targets on Roles and ClusterRoles (if they have direct bindings) but not as members of their groups. +To resolve those memberships, attach an identity source instead — see below. + +## External Identity Matching + +Because a cluster authorizes identities it does not store, a `User` or `Group` subject in an RBAC binding is only a string some authenticator asserted: an OIDC claim, an x509 `CN=`/`O=` field, a Microsoft Entra object ID, an AWS IAM ARN. The directory that knows who that principal is belongs to a different ConductorOne app. + +Attaching that app as an **identity source** lets ConductorOne resolve the two. For every `User` and `Group` subject the connector emits an additional *carrier* grant annotated with what it knows about the subject, and the Baton SDK rewrites each carrier onto the matching principal from the identity source. A matched group additionally expands through the directory's own membership entitlement, so `Group developers → ClusterRole admin` becomes visible per person — the membership Kubernetes itself cannot supply. + +Two match strategies ride on every carrier and both are attempted, since which one fits is a property of the directory rather than of Kubernetes: + +- the external resource's **ID**, for directories whose IDs Kubernetes uses verbatim (Entra group object IDs, IAM role ARNs) +- a **profile field**, for the OIDC case where the subject is a human-readable name or address + +The defaults suit an OIDC-federated cluster and need no configuration. Override them when the cluster federates against something else: + +| Flag | Default | Set it to | +| --- | --- | --- | +| `--external-user-match-key` | `email` — also matches a user's email addresses | `userPrincipalName` for Microsoft Entra | +| `--external-group-match-key` | `display_name` — where Entra publishes a group's name | `sAMAccountName` to match Active Directory directly rather than through Entra | + +A matched group expands through the identity source's own membership entitlement. That is not configurable: it has to be an entitlement the source actually emitted, and the connector targets Entra's (`members`). Federating against a directory that names it `member` instead — Okta, Google Workspace, Active Directory — requires a code change, not a flag. + +Locally, point the connector at another connector's `.c1z` to do the same resolution offline: + +``` +baton-kubernetes --external-resource-c1z ./entra.c1z +``` + +**Group access stays reviewable with no identity source at all.** The carrier is emitted alongside the ordinary `kube_user` / `kube_group` grant, never instead of it — a carrier is consumed and discarded during matching, so cluster-level evidence would be lost if it were the only record. With no identity source configured, or when a group matches nothing in the directory, the group remains a first-class, attestable grantee exactly as before. + # Contributing, Support and Issues We started Baton because we were tired of taking screenshots and manually @@ -386,9 +416,11 @@ Flags: --cluster string The name of the kubeconfig cluster to use ($BATON_CLUSTER) --context string The name of the kubeconfig context to use ($BATON_CONTEXT) --disable-compression If true, opt-out of response compression for all requests to the server ($BATON_DISABLE_COMPRESSION) + --external-group-match-key string Profile field on the external identity source to match a Kubernetes Group subject against. Defaults to "display_name", which is where Microsoft Entra publishes a group's name. Group subjects are additionally always matched against the external group's ID, which is what AKS clusters use as the group name (an Entra object GUID). ($BATON_EXTERNAL_GROUP_MATCH_KEY) --external-resource-c1z string The path to the c1z file to sync external baton resources with ($BATON_EXTERNAL_RESOURCE_C1Z) --external-resource-entitlement-id-filter string The entitlement that external users, groups must have access to sync external baton resources ($BATON_EXTERNAL_RESOURCE_ENTITLEMENT_ID_FILTER) --external-resource-traits strings Resource type traits (e.g. "user", "group", "app") to sync and match from the external resource c1z. When unset the matcher falls back to user and group; passing this flag replaces the full set rather than adding to it. ($BATON_EXTERNAL_RESOURCE_TRAITS) + --external-user-match-key string Profile field on the external identity source to match a Kubernetes User subject against. Defaults to "email", which also matches a user's email addresses. Use "userPrincipalName" for clusters federated against Microsoft Entra. ($BATON_EXTERNAL_USER_MATCH_KEY) -f, --file string The path to the c1z file to sync with ($BATON_FILE) (default "sync.c1z") --health-check Enable the HTTP health check endpoint ($BATON_HEALTH_CHECK) --health-check-port int Port for the HTTP health check endpoint ($BATON_HEALTH_CHECK_PORT) (default 8081) diff --git a/config_schema.json b/config_schema.json index a74542e2..27697d3a 100644 --- a/config_schema.json +++ b/config_schema.json @@ -210,6 +210,22 @@ "displayName": "Include control-plane permissions on objects", "description": "If true, also report permissions held by system: cluster roles on individual objects. These are the Kubernetes control plane's own controllers and they reach every object, so they are excluded by default. What they permit is reported on API resources regardless.", "boolField": {} + }, + { + "name": "external-user-match-key", + "displayName": "External user match key", + "description": "Profile field on the identity source to match a Kubernetes User subject against. Defaults to \"email\".", + "stringField": { + "rules": {} + } + }, + { + "name": "external-group-match-key", + "displayName": "External group match key", + "description": "Profile field on the identity source to match a Kubernetes Group subject against. Defaults to \"display_name\". Group subjects are also always matched against the identity source's resource ID.", + "stringField": { + "rules": {} + } } ], "constraints": [ @@ -255,5 +271,6 @@ "client-key" ] } - ] + ], + "supportsExternalResources": true } \ No newline at end of file diff --git a/docs/connector.mdx b/docs/connector.mdx index b5c327e6..8735a7b1 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -191,6 +191,32 @@ The following authentication methods are **not supported** for group membership **What this means for access reviews:** RBAC bindings to groups are fully visible. If `ClusterRole:admin` is bound to group `developers`, that grant is synced. However, the list of users in `developers` is only complete if those users authenticate via x509 client certificates whose kubeconfigs are stored as Secrets in the cluster. Users authenticating via OIDC or webhook will appear as grant targets on Roles and ClusterRoles if they have direct bindings, but not as members of their groups. +To resolve those memberships from the directory that does hold them, attach an identity source. + +## Matching cluster identities to a directory + +A cluster authorizes identities it does not store, so a `User` or `Group` subject in an RBAC binding is only a string the authenticator asserted: an OIDC claim, an x509 `CN=`/`O=` field, a Microsoft Entra object ID, an AWS IAM ARN. The directory that knows who that principal is belongs to a different app in C1. + +Selecting that app as this connector's **identity source** lets C1 resolve the two. Each `User` and `Group` subject the connector reports is matched against the identity source's principals, and a matched group is expanded through the directory's own membership, so `Group developers → ClusterRole admin` becomes reviewable person by person — the membership Kubernetes itself cannot supply. + +Matching is attempted two ways at once, because which one fits depends on the directory rather than on Kubernetes: + +- against the external resource's **ID**, for directories whose identifiers the cluster uses verbatim, such as Microsoft Entra group object IDs or AWS IAM role ARNs +- against a **profile field**, for the OIDC case where the subject is a human-readable name or email address + +The defaults suit a cluster federated through an OIDC issuer and need no configuration. Change them when the cluster federates against something else: + +| Setting | Default | Change it to | +| :--- | :--- | :--- | +| External user match key | `email`, which also matches a user's email addresses | `userPrincipalName` for Microsoft Entra | +| External group match key | `display_name`, where Microsoft Entra publishes a group's name | `sAMAccountName` to match Active Directory directly rather than through Entra | + +Resolving a matched group to the accounts inside it needs no setting: the connector targets the membership entitlement Microsoft Entra publishes, which is the identity source it is built to federate against. + + +**Group access stays reviewable whether or not an identity source is attached.** Matching adds a resolved view of each group; it never replaces the group itself. A group that matches nothing in the directory — or a connector with no identity source selected at all — still reports the group as a first-class grantee you can attest in a campaign. + + ## Understanding how the connector selects a cluster The connector resolves its target cluster in this order: diff --git a/pkg/config/conf.gen.go b/pkg/config/conf.gen.go index 037136b5..1dd4efe8 100644 --- a/pkg/config/conf.gen.go +++ b/pkg/config/conf.gen.go @@ -22,6 +22,8 @@ type Kubernetes struct { DisableCompression bool `mapstructure:"disable-compression"` UseRoleAssignments bool `mapstructure:"use-role-assignments"` IncludeSystemObjectPermissions bool `mapstructure:"include-system-object-permissions"` + ExternalUserMatchKey string `mapstructure:"external-user-match-key"` + ExternalGroupMatchKey string `mapstructure:"external-group-match-key"` } func (c *Kubernetes) findFieldByTag(tagValue string) (any, bool) { diff --git a/pkg/config/config.go b/pkg/config/config.go index 4110f3cc..c990d79a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -33,6 +33,10 @@ const ( // FlagIncludeSystemObjectPermissions is this connector's own flag, not one // of cli-runtime's. FlagIncludeSystemObjectPermissions = "include-system-object-permissions" + + // External identity matching flags, also this connector's own. + FlagExternalUserMatchKey = "external-user-match-key" + FlagExternalGroupMatchKey = "external-group-match-key" ) var ( @@ -148,6 +152,25 @@ var ( " so they are excluded by default. What they permit is reported on API resources regardless."), field.WithDefaultValue(false), ) + // These tune identity matching rather than enable it; empty means use the + // default in pkg/connector/external_match.go. + externalUserMatchKeyField = field.StringField( + FlagExternalUserMatchKey, + field.WithDisplayName("External user match key"), + field.WithDescription( + "Profile field on the identity source to match a Kubernetes User subject against."+ + " Defaults to \"email\"."), + field.WithRequired(false), + ) + externalGroupMatchKeyField = field.StringField( + FlagExternalGroupMatchKey, + field.WithDisplayName("External group match key"), + field.WithDescription( + "Profile field on the identity source to match a Kubernetes Group subject against."+ + " Defaults to \"display_name\"."+ + " Group subjects are also always matched against the identity source's resource ID."), + field.WithRequired(false), + ) ) // ConfigurationFields lists all connector-specific schema fields. @@ -170,6 +193,8 @@ var ConfigurationFields = []field.SchemaField{ disableCompressionField, useRoleAssignmentsField, includeSystemObjectPermissionsField, + externalUserMatchKeyField, + externalGroupMatchKeyField, } // ConfigRelations lists mutual-exclusivity and required-together constraints. @@ -194,7 +219,12 @@ var ConfigRelations = []field.SchemaFieldRelationship{ } // Configuration is the full connector schema passed to DefineConfiguration. +// +// SupportsExternalResources is what makes C1 offer the identity-source picker +// for this app; the --external-resource-* flags are registered by the SDK +// regardless. See pkg/connector/external_match.go. var Configuration = field.NewConfiguration( ConfigurationFields, field.WithConstraints(ConfigRelations...), + field.WithSupportsExternalResources(true), ) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 022e66f7..767a5702 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -27,6 +27,8 @@ func TestConfigurationFields(t *testing.T) { config.FlagDisableCompression, config.FlagUseRoleAssignments, config.FlagIncludeSystemObjectPermissions, + config.FlagExternalUserMatchKey, + config.FlagExternalGroupMatchKey, } got := make(map[string]bool) diff --git a/pkg/connector/clusterrole.go b/pkg/connector/clusterrole.go index 65a6ed17..c5881230 100644 --- a/pkg/connector/clusterrole.go +++ b/pkg/connector/clusterrole.go @@ -28,6 +28,8 @@ type clusterRoleBuilder struct { // because the role_assignment type is expressing the same access. The two // models are mutually exclusive; emitting both would double-count it. useRoleAssignments bool + // matchCfg tunes external-match carriers. See external_match.go. + matchCfg ExternalMatchConfig // Cached namespaces cachedNamespaces []string nsMutex sync.Mutex @@ -224,12 +226,12 @@ func (c *clusterRoleBuilder) Grants(ctx context.Context, resource *v2.Resource, for _, binding := range matchingClusterBindings { // Process each subject in the binding for _, subject := range binding.Subjects { - subjectGrant, err := GrantRoleToSubject(subject, resource, clusterScopedMember) + subjectGrants, err := GrantRoleToSubject(ctx, subject, resource, clusterScopedMember, c.matchCfg) if err != nil { l.Debug("subject type not supported", zap.String("subject kind", subject.Kind)) continue } - rv = append(rv, subjectGrant) + rv = append(rv, subjectGrants...) } } @@ -244,12 +246,12 @@ func (c *clusterRoleBuilder) Grants(ctx context.Context, resource *v2.Resource, subject.Namespace = binding.Namespace } entName := fmt.Sprintf("%s:%s", namespace, "member") - subjectGrant, err := GrantRoleToSubject(subject, resource, entName) + subjectGrants, err := GrantRoleToSubject(ctx, subject, resource, entName, c.matchCfg) if err != nil { l.Debug("subject kind not supported", zap.String("subject kind", subject.Kind)) continue } - rv = append(rv, subjectGrant) + rv = append(rv, subjectGrants...) } } @@ -293,10 +295,16 @@ func (c *clusterRoleBuilder) cacheNamespaces(ctx context.Context) error { } // newClusterRoleBuilder creates a new cluster role builder. -func newClusterRoleBuilder(client kubernetes.Interface, bindingProvider ClusterRoleBindingProvider, useRoleAssignments bool) *clusterRoleBuilder { +func newClusterRoleBuilder( + client kubernetes.Interface, + bindingProvider ClusterRoleBindingProvider, + useRoleAssignments bool, + matchCfg ExternalMatchConfig, +) *clusterRoleBuilder { return &clusterRoleBuilder{ client: client, bindingProvider: bindingProvider, useRoleAssignments: useRoleAssignments, + matchCfg: matchCfg, } } diff --git a/pkg/connector/config_wiring_test.go b/pkg/connector/config_wiring_test.go index 7d00c567..db7b9cec 100644 --- a/pkg/connector/config_wiring_test.go +++ b/pkg/connector/config_wiring_test.go @@ -108,7 +108,7 @@ func TestRoleAssignmentsEmitNothingWhenDisabled(t *testing.T) { crbFor("view-everywhere", "view", userSubject("alice")), ) - assignments, _, err := newRoleAssignmentBuilder(client, &Kubernetes{client: client}, false). + assignments, _, err := newRoleAssignmentBuilder(client, &Kubernetes{client: client}, false, ExternalMatchConfig{}). List(ctx, nil, rs.SyncOpAttrs{SyncID: "sync-1"}) require.NoError(t, err) assert.Empty(t, assignments, "role assignments must not be emitted alongside the flat model") @@ -199,3 +199,44 @@ func TestDefaultSyncFilterIsRegistered(t *testing.T) { assert.Less(t, len(defaults), len(AllResourceTypeIDs), "the default must still be narrower than everything, or it is not a default") } + +// TestExternalMatchConfigReachesBuilders covers the flag -> config -> builder +// hop. A key that decodes but never reaches a builder leaves the connector +// silently matching on defaults, which looks like a directory that just does not +// match rather than a wiring bug. +func TestExternalMatchConfigReachesBuilders(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("KUBECONFIG", "") + + builder, _, err := NewFromConfig(context.Background(), &pkgconfig.Kubernetes{ + Server: "https://127.0.0.1:65535", + Token: "fake-token", + InsecureSkipTlsVerify: true, + ExternalUserMatchKey: "userPrincipalName", + ExternalGroupMatchKey: "displayName", + }, nil) + require.NoError(t, err) + + want := ExternalMatchConfig{ + UserMatchKey: "userPrincipalName", + GroupMatchKey: "displayName", + } + + // Check every builder that emits subject grants; one missed call site would + // let the flat and sparse models disagree. + var checked int + for _, s := range builder.ResourceSyncers(context.Background()) { + switch b := s.(type) { + case *roleBuilder: + assert.Equal(t, want, b.matchCfg, "role builder") + checked++ + case *clusterRoleBuilder: + assert.Equal(t, want, b.matchCfg, "cluster role builder") + checked++ + case *roleAssignmentBuilder: + assert.Equal(t, want, b.matchCfg, "role assignment builder") + checked++ + } + } + assert.Equal(t, 3, checked, "every subject-granting builder must receive the match config") +} diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 80c6c4e9..3b8d2cac 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -51,6 +51,9 @@ type ConnectorOpts struct { // ClusterName labels the cluster resource. Empty falls back to the API // server host. ClusterName string + // ExternalMatch tunes the carrier grants emitted for User and Group subjects. + // Zero value is usable. See external_match.go. + ExternalMatch ExternalMatchConfig } // ConnectorOption is a function that configures the connector options. @@ -124,6 +127,16 @@ func WithClusterName(name string) ConnectorOption { } } +// WithExternalMatch sets the profile keys external-match carriers claim to match +// on. Unset fields take the defaults in external_match.go; downstream connectors +// that know their identity source should pass its keys. +func WithExternalMatch(cfg ExternalMatchConfig) ConnectorOption { + return func(opts *ConnectorOpts) error { + opts.ExternalMatch = cfg + return nil + } +} + // Kubernetes connector struct. type Kubernetes struct { client kubernetes.Interface @@ -293,6 +306,10 @@ func NewFromConfig( WithRoleAssignments(cfg.UseRoleAssignments), WithSystemObjectPermissions(cfg.IncludeSystemObjectPermissions), WithClusterName(clusterNameFromConfig(opt, cfg)), + WithExternalMatch(ExternalMatchConfig{ + UserMatchKey: cfg.ExternalUserMatchKey, + GroupMatchKey: cfg.ExternalGroupMatchKey, + }), ) if err != nil { return nil, nil, err @@ -414,16 +431,16 @@ func (k *Kubernetes) ResourceSyncers(ctx context.Context) []connectorbuilder.Res return newServiceAccountBuilder(k.client, k.permissions()) }, ResourceTypeRole.Id: func(i *kubernetes.Interface, k *Kubernetes) connectorbuilder.ResourceSyncerV2 { - return newRoleBuilder(k.client, k) + return newRoleBuilder(k.client, k, k.opts.ExternalMatch) }, ResourceTypeClusterRole.Id: func(i *kubernetes.Interface, k *Kubernetes) connectorbuilder.ResourceSyncerV2 { - return newClusterRoleBuilder(k.client, k, k.opts.UseRoleAssignments) + return newClusterRoleBuilder(k.client, k, k.opts.UseRoleAssignments, k.opts.ExternalMatch) }, ResourceTypeCluster.Id: func(i *kubernetes.Interface, k *Kubernetes) connectorbuilder.ResourceSyncerV2 { return newClusterBuilder(k.opts.ClusterName, k.config.Host) }, ResourceTypeRoleAssignment.Id: func(i *kubernetes.Interface, k *Kubernetes) connectorbuilder.ResourceSyncerV2 { - return newRoleAssignmentBuilder(k.client, k, k.opts.UseRoleAssignments) + return newRoleAssignmentBuilder(k.client, k, k.opts.UseRoleAssignments, k.opts.ExternalMatch) }, ResourceTypeAPIResource.Id: func(i *kubernetes.Interface, k *Kubernetes) connectorbuilder.ResourceSyncerV2 { return newAPIResourceBuilder(k) @@ -560,10 +577,10 @@ func (d *defaultCapabilitiesBuilder) ResourceSyncers(_ context.Context) []connec return []connectorbuilder.ResourceSyncerV2{ newNamespaceBuilder(nil, nil), newServiceAccountBuilder(nil, nil), - newRoleBuilder(nil, nil), - newClusterRoleBuilder(nil, nil, false), + newRoleBuilder(nil, nil, ExternalMatchConfig{}), + newClusterRoleBuilder(nil, nil, false, ExternalMatchConfig{}), newClusterBuilder("", ""), - newRoleAssignmentBuilder(nil, nil, true), + newRoleAssignmentBuilder(nil, nil, true, ExternalMatchConfig{}), newAPIResourceBuilder(nil), newKubeUserBuilder(nil), newKubeGroupBuilder(nil), diff --git a/pkg/connector/external_match.go b/pkg/connector/external_match.go new file mode 100644 index 00000000..defe83bc --- /dev/null +++ b/pkg/connector/external_match.go @@ -0,0 +1,136 @@ +package connector + +import ( + "fmt" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/bid" + "github.com/conductorone/baton-sdk/pkg/types/entitlement" + "github.com/conductorone/baton-sdk/pkg/types/grant" +) + +// External identity matching ("baton-id"). +// +// Kubernetes authorizes identities it does not store: a User or Group subject is +// just a string an authenticator asserted, and the directory behind it is a +// different C1 app. A grant carrying a match annotation is a *carrier* — the SDK +// rewrites it onto the matching external principal, then deletes it. +// +// Deletion is unconditional, matched or not, so carriers are emitted *alongside* +// the durable kube_user / kube_group grant, never instead of it. The durable +// grant is unannotated and always survives, which is what keeps group access +// attestable when a group matches nothing. Carriers ride the placeholder user and +// group resource types, which nothing syncs; the SDK exempts match-annotated +// grants from the usual unsynced-principal drop. +// +// Each carrier declares both strategies, since which one fits depends on the +// directory: MatchID for subjects that are already the external resource's ID, +// and a profile-key match for name- or address-shaped subjects. The SDK tries +// both, and honors only the first annotation of each type. +// +// ServiceAccounts are never carriers — they are real cluster objects with no +// directory counterpart. + +// Default profile keys for the key/value match strategy. Both are overridable +// per deployment; the SDK also resolves "email" against a user's trait email +// addresses, not just a profile field of that name. +const ( + DefaultExternalUserMatchKey = "email" + DefaultExternalGroupMatchKey = "display_name" +) + +// externalGroupMemberEntitlement is the entitlement a matched group expands +// through. It must be the last segment of an entitlement ID the identity source +// really emitted — the SDK looks up NewEntitlementID(matchedPrincipal, slug) as +// an exact string and drops the expansion on NotFound. Read the source's +// entitlement *ID*, not its Slug field; they can disagree. EntitlementIds is a +// list, so a second slug can be added if a source needs a different one. +const externalGroupMemberEntitlement = "members" + +// makeCarrierBID is a seam. bid.MakeBid cannot fail for the inputs built here — +// the carrier resource always has both a type and a non-empty id — so the +// error branch below is only reachable from tests, which is exactly why it +// needs one: the branch must keep the durable grant, and nothing else proves +// it does. +var makeCarrierBID = bid.MakeBid + +// ExternalMatchConfig names the profile keys a Kubernetes subject is matched on. +// The zero value is usable and takes the defaults; nothing here turns matching +// on or off. +type ExternalMatchConfig struct { + UserMatchKey string + GroupMatchKey string +} + +// withDefaults fills unset fields so a partial or zero struct still works. +func (c ExternalMatchConfig) withDefaults() ExternalMatchConfig { + if c.UserMatchKey == "" { + c.UserMatchKey = DefaultExternalUserMatchKey + } + if c.GroupMatchKey == "" { + c.GroupMatchKey = DefaultExternalGroupMatchKey + } + return c +} + +// userCarrierGrant returns the carrier for a User subject, or nil if unnamed. +func (c ExternalMatchConfig) userCarrierGrant(resource *v2.Resource, entName string, subjectName string) *v2.Grant { + if subjectName == "" { + return nil + } + cfg := c.withDefaults() + carrier := GenerateResourceForGrant(subjectName, ResourceTypeUser.Id) + return grant.NewGrant( + resource, + entName, + carrier, + grant.WithAnnotation( + &v2.ExternalResourceMatchID{Id: subjectName}, + &v2.ExternalResourceMatch{ + Key: cfg.UserMatchKey, + Value: subjectName, + ResourceType: v2.ResourceType_TRAIT_USER, + }, + ), + ) +} + +// groupCarrierGrant returns the carrier for a Group subject, or nil if unnamed. +// +// GrantExpandable is what resolves a matched group to its members. Its +// entitlement must name the *carrier* resource: the SDK finds the expansion by +// the grant principal's bid, then re-mints the slug against whatever matched. +func (c ExternalMatchConfig) groupCarrierGrant(resource *v2.Resource, entName string, subjectName string) (*v2.Grant, error) { + if subjectName == "" { + return nil, nil + } + cfg := c.withDefaults() + carrier := GenerateResourceForGrant(subjectName, ResourceTypeGroup.Id) + + memberBID, err := makeCarrierBID(entitlement.NewAssignmentEntitlement(carrier, externalGroupMemberEntitlement)) + if err != nil { + return nil, fmt.Errorf("baton-kubernetes: failed to build %q entitlement bid for group %q: %w", + externalGroupMemberEntitlement, subjectName, err) + } + + return grant.NewGrant( + resource, + entName, + carrier, + grant.WithAnnotation( + &v2.ExternalResourceMatchID{Id: subjectName}, + &v2.ExternalResourceMatch{ + Key: cfg.GroupMatchKey, + Value: subjectName, + ResourceType: v2.ResourceType_TRAIT_GROUP, + }, + // Shallow: the source's own connector already syncs nested + // membership. ResourceTypeIds is left unset (unfiltered) because + // the source's resource type IDs are not knowable from here. + &v2.GrantExpandable{ + EntitlementIds: []string{memberBID}, + Shallow: true, + }, + ), + ), nil +} diff --git a/pkg/connector/external_match_test.go b/pkg/connector/external_match_test.go new file mode 100644 index 00000000..1ca9425a --- /dev/null +++ b/pkg/connector/external_match_test.go @@ -0,0 +1,329 @@ +package connector + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + rbacv1 "k8s.io/api/rbac/v1" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/bid" +) + +// testRoleResource is the entitlement-side resource the grants under test point at. +var testRoleResource = &v2.Resource{ + Id: &v2.ResourceId{ResourceType: ResourceTypeRole.Id, Resource: "team-a/pod-reader"}, + DisplayName: "pod-reader", +} + +// isCarrier reports whether a grant is an external-match carrier. +func isCarrier(g *v2.Grant) bool { + annos := annotations.Annotations(g.GetAnnotations()) + return annos.ContainsAny( + &v2.ExternalResourceMatchAll{}, + &v2.ExternalResourceMatch{}, + &v2.ExternalResourceMatchID{}, + ) +} + +// durableGrants returns the non-carrier grants, so tests about binding scope, +// dedup or cache lifetime measure access rather than carriers. +func durableGrants(grants []*v2.Grant) []*v2.Grant { + out := make([]*v2.Grant, 0, len(grants)) + for _, g := range grants { + if !isCarrier(g) { + out = append(out, g) + } + } + return out +} + +// carrierGrants is durableGrants' complement. +func carrierGrants(grants []*v2.Grant) []*v2.Grant { + out := make([]*v2.Grant, 0, len(grants)) + for _, g := range grants { + if isCarrier(g) { + out = append(out, g) + } + } + return out +} + +// pickMatchID returns the grant's ExternalResourceMatchID, or fails. +func pickMatchID(t *testing.T, g *v2.Grant) *v2.ExternalResourceMatchID { + t.Helper() + annos := annotations.Annotations(g.GetAnnotations()) + got := &v2.ExternalResourceMatchID{} + ok, err := annos.Pick(got) + require.NoError(t, err) + require.True(t, ok, "grant carries no ExternalResourceMatchID") + return got +} + +// pickMatch returns the grant's key/value ExternalResourceMatch, or fails. +func pickMatch(t *testing.T, g *v2.Grant) *v2.ExternalResourceMatch { + t.Helper() + annos := annotations.Annotations(g.GetAnnotations()) + got := &v2.ExternalResourceMatch{} + ok, err := annos.Pick(got) + require.NoError(t, err) + require.True(t, ok, "grant carries no ExternalResourceMatch") + return got +} + +// pickExpandable returns the GrantExpandable on a grant, or nil. +func pickExpandable(t *testing.T, g *v2.Grant) *v2.GrantExpandable { + t.Helper() + annos := annotations.Annotations(g.GetAnnotations()) + got := &v2.GrantExpandable{} + ok, err := annos.Pick(got) + require.NoError(t, err) + if !ok { + return nil + } + return got +} + +// TestUserSubjectEmitsDurableAndCarrier verifies a User subject yields both +// grants, and that the durable one carries no annotation — otherwise the SDK +// would delete it along with the carrier. +func TestUserSubjectEmitsDurableAndCarrier(t *testing.T) { + subject := rbacv1.Subject{Kind: SubjectKindUser, Name: "alice@example.com", APIGroup: RBACAPIGroup} + + grants, err := GrantRoleToSubject(context.Background(), subject, testRoleResource, "member", ExternalMatchConfig{}) + require.NoError(t, err) + require.Len(t, grants, 2) + + durable := durableGrants(grants) + require.Len(t, durable, 1) + assert.Equal(t, ResourceTypeKubeUser.Id, durable[0].GetPrincipal().GetId().GetResourceType()) + assert.Equal(t, "alice@example.com", durable[0].GetPrincipal().GetId().GetResource()) + assert.Empty(t, durable[0].GetAnnotations(), "the durable grant must not be deletable as a carrier") + + carriers := carrierGrants(grants) + require.Len(t, carriers, 1) + carrier := carriers[0] + assert.Equal(t, ResourceTypeUser.Id, carrier.GetPrincipal().GetId().GetResourceType()) + assert.Equal(t, "alice@example.com", carrier.GetPrincipal().GetId().GetResource()) + + assert.Equal(t, "alice@example.com", pickMatchID(t, carrier).GetId()) + + match := pickMatch(t, carrier) + assert.Equal(t, DefaultExternalUserMatchKey, match.GetKey()) + assert.Equal(t, "alice@example.com", match.GetValue()) + assert.Equal(t, v2.ResourceType_TRAIT_USER, match.GetResourceType()) + + assert.Nil(t, pickExpandable(t, carrier), + "a user resolves to one account; there is nothing to expand through") +} + +// TestGroupSubjectEmitsDurableAndCarrier verifies the same for a Group, plus the +// expansion annotation. +func TestGroupSubjectEmitsDurableAndCarrier(t *testing.T) { + subject := rbacv1.Subject{Kind: SubjectKindGroup, Name: "SCRUM-HPC-ADMIN", APIGroup: RBACAPIGroup} + + grants, err := GrantRoleToSubject(context.Background(), subject, testRoleResource, "member", ExternalMatchConfig{}) + require.NoError(t, err) + require.Len(t, grants, 2) + + durable := durableGrants(grants) + require.Len(t, durable, 1) + assert.Equal(t, ResourceTypeKubeGroup.Id, durable[0].GetPrincipal().GetId().GetResourceType()) + assert.Equal(t, "SCRUM-HPC-ADMIN", durable[0].GetPrincipal().GetId().GetResource()) + assert.Empty(t, durable[0].GetAnnotations(), + "group access must stay attestable when the group matches nothing") + + carriers := carrierGrants(grants) + require.Len(t, carriers, 1) + carrier := carriers[0] + assert.Equal(t, ResourceTypeGroup.Id, carrier.GetPrincipal().GetId().GetResourceType()) + + assert.Equal(t, "SCRUM-HPC-ADMIN", pickMatchID(t, carrier).GetId()) + + match := pickMatch(t, carrier) + assert.Equal(t, DefaultExternalGroupMatchKey, match.GetKey()) + assert.Equal(t, "SCRUM-HPC-ADMIN", match.GetValue()) + assert.Equal(t, v2.ResourceType_TRAIT_GROUP, match.GetResourceType()) + + expandable := pickExpandable(t, carrier) + require.NotNil(t, expandable) + assert.True(t, expandable.GetShallow(), + "the directory's own connector resolves nesting; one level is enough") + require.NotEmpty(t, expandable.GetEntitlementIds()) +} + +// TestGroupCarrierExpandableTargetsItsOwnPrincipal pins what the SDK's remap +// needs: it finds the expansion by the grant principal's bid. An expandable +// naming any other resource is silently ignored and never expands. +func TestGroupCarrierExpandableTargetsItsOwnPrincipal(t *testing.T) { + subject := rbacv1.Subject{Kind: SubjectKindGroup, Name: "prod-developer", APIGroup: RBACAPIGroup} + + grants, err := GrantRoleToSubject(context.Background(), subject, testRoleResource, "member", ExternalMatchConfig{}) + require.NoError(t, err) + carriers := carrierGrants(grants) + require.Len(t, carriers, 1) + carrier := carriers[0] + + expandable := pickExpandable(t, carrier) + require.NotNil(t, expandable) + require.NotEmpty(t, expandable.GetEntitlementIds()) + + wantBID, err := bid.MakeBid(carrier.GetPrincipal()) + require.NoError(t, err) + + for _, entID := range expandable.GetEntitlementIds() { + parsed, err := bid.ParseEntitlementBid(entID) + require.NoError(t, err) + gotBID, err := bid.MakeBid(parsed.GetResource()) + require.NoError(t, err) + assert.Equal(t, wantBID, gotBID, + "every expandable entitlement must name the carrier principal, or the SDK cannot remap it") + } +} + +// TestCarrierAndDurableGrantIDsDiffer: grant identity is (principal, +// entitlement), so sharing a principal would collapse the two into one. +func TestCarrierAndDurableGrantIDsDiffer(t *testing.T) { + for _, subject := range []rbacv1.Subject{ + {Kind: SubjectKindUser, Name: "alice", APIGroup: RBACAPIGroup}, + {Kind: SubjectKindGroup, Name: "admins", APIGroup: RBACAPIGroup}, + } { + t.Run(subject.Kind, func(t *testing.T) { + grants, err := GrantRoleToSubject(context.Background(), subject, testRoleResource, "member", ExternalMatchConfig{}) + require.NoError(t, err) + require.Len(t, grants, 2) + assert.NotEqual(t, grants[0].GetId(), grants[1].GetId()) + }) + } +} + +// TestServiceAccountEmitsNoCarrier: a ServiceAccount has no directory +// counterpart, so a carrier could only match the wrong thing. +func TestServiceAccountEmitsNoCarrier(t *testing.T) { + subject := rbacv1.Subject{Kind: SubjectKindServiceAccount, Name: "argo", Namespace: "argocd"} + + grants, err := GrantRoleToSubject(context.Background(), subject, testRoleResource, "member", ExternalMatchConfig{}) + require.NoError(t, err) + require.Len(t, grants, 1) + assert.Equal(t, ResourceTypeServiceAccount.Id, grants[0].GetPrincipal().GetId().GetResourceType()) + assert.Equal(t, "argocd/argo", grants[0].GetPrincipal().GetId().GetResource()) + assert.Empty(t, carrierGrants(grants)) +} + +// TestSystemSubjectsStillSkipped verifies carriers did not widen which subjects +// the connector emits. +func TestSystemSubjectsStillSkipped(t *testing.T) { + for _, subject := range []rbacv1.Subject{ + {Kind: SubjectKindGroup, Name: "system:masters", APIGroup: RBACAPIGroup}, + {Kind: SubjectKindUser, Name: "system:kube-controller-manager", APIGroup: RBACAPIGroup}, + } { + t.Run(subject.Name, func(t *testing.T) { + grants, err := GrantRoleToSubject(context.Background(), subject, testRoleResource, "member", ExternalMatchConfig{}) + require.Error(t, err) + assert.Empty(t, grants) + }) + } +} + +// TestNonRBACAPIGroupSubjectsSkipped: an unexpected apiGroup must be rejected, +// not turned into a carrier claiming a directory match. +func TestNonRBACAPIGroupSubjectsSkipped(t *testing.T) { + subject := rbacv1.Subject{Kind: SubjectKindUser, Name: "alice", APIGroup: "example.com"} + + grants, err := GrantRoleToSubject(context.Background(), subject, testRoleResource, "member", ExternalMatchConfig{}) + require.Error(t, err) + assert.Empty(t, grants) +} + +// TestExternalMatchConfigOverrides verifies configured keys reach the annotations. +func TestExternalMatchConfigOverrides(t *testing.T) { + cfg := ExternalMatchConfig{ + UserMatchKey: "userPrincipalName", + GroupMatchKey: "displayName", + } + + userGrants, err := GrantRoleToSubject( + context.Background(), + rbacv1.Subject{Kind: SubjectKindUser, Name: "alice@corp.example", APIGroup: RBACAPIGroup}, + testRoleResource, "member", cfg) + require.NoError(t, err) + userCarrier := carrierGrants(userGrants) + require.Len(t, userCarrier, 1) + assert.Equal(t, "userPrincipalName", pickMatch(t, userCarrier[0]).GetKey()) + + groupGrants, err := GrantRoleToSubject( + context.Background(), + rbacv1.Subject{Kind: SubjectKindGroup, Name: "eng", APIGroup: RBACAPIGroup}, + testRoleResource, "member", cfg) + require.NoError(t, err) + groupCarrier := carrierGrants(groupGrants) + require.Len(t, groupCarrier, 1) + assert.Equal(t, "displayName", pickMatch(t, groupCarrier[0]).GetKey()) +} + +// TestGroupCarrierMemberEntitlementSlug pins the slug the identity source +// actually emits. The SDK looks up NewEntitlementID(matchedPrincipal, slug) as an +// exact string, and a source's entitlement ID and Slug field can disagree — +// taking the Slug at face value resolves to nothing and drops the expansion. +func TestGroupCarrierMemberEntitlementSlug(t *testing.T) { + grants, err := GrantRoleToSubject( + context.Background(), + rbacv1.Subject{Kind: SubjectKindGroup, Name: "eng", APIGroup: RBACAPIGroup}, + testRoleResource, "member", ExternalMatchConfig{}) + require.NoError(t, err) + + carriers := carrierGrants(grants) + require.Len(t, carriers, 1) + expandable := pickExpandable(t, carriers[0]) + require.NotNil(t, expandable) + + var slugs []string + for _, entID := range expandable.GetEntitlementIds() { + parsed, err := bid.ParseEntitlementBid(entID) + require.NoError(t, err) + slugs = append(slugs, parsed.GetSlug()) + } + assert.Equal(t, []string{"members"}, slugs) +} + +// TestExternalMatchConfigDefaults verifies the zero value is usable, since +// library callers may not set it. +func TestExternalMatchConfigDefaults(t *testing.T) { + got := ExternalMatchConfig{}.withDefaults() + assert.Equal(t, DefaultExternalUserMatchKey, got.UserMatchKey) + assert.Equal(t, DefaultExternalGroupMatchKey, got.GroupMatchKey) + + partial := ExternalMatchConfig{GroupMatchKey: "displayName"}.withDefaults() + assert.Equal(t, DefaultExternalUserMatchKey, partial.UserMatchKey) + assert.Equal(t, "displayName", partial.GroupMatchKey) +} + +// TestGroupCarrierFailureKeepsDurableGrant guards the asymmetry between the two +// things GrantRoleToSubject can fail at. +// +// An unsupported subject kind is a real error and callers skip the subject. A +// carrier that will not build is not: the durable kube_group grant is the +// cluster's own record that this binding exists, and it has to survive. Callers +// read any error as "unsupported subject kind" and drop the subject entirely, so +// returning one here would silently delete access data over a failed +// optimization. +func TestGroupCarrierFailureKeepsDurableGrant(t *testing.T) { + orig := makeCarrierBID + t.Cleanup(func() { makeCarrierBID = orig }) + makeCarrierBID = func(bid.BID) (string, error) { + return "", errors.New("synthetic bid failure") + } + + subject := rbacv1.Subject{Kind: SubjectKindGroup, Name: "eng", APIGroup: RBACAPIGroup} + grants, err := GrantRoleToSubject(context.Background(), subject, testRoleResource, "member", ExternalMatchConfig{}) + + require.NoError(t, err, "a carrier failure must not surface as an error: callers read it as an unsupported subject kind and skip the subject") + require.Len(t, grants, 1, "the durable grant must survive on its own") + assert.Empty(t, carrierGrants(grants), "no carrier should be emitted when it cannot be built") + assert.Equal(t, ResourceTypeKubeGroup.Id, grants[0].GetPrincipal().GetId().GetResourceType()) + assert.Equal(t, "eng", grants[0].GetPrincipal().GetId().GetResource()) +} diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go index 419a433a..af2e5099 100644 --- a/pkg/connector/helper.go +++ b/pkg/connector/helper.go @@ -1,12 +1,15 @@ package connector import ( + "context" "encoding/json" "fmt" "strings" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/types/grant" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" rbacv1 "k8s.io/api/rbac/v1" ) @@ -56,8 +59,23 @@ func GenerateResourceForGrant(rName string, rType string) *v2.Resource { } } -func GrantRoleToSubject(subject rbacv1.Subject, resource *v2.Resource, entName string) (*v2.Grant, error) { - var grantOpts []grant.GrantOption +// GrantRoleToSubject renders one RBAC binding subject as grants on entName. +// +// A ServiceAccount yields one grant. A User or Group yields two: the durable +// grant on kube_user / kube_group, plus an external-match carrier (see +// external_match.go). Returns an error for subject kinds we do not model, which +// callers log and skip. +// +// The error is reserved for that one meaning. A carrier that cannot be built is +// logged and dropped on its own, because the durable grant is the cluster's +// record of the binding and a failed optimization must not erase it. +func GrantRoleToSubject( + ctx context.Context, + subject rbacv1.Subject, + resource *v2.Resource, + entName string, + matchCfg ExternalMatchConfig, +) ([]*v2.Grant, error) { if subject.Kind == SubjectKindServiceAccount { saName := fmt.Sprintf("%s/%s", subject.Namespace, subject.Name) // SA are always namespaced, even if they can have cluster roles bind to cluster level. saResource := GenerateResourceForGrant(saName, ResourceTypeServiceAccount.Id) @@ -66,31 +84,48 @@ func GrantRoleToSubject(subject rbacv1.Subject, resource *v2.Resource, entName s entName, saResource, ) - return g, nil + return []*v2.Grant{g}, nil } else if (subject.APIGroup == RBACAPIGroup || subject.APIGroup == RBACAPIGroupV1) && !strings.Contains(subject.Name, "system:") { // Ignore System subjects if subject.Kind == SubjectKindGroup { - // Group grants intentionally carry no GrantExpandable annotation: vanilla - // Kubernetes has no membership source to expand through (membership lives - // in the authenticator — x509 O= fields, OIDC claims, cloud IAM mappers). - // Cloud connectors (EKS/AKS/GKE) add their own expansion annotations paired - // with ExternalResourceMatch in their custom builders. groupResource := GenerateResourceForGrant(subject.Name, ResourceTypeKubeGroup.Id) - g := grant.NewGrant( - resource, - entName, - groupResource, - ) - return g, nil + grants := []*v2.Grant{ + grant.NewGrant( + resource, + entName, + groupResource, + ), + } + carrier, err := matchCfg.groupCarrierGrant(resource, entName, subject.Name) + if err != nil { + // Skip the carrier, keep the durable grant. Returning the error + // here would lose both: every caller reads an error as an + // unsupported subject kind and drops the subject entirely. + ctxzap.Extract(ctx).Warn( + "baton-kubernetes: failed to build external-match carrier, keeping durable group grant", + zap.String("subject_name", subject.Name), + zap.String("entitlement", entName), + zap.Error(err), + ) + return grants, nil + } + if carrier != nil { + grants = append(grants, carrier) + } + return grants, nil } if subject.Kind == SubjectKindUser { - g := grant.NewGrant( - resource, - entName, - GenerateResourceForGrant(subject.Name, ResourceTypeKubeUser.Id), - grantOpts..., - ) - return g, nil + grants := []*v2.Grant{ + grant.NewGrant( + resource, + entName, + GenerateResourceForGrant(subject.Name, ResourceTypeKubeUser.Id), + ), + } + if carrier := matchCfg.userCarrierGrant(resource, entName, subject.Name); carrier != nil { + grants = append(grants, carrier) + } + return grants, nil } } return nil, fmt.Errorf("unsupported subject type") diff --git a/pkg/connector/resource_types.go b/pkg/connector/resource_types.go index e4030a91..ad8d4c9e 100644 --- a/pkg/connector/resource_types.go +++ b/pkg/connector/resource_types.go @@ -96,8 +96,12 @@ var ( Annotations: optInAnnotations(), } ResourceTypeBinding = &v2.ResourceType{Id: "binding", DisplayName: "Binding", Description: "Internal type for processing RBAC bindings"} - ResourceTypeUser = &v2.ResourceType{Id: "user", DisplayName: SubjectTypeUser, Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_USER}} - ResourceTypeGroup = &v2.ResourceType{Id: "group", DisplayName: SubjectTypeGroup, Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_GROUP}} + // ResourceTypeUser and ResourceTypeGroup are the placeholder principals that + // external-match carriers point at. Deliberately unregistered and absent from + // DeclaredResourceTypeIDs: they stand for resources in another app, and the + // carrier is deleted once matching runs. See external_match.go. + ResourceTypeUser = &v2.ResourceType{Id: "user", DisplayName: SubjectTypeUser, Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_USER}} + ResourceTypeGroup = &v2.ResourceType{Id: "group", DisplayName: SubjectTypeGroup, Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_GROUP}} ) // SparseResourceTypeIDs lists the types belonging to the sparse model, which diff --git a/pkg/connector/role.go b/pkg/connector/role.go index 15af91ff..cf34026e 100644 --- a/pkg/connector/role.go +++ b/pkg/connector/role.go @@ -20,6 +20,8 @@ import ( type roleBuilder struct { client kubernetes.Interface bindingProvider RoleBindingProvider + // matchCfg tunes external-match carriers. See external_match.go. + matchCfg ExternalMatchConfig } // ResourceType returns the resource type for Role. @@ -190,12 +192,12 @@ func (r *roleBuilder) Grants(ctx context.Context, resource *v2.Resource, opts rs if subject.Kind == SubjectKindServiceAccount && subject.Namespace == "" { subject.Namespace = binding.Namespace } - subjectGrant, err := GrantRoleToSubject(subject, resource, "member") + subjectGrants, err := GrantRoleToSubject(ctx, subject, resource, "member", r.matchCfg) if err != nil { l.Debug("subject kind not supported", zap.String("subject kind", subject.Kind)) continue } - rv = append(rv, subjectGrant) + rv = append(rv, subjectGrants...) } } @@ -203,9 +205,10 @@ func (r *roleBuilder) Grants(ctx context.Context, resource *v2.Resource, opts rs } // newRoleBuilder creates a new role builder. -func newRoleBuilder(client kubernetes.Interface, bindingProvider RoleBindingProvider) *roleBuilder { +func newRoleBuilder(client kubernetes.Interface, bindingProvider RoleBindingProvider, matchCfg ExternalMatchConfig) *roleBuilder { return &roleBuilder{ client: client, bindingProvider: bindingProvider, + matchCfg: matchCfg, } } diff --git a/pkg/connector/role_assignment.go b/pkg/connector/role_assignment.go index 80befa26..f181279d 100644 --- a/pkg/connector/role_assignment.go +++ b/pkg/connector/role_assignment.go @@ -86,6 +86,8 @@ type roleAssignmentBuilder struct { // entitlements and grants, so emitting assignments too would count the same // access twice. enabled bool + // matchCfg tunes external-match carriers. See external_match.go. + matchCfg ExternalMatchConfig // clusterRoles caches the names of existing cluster roles for one sync, so // paging through assignments does not re-list them per page. @@ -387,12 +389,12 @@ func (b *roleAssignmentBuilder) Grants(ctx context.Context, resource *v2.Resourc } seen[subject] = true - subjectGrant, err := GrantRoleToSubject(subject, resource, assignedEntitlement) + subjectGrants, err := GrantRoleToSubject(ctx, subject, resource, assignedEntitlement, b.matchCfg) if err != nil { l.Debug("subject kind not supported", zap.String("subject kind", subject.Kind)) continue } - rv = append(rv, subjectGrant) + rv = append(rv, subjectGrants...) } return rv, nil, nil @@ -472,11 +474,17 @@ func pageLimit(size int) int { return size } -func newRoleAssignmentBuilder(client kubernetes.Interface, k8s *Kubernetes, enabled bool) *roleAssignmentBuilder { +func newRoleAssignmentBuilder( + client kubernetes.Interface, + k8s *Kubernetes, + enabled bool, + matchCfg ExternalMatchConfig, +) *roleAssignmentBuilder { return &roleAssignmentBuilder{ client: client, bindings: k8s, bindingProvider: k8s, enabled: enabled, + matchCfg: matchCfg, } } diff --git a/pkg/connector/role_assignment_test.go b/pkg/connector/role_assignment_test.go index a1dc910f..d4593662 100644 --- a/pkg/connector/role_assignment_test.go +++ b/pkg/connector/role_assignment_test.go @@ -45,7 +45,7 @@ func userSubject(name string) rbacv1.Subject { // Kubernetes connector so the binding cache and lookups behave as in production. func newRoleAssignmentFixture(objects ...runtime.Object) *roleAssignmentBuilder { client := fake.NewSimpleClientset(objects...) - return newRoleAssignmentBuilder(client, &Kubernetes{client: client}, true) + return newRoleAssignmentBuilder(client, &Kubernetes{client: client}, true, ExternalMatchConfig{}) } // listAssignmentIDs drains List and returns the object IDs it emitted. @@ -179,7 +179,7 @@ func TestRoleAssignmentGrantsDedupeSubjects(t *testing.T) { require.NoError(t, err) principals := []string{} - for _, g := range grants { + for _, g := range durableGrants(grants) { principals = append(principals, g.GetPrincipal().GetId().GetResource()) } assert.ElementsMatch(t, []string{"alice", "bob"}, principals, @@ -204,7 +204,7 @@ func TestRoleAssignmentGrantsScopedToNamespace(t *testing.T) { for _, r := range resources { grants, _, err := b.Grants(ctx, r, rs.SyncOpAttrs{SyncID: "sync-1"}) require.NoError(t, err) - for _, g := range grants { + for _, g := range durableGrants(grants) { got[r.GetId().GetResource()] = append(got[r.GetId().GetResource()], g.GetPrincipal().GetId().GetResource()) } } @@ -341,7 +341,7 @@ func TestClusterRoleSuppressedUnderRoleAssignments(t *testing.T) { DisplayName: "view", } - flat := newClusterRoleBuilder(client, k8s, false) + flat := newClusterRoleBuilder(client, k8s, false, ExternalMatchConfig{}) ents, _, err := flat.Entitlements(ctx, resource, rs.SyncOpAttrs{SyncID: "sync-1"}) require.NoError(t, err) assert.NotEmpty(t, ents, "the flat model must still declare cluster role entitlements") @@ -349,7 +349,7 @@ func TestClusterRoleSuppressedUnderRoleAssignments(t *testing.T) { require.NoError(t, err) assert.NotEmpty(t, grants) - sparse := newClusterRoleBuilder(client, k8s, true) + sparse := newClusterRoleBuilder(client, k8s, true, ExternalMatchConfig{}) ents, _, err = sparse.Entitlements(ctx, resource, rs.SyncOpAttrs{SyncID: "sync-2"}) require.NoError(t, err) assert.Empty(t, ents, "role_assignment expresses this access instead") diff --git a/pkg/connector/role_test.go b/pkg/connector/role_test.go index 54be5097..62e4bf8b 100644 --- a/pkg/connector/role_test.go +++ b/pkg/connector/role_test.go @@ -315,18 +315,18 @@ var podReaderResource = &v2.Resource{ // report grants from the first sync's bindings forever. func TestRoleBuilderGrantsAcrossSyncs(t *testing.T) { fakeClient, k8s := bindRoleFixture(t) - builder := newRoleBuilder(fakeClient, k8s) + builder := newRoleBuilder(fakeClient, k8s, ExternalMatchConfig{}) ctx := context.Background() grants, _, err := builder.Grants(ctx, podReaderResource, rs.SyncOpAttrs{SyncID: "sync-1"}) require.NoError(t, err) - require.Len(t, grants, 1) + require.Len(t, durableGrants(grants), 1) bindBob(t, ctx, fakeClient) grants, _, err = builder.Grants(ctx, podReaderResource, rs.SyncOpAttrs{SyncID: "sync-2"}) require.NoError(t, err) - assert.Len(t, grants, 2, "a later sync must reflect bindings added since the first sync") + assert.Len(t, durableGrants(grants), 2, "a later sync must reflect bindings added since the first sync") } // TestBindingCacheHeldWithinSync verifies the cache still does its job: repeated @@ -334,18 +334,18 @@ func TestRoleBuilderGrantsAcrossSyncs(t *testing.T) { // a full cluster-wide binding list. func TestBindingCacheHeldWithinSync(t *testing.T) { fakeClient, k8s := bindRoleFixture(t) - builder := newRoleBuilder(fakeClient, k8s) + builder := newRoleBuilder(fakeClient, k8s, ExternalMatchConfig{}) ctx := context.Background() grants, _, err := builder.Grants(ctx, podReaderResource, rs.SyncOpAttrs{SyncID: "sync-1"}) require.NoError(t, err) - require.Len(t, grants, 1) + require.Len(t, durableGrants(grants), 1) bindBob(t, ctx, fakeClient) grants, _, err = builder.Grants(ctx, podReaderResource, rs.SyncOpAttrs{SyncID: "sync-1"}) require.NoError(t, err) - assert.Len(t, grants, 1, "the same sync must serve its cached snapshot, not re-list") + assert.Len(t, durableGrants(grants), 1, "the same sync must serve its cached snapshot, not re-list") } // TestBindingCacheInvalidationIsIndependentOfList pins the reason the cache is