Skip to content
Merged
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
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 18 additions & 1 deletion config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {}
Comment on lines +214 to +219

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: userCarrierGrant also attaches ExternalResourceMatchID, so User subjects are matched against the identity source's resource ID exactly like Groups are. Only the group field's description says so (line 220), and the README flag help repeats the asymmetry, which reads as if ID matching is group-only. Add the same sentence here for consistency with the code.

}
},
{
"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": [
Expand Down Expand Up @@ -255,5 +271,6 @@
"client-key"
]
}
]
],
"supportsExternalResources": true
}
26 changes: 26 additions & 0 deletions docs/connector.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

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: This section explains what selecting an identity source does but never says where to do it, and the setup walkthrough at "Step 3: Configure the connector in C1" (~line 487) still lists only Server, Token, and Sync cluster role assignments. supportsExternalResources: true is what newly surfaces that picker, so a pointer into the setup steps would keep the feature from being inert for anyone following the guide top-to-bottom.


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.

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: This drops the caveat the README keeps. externalGroupMemberEntitlement is hardcoded to "members", which is Entra's slug; a customer federating against Okta, Google Workspace, or AD (member) gets no expansion at all, and the SDK drops it silently on NotFound rather than erroring. "needs no setting" reads as universal here — worth carrying over the README's "requires a code change, not a flag" sentence.


<Note>
**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.
</Note>

## Understanding how the connector selects a cluster

The connector resolves its target cluster in this order:
Expand Down
2 changes: 2 additions & 0 deletions pkg/config/conf.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 30 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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.
Expand All @@ -170,6 +193,8 @@ var ConfigurationFields = []field.SchemaField{
disableCompressionField,
useRoleAssignmentsField,
includeSystemObjectPermissionsField,
externalUserMatchKeyField,
externalGroupMatchKeyField,
}

// ConfigRelations lists mutual-exclusivity and required-together constraints.
Expand All @@ -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),
)
2 changes: 2 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ func TestConfigurationFields(t *testing.T) {
config.FlagDisableCompression,
config.FlagUseRoleAssignments,
config.FlagIncludeSystemObjectPermissions,
config.FlagExternalUserMatchKey,
config.FlagExternalGroupMatchKey,
}

got := make(map[string]bool)
Expand Down
18 changes: 13 additions & 5 deletions pkg/connector/clusterrole.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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...)
}
}

Expand All @@ -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...)
}
}

Expand Down Expand Up @@ -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,
}
}
43 changes: 42 additions & 1 deletion pkg/connector/config_wiring_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
}
Loading
Loading