diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 5bebae0..2dee176 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -61,6 +61,10 @@ release: github: owner: observiq name: bindplane-operator + # Tags with a semver prerelease suffix (e.g. 0.2.0-rc.1, 0.1.0-beta.0) are + # published as GitHub prereleases, and only stable tags become "Latest". + prerelease: auto + make_latest: "{{ not .Prerelease }}" extra_files: - glob: tmp/install.yaml - glob: tmp/install-no-webhook.yaml diff --git a/api/v1alpha1/bindplane_types.go b/api/v1alpha1/bindplane_types.go index a3555e4..02ba309 100644 --- a/api/v1alpha1/bindplane_types.go +++ b/api/v1alpha1/bindplane_types.go @@ -1382,6 +1382,75 @@ type OIDCConfig struct { // When true, users cannot be invited via email and must log in via OIDC directly. // +optional DisableInvitations bool `json:"disableInvitations,omitempty"` + + // CustomClaims customizes the ID token claim names Bindplane reads and the + // group / role strings it matches within those claims. Omit a field to keep + // the Bindplane default. + // +optional + CustomClaims *OIDCCustomClaimsConfig `json:"customClaims,omitempty"` +} + +// OIDCCustomClaimsConfig customizes OIDC claim names and the group / role strings +// Bindplane matches, so an existing identity provider does not have to be reshaped +// to Bindplane's default claim names. +// +// The first group of fields renames the claims Bindplane reads on the ID token. +// The second group changes the strings Bindplane matches inside the groups and +// roles claims (after claim names are resolved). +type OIDCCustomClaimsConfig struct { + // Groups is the claim name for user groups. Defaults to "groups". + // +optional + Groups string `json:"groups,omitempty"` + + // GroupIDs is the claim name for group IDs, used as a fallback when the groups + // claim is missing, empty, or parses to an empty list. Defaults to "group_ids". + // +optional + GroupIDs string `json:"groupIds,omitempty"` + + // Roles is the claim name for user roles. Defaults to "roles". + // +optional + Roles string `json:"roles,omitempty"` + + // OrganizationAdmin is the claim name indicating organization admin status. + // Defaults to "bindplane_org_admin". + // +optional + OrganizationAdmin string `json:"organizationAdmin,omitempty"` + + // Projects is the claim name for Bindplane project assignments. + // Defaults to "bindplane_projects". + // +optional + Projects string `json:"projects,omitempty"` + + // DefaultRole is the claim name for default role assignment. + // Defaults to "bindplane_default_role". + // +optional + DefaultRole string `json:"defaultRole,omitempty"` + + // OrgAdminGroupName is the group / role string that indicates organization admin + // status. Defaults to "bindplane-org-admin". + // +optional + OrgAdminGroupName string `json:"orgAdminGroupName,omitempty"` + + // ProjectsGroupPrefix is the group / role prefix that encodes project assignments, + // e.g. "bindplane-projects-" or "bindplane-projects-:". + // Defaults to "bindplane-projects-". + // +optional + ProjectsGroupPrefix string `json:"projectsGroupPrefix,omitempty"` + + // AdminGroupName is the group / role string that indicates the admin role. + // Defaults to "bindplane-admin". + // +optional + AdminGroupName string `json:"adminGroupName,omitempty"` + + // UserGroupName is the group / role string that indicates the user role. + // Defaults to "bindplane-user". + // +optional + UserGroupName string `json:"userGroupName,omitempty"` + + // ViewerGroupName is the group / role string that indicates the viewer role. + // Defaults to "bindplane-viewer". + // +optional + ViewerGroupName string `json:"viewerGroupName,omitempty"` } // NetworkConfig defines network configuration diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 872e8b6..56821b0 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -984,6 +984,11 @@ func (in *OIDCConfig) DeepCopyInto(out *OIDCConfig) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.CustomClaims != nil { + in, out := &in.CustomClaims, &out.CustomClaims + *out = new(OIDCCustomClaimsConfig) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OIDCConfig. @@ -996,6 +1001,21 @@ func (in *OIDCConfig) DeepCopy() *OIDCConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OIDCCustomClaimsConfig) DeepCopyInto(out *OIDCCustomClaimsConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OIDCCustomClaimsConfig. +func (in *OIDCCustomClaimsConfig) DeepCopy() *OIDCCustomClaimsConfig { + if in == nil { + return nil + } + out := new(OIDCCustomClaimsConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OpAMPComponentSpec) DeepCopyInto(out *OpAMPComponentSpec) { *out = *in diff --git a/config/crd/bases/k8s.bindplane.com_bindplanes.yaml b/config/crd/bases/k8s.bindplane.com_bindplanes.yaml index 4e636c2..9899229 100644 --- a/config/crd/bases/k8s.bindplane.com_bindplanes.yaml +++ b/config/crd/bases/k8s.bindplane.com_bindplanes.yaml @@ -14834,6 +14834,31 @@ spec: - key type: object x-kubernetes-map-type: atomic + customClaims: + properties: + adminGroupName: + type: string + defaultRole: + type: string + groupIds: + type: string + groups: + type: string + orgAdminGroupName: + type: string + organizationAdmin: + type: string + projects: + type: string + projectsGroupPrefix: + type: string + roles: + type: string + userGroupName: + type: string + viewerGroupName: + type: string + type: object disableInvitations: type: boolean issuer: diff --git a/docs/configuration/api.md b/docs/configuration/api.md index de561b9..1fd7e63 100644 --- a/docs/configuration/api.md +++ b/docs/configuration/api.md @@ -497,6 +497,34 @@ _Appears in:_ | `issuer` _string_ | Issuer is the URL of the OIDC provider | | Optional: \{\}
| | `scopes` _string array_ | Scopes is the list of OAuth2 scopes to request | | Optional: \{\}
| | `disableInvitations` _boolean_ | DisableInvitations disables the invitation flow for OIDC-authenticated users.
When true, users cannot be invited via email and must log in via OIDC directly. | | Optional: \{\}
| +| `customClaims` _[OIDCCustomClaimsConfig](#oidccustomclaimsconfig)_ | CustomClaims customizes the ID token claim names Bindplane reads and the
group / role strings it matches within those claims. Omit a field to keep
the Bindplane default. | | Optional: \{\}
| + +#### OIDCCustomClaimsConfig + +OIDCCustomClaimsConfig customizes OIDC claim names and the group / role strings +Bindplane matches, so an existing identity provider does not have to be reshaped +to Bindplane's default claim names. + +The first group of fields renames the claims Bindplane reads on the ID token. +The second group changes the strings Bindplane matches inside the groups and +roles claims (after claim names are resolved). + +_Appears in:_ +- [OIDCConfig](#oidcconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `groups` _string_ | Groups is the claim name for user groups. Defaults to "groups". | | Optional: \{\}
| +| `groupIds` _string_ | GroupIDs is the claim name for group IDs, used as a fallback when the groups
claim is missing, empty, or parses to an empty list. Defaults to "group_ids". | | Optional: \{\}
| +| `roles` _string_ | Roles is the claim name for user roles. Defaults to "roles". | | Optional: \{\}
| +| `organizationAdmin` _string_ | OrganizationAdmin is the claim name indicating organization admin status.
Defaults to "bindplane_org_admin". | | Optional: \{\}
| +| `projects` _string_ | Projects is the claim name for Bindplane project assignments.
Defaults to "bindplane_projects". | | Optional: \{\}
| +| `defaultRole` _string_ | DefaultRole is the claim name for default role assignment.
Defaults to "bindplane_default_role". | | Optional: \{\}
| +| `orgAdminGroupName` _string_ | OrgAdminGroupName is the group / role string that indicates organization admin
status. Defaults to "bindplane-org-admin". | | Optional: \{\}
| +| `projectsGroupPrefix` _string_ | ProjectsGroupPrefix is the group / role prefix that encodes project assignments,
e.g. "bindplane-projects-" or "bindplane-projects-:".
Defaults to "bindplane-projects-". | | Optional: \{\}
| +| `adminGroupName` _string_ | AdminGroupName is the group / role string that indicates the admin role.
Defaults to "bindplane-admin". | | Optional: \{\}
| +| `userGroupName` _string_ | UserGroupName is the group / role string that indicates the user role.
Defaults to "bindplane-user". | | Optional: \{\}
| +| `viewerGroupName` _string_ | ViewerGroupName is the group / role string that indicates the viewer role.
Defaults to "bindplane-viewer". | | Optional: \{\}
| #### OpAMPComponentSpec diff --git a/docs/configuration/configuration.md b/docs/configuration/configuration.md index ef15938..0404597 100644 --- a/docs/configuration/configuration.md +++ b/docs/configuration/configuration.md @@ -11,6 +11,7 @@ Configuration is provided via the `spec.config` field of the `Bindplane` custom - [System auth](#system-auth) - [LDAP and Active Directory](#ldap-and-active-directory) - [OIDC](#oidc) + - [Custom claims](#custom-claims) - [Network](#network) - [Store](#store) - [PostgreSQL](#postgresql) @@ -278,6 +279,89 @@ spec: key: client-secret ``` +#### Custom claims + +`spec.config.auth.oidc.customClaims` adapts Bindplane to an identity provider that already emits its own claim names or group naming scheme, so the IdP does not have to be reshaped to Bindplane's defaults. + +There are two groups of settings: + +- **Claim name overrides** (`groups`, `groupIds`, `roles`, `organizationAdmin`, `projects`, `defaultRole`) rename the claims Bindplane reads on the ID token. +- **Group / role string overrides** (`orgAdminGroupName`, `projectsGroupPrefix`, `adminGroupName`, `userGroupName`, `viewerGroupName`) change the strings Bindplane matches *inside* the groups and roles claims, after the claim names above are resolved. + +Every field is optional. Omit a field to keep the Bindplane default; the operator only sets an environment variable for fields you populate. + +`groupIds` is a fallback: Bindplane uses it when the groups claim is missing, empty, or parses to an empty list. + +`projectsGroupPrefix` matches group entries that encode project access, either `` or `:` (for example `bindplane-projects-admin:01PROJECTID`). + +| CRD Field | Environment Variable | Default | Required | +|---|---|---|---| +| `spec.config.auth.oidc.customClaims.groups` | `BINDPLANE_OIDC_CUSTOM_CLAIMS_GROUPS` | `groups` | No | +| `spec.config.auth.oidc.customClaims.groupIds` | `BINDPLANE_OIDC_CUSTOM_CLAIMS_GROUP_IDS` | `group_ids` | No | +| `spec.config.auth.oidc.customClaims.roles` | `BINDPLANE_OIDC_CUSTOM_CLAIMS_ROLES` | `roles` | No | +| `spec.config.auth.oidc.customClaims.organizationAdmin` | `BINDPLANE_OIDC_CUSTOM_CLAIMS_ORGANIZATION_ADMIN` | `bindplane_org_admin` | No | +| `spec.config.auth.oidc.customClaims.projects` | `BINDPLANE_OIDC_CUSTOM_CLAIMS_PROJECTS` | `bindplane_projects` | No | +| `spec.config.auth.oidc.customClaims.defaultRole` | `BINDPLANE_OIDC_CUSTOM_CLAIMS_DEFAULT_ROLE` | `bindplane_default_role` | No | +| `spec.config.auth.oidc.customClaims.orgAdminGroupName` | `BINDPLANE_OIDC_CUSTOM_CLAIMS_ORG_ADMIN_GROUP_NAME` | `bindplane-org-admin` | No | +| `spec.config.auth.oidc.customClaims.projectsGroupPrefix` | `BINDPLANE_OIDC_CUSTOM_CLAIMS_PROJECTS_GROUP_PREFIX` | `bindplane-projects-` | No | +| `spec.config.auth.oidc.customClaims.adminGroupName` | `BINDPLANE_OIDC_CUSTOM_CLAIMS_ADMIN_GROUP_NAME` | `bindplane-admin` | No | +| `spec.config.auth.oidc.customClaims.userGroupName` | `BINDPLANE_OIDC_CUSTOM_CLAIMS_USER_GROUP_NAME` | `bindplane-user` | No | +| `spec.config.auth.oidc.customClaims.viewerGroupName` | `BINDPLANE_OIDC_CUSTOM_CLAIMS_VIEWER_GROUP_NAME` | `bindplane-viewer` | No | + +Custom claims require Bindplane server v1.100.2 or later. On earlier versions the environment variables are ignored and the defaults apply. + +Example (renaming claim names only): + +```yaml +spec: + config: + auth: + type: oidc + oidc: + issuer: https://accounts.example.com + scopes: + - openid + - profile + - email + clientIDSecretRef: + name: oidc-secrets + key: client-id + clientSecretSecretRef: + name: oidc-secrets + key: client-secret + customClaims: + groups: "user_groups" + roles: "user_roles" + organizationAdmin: "acme_org_admin" +``` + +Example (renaming the group and role strings Bindplane matches): + +```yaml +spec: + config: + auth: + type: oidc + oidc: + issuer: https://accounts.example.com + scopes: + - openid + - profile + - email + clientIDSecretRef: + name: oidc-secrets + key: client-id + clientSecretSecretRef: + name: oidc-secrets + key: client-secret + customClaims: + orgAdminGroupName: "acme-org-admin" + projectsGroupPrefix: "acme-projects-" + adminGroupName: "acme-admin" + userGroupName: "acme-user" + viewerGroupName: "acme-viewer" +``` + ## Network TLS is generally not configured on the Bindplane server when you use Ingress or Gateway API to terminate TLS. In that case, only `remoteURL` (and optionally `webURL`) need to reflect the external URL; the server continues to listen over HTTP inside the cluster. diff --git a/docs/configuration/security.md b/docs/configuration/security.md index b1c76bc..b56cc14 100644 --- a/docs/configuration/security.md +++ b/docs/configuration/security.md @@ -14,7 +14,7 @@ The following are configured via the Bindplane custom resource and documented in | Area | What you configure | How the operator uses it | |------|--------------------|---------------------------| | **License** | `spec.config.license` or `licenseSecretRef` | Injects `BINDPLANE_LICENSE` from value or Secret into Node, Jobs, NATS, and the Jobs Migrate Job. | -| **Authentication** | System auth (`username`/`password` or Secret refs), session secret (`sessionSecret` or `sessionSecretSecretRef`), API key (`apiKey` or `apiKeySecretRef`), LDAP bind user/password and [LDAP TLS](configuration.md#ldap-and-active-directory), OIDC client ID/secret or Secret refs, agent auth (`spec.config.agents.auth`) | Sets auth-related env vars; mounts LDAP TLS Secret when `spec.config.auth.ldap.tls` is set. | +| **Authentication** | System auth (`username`/`password` or Secret refs), session secret (`sessionSecret` or `sessionSecretSecretRef`), API key (`apiKey` or `apiKeySecretRef`), LDAP bind user/password and [LDAP TLS](configuration.md#ldap-and-active-directory), OIDC client ID/secret or Secret refs, [OIDC custom claims](configuration.md#custom-claims), agent auth (`spec.config.agents.auth`) | Sets auth-related env vars; mounts LDAP TLS Secret when `spec.config.auth.ldap.tls` is set. | | **Network TLS** | [Network TLS](configuration.md#network): `spec.config.network.tls` (secretName, certKey, keyKey, caKey, minVersion, skipVerify) | Mounts the Secret at a fixed path and sets `BINDPLANE_TLS_*` env vars to the mounted file paths. Used when you want server-side or mutual TLS on the Bindplane server (often omitted when using [Ingress or Gateway API](configuration.md#network) to terminate TLS). | | **PostgreSQL** | Postgres username/password (or Secret refs) and [PostgreSQL TLS](configuration.md#postgresql) (`spec.config.store.postgres.tls`, sslmode) | Injects credentials; mounts Postgres TLS Secret when TLS is configured and sets `BINDPLANE_POSTGRES_SSL_*` env vars. | | **Metrics (Prometheus)** | Optional basic auth for the HTTP endpoint where Bindplane **exposes** its own metrics via `spec.config.metrics.prometheus.username` and `password` or `passwordSecretRef` | Sets `BINDPLANE_METRICS_PROMETHEUS_USERNAME` / `BINDPLANE_METRICS_PROMETHEUS_PASSWORD` when configured. Distinct from Bindplane TSDB remote write auth (below). | @@ -226,6 +226,8 @@ This rule is enforced at admission time by the Kubernetes API server (and additi | `spec.config.errors.frontendDSN` | `frontendDSNSecretRef` | | `spec.config.status.keys` | `keysSecretRef` | +[OIDC custom claims](configuration.md#custom-claims) (`spec.config.auth.oidc.customClaims`) deliberately have no SecretRef variant: they are claim and group *names* that identify where Bindplane looks in the ID token, not credentials, and are set as plain env var values. + ## Summary | Secret / TLS | User-configurable? | Env vars (where applicable) | Where configured | Documentation | diff --git a/internal/controller/bindplane_controller.go b/internal/controller/bindplane_controller.go index 7a3d387..cc486dc 100644 --- a/internal/controller/bindplane_controller.go +++ b/internal/controller/bindplane_controller.go @@ -269,6 +269,21 @@ const ( // OIDC additional configuration bindplaneOIDCDisableInvitationsEnvVar = "BINDPLANE_OIDC_DISABLE_INVITATIONS" + // OIDC custom claims: claim name overrides + bindplaneOIDCCustomClaimsGroupsEnvVar = "BINDPLANE_OIDC_CUSTOM_CLAIMS_GROUPS" + bindplaneOIDCCustomClaimsGroupIDsEnvVar = "BINDPLANE_OIDC_CUSTOM_CLAIMS_GROUP_IDS" + bindplaneOIDCCustomClaimsRolesEnvVar = "BINDPLANE_OIDC_CUSTOM_CLAIMS_ROLES" + bindplaneOIDCCustomClaimsOrganizationAdminEnvVar = "BINDPLANE_OIDC_CUSTOM_CLAIMS_ORGANIZATION_ADMIN" + bindplaneOIDCCustomClaimsProjectsEnvVar = "BINDPLANE_OIDC_CUSTOM_CLAIMS_PROJECTS" + bindplaneOIDCCustomClaimsDefaultRoleEnvVar = "BINDPLANE_OIDC_CUSTOM_CLAIMS_DEFAULT_ROLE" + + // OIDC custom claims: group / role pattern overrides + bindplaneOIDCCustomClaimsOrgAdminGroupNameEnvVar = "BINDPLANE_OIDC_CUSTOM_CLAIMS_ORG_ADMIN_GROUP_NAME" + bindplaneOIDCCustomClaimsProjectsGroupPrefixEnvVar = "BINDPLANE_OIDC_CUSTOM_CLAIMS_PROJECTS_GROUP_PREFIX" + bindplaneOIDCCustomClaimsAdminGroupNameEnvVar = "BINDPLANE_OIDC_CUSTOM_CLAIMS_ADMIN_GROUP_NAME" + bindplaneOIDCCustomClaimsUserGroupNameEnvVar = "BINDPLANE_OIDC_CUSTOM_CLAIMS_USER_GROUP_NAME" + bindplaneOIDCCustomClaimsViewerGroupNameEnvVar = "BINDPLANE_OIDC_CUSTOM_CLAIMS_VIEWER_GROUP_NAME" + // NATS TLS additional configuration bindplaneNatsTLSSkipVerifyEnvVar = "BINDPLANE_NATS_TLS_SKIP_VERIFY" ) diff --git a/internal/controller/config_env.go b/internal/controller/config_env.go index 178681f..019bd1e 100644 --- a/internal/controller/config_env.go +++ b/internal/controller/config_env.go @@ -109,6 +109,39 @@ func getOIDCEnvVars(oidc *bindplanev1alpha1.OIDCConfig) []corev1.EnvVar { if oidc.DisableInvitations { envVars = append(envVars, corev1.EnvVar{Name: bindplaneOIDCDisableInvitationsEnvVar, Value: "true"}) } + envVars = append(envVars, getOIDCCustomClaimsEnvVars(oidc.CustomClaims)...) + return envVars +} + +// getOIDCCustomClaimsEnvVars returns OIDC custom claims environment variables. +// Each field is omitted when empty so the Bindplane server default applies. +// Returns nil when customClaims is nil. +func getOIDCCustomClaimsEnvVars(customClaims *bindplanev1alpha1.OIDCCustomClaimsConfig) []corev1.EnvVar { + if customClaims == nil { + return nil + } + pairs := []struct { + name string + value string + }{ + {bindplaneOIDCCustomClaimsGroupsEnvVar, customClaims.Groups}, + {bindplaneOIDCCustomClaimsGroupIDsEnvVar, customClaims.GroupIDs}, + {bindplaneOIDCCustomClaimsRolesEnvVar, customClaims.Roles}, + {bindplaneOIDCCustomClaimsOrganizationAdminEnvVar, customClaims.OrganizationAdmin}, + {bindplaneOIDCCustomClaimsProjectsEnvVar, customClaims.Projects}, + {bindplaneOIDCCustomClaimsDefaultRoleEnvVar, customClaims.DefaultRole}, + {bindplaneOIDCCustomClaimsOrgAdminGroupNameEnvVar, customClaims.OrgAdminGroupName}, + {bindplaneOIDCCustomClaimsProjectsGroupPrefixEnvVar, customClaims.ProjectsGroupPrefix}, + {bindplaneOIDCCustomClaimsAdminGroupNameEnvVar, customClaims.AdminGroupName}, + {bindplaneOIDCCustomClaimsUserGroupNameEnvVar, customClaims.UserGroupName}, + {bindplaneOIDCCustomClaimsViewerGroupNameEnvVar, customClaims.ViewerGroupName}, + } + var envVars []corev1.EnvVar + for _, p := range pairs { + if p.value != "" { + envVars = append(envVars, corev1.EnvVar{Name: p.name, Value: p.value}) + } + } return envVars } diff --git a/internal/controller/config_env_test.go b/internal/controller/config_env_test.go index 35b3be7..c6160e5 100644 --- a/internal/controller/config_env_test.go +++ b/internal/controller/config_env_test.go @@ -60,6 +60,97 @@ func TestGetOIDCEnvVars_DisableInvitationsFalse(t *testing.T) { requireNoEnvVar(t, envVars, bindplaneOIDCDisableInvitationsEnvVar) } +// ------- getOIDCCustomClaimsEnvVars ------- + +func TestGetOIDCCustomClaimsEnvVars_NilReturnsNil(t *testing.T) { + if envVars := getOIDCCustomClaimsEnvVars(nil); envVars != nil { + t.Errorf("expected nil env vars for nil custom claims, got %v", envVars) + } +} + +func TestGetOIDCCustomClaimsEnvVars_EmptyEmitsNothing(t *testing.T) { + envVars := getOIDCCustomClaimsEnvVars(&bindplanev1alpha1.OIDCCustomClaimsConfig{}) + if len(envVars) != 0 { + t.Errorf("expected no env vars for empty custom claims, got %v", envVars) + } +} + +func TestGetOIDCCustomClaimsEnvVars_AllFields(t *testing.T) { + customClaims := &bindplanev1alpha1.OIDCCustomClaimsConfig{ + Groups: "my_groups", + GroupIDs: "my_group_ids", + Roles: "my_roles", + OrganizationAdmin: "my_org_admin", + Projects: "my_projects", + DefaultRole: "my_default_role", + OrgAdminGroupName: "acme-org-admin", + ProjectsGroupPrefix: "acme-projects-", + AdminGroupName: "acme-admin", + UserGroupName: "acme-user", + ViewerGroupName: "acme-viewer", + } + envVars := getOIDCCustomClaimsEnvVars(customClaims) + + requireEnvVar(t, envVars, bindplaneOIDCCustomClaimsGroupsEnvVar, "my_groups") + requireEnvVar(t, envVars, bindplaneOIDCCustomClaimsGroupIDsEnvVar, "my_group_ids") + requireEnvVar(t, envVars, bindplaneOIDCCustomClaimsRolesEnvVar, "my_roles") + requireEnvVar(t, envVars, bindplaneOIDCCustomClaimsOrganizationAdminEnvVar, "my_org_admin") + requireEnvVar(t, envVars, bindplaneOIDCCustomClaimsProjectsEnvVar, "my_projects") + requireEnvVar(t, envVars, bindplaneOIDCCustomClaimsDefaultRoleEnvVar, "my_default_role") + requireEnvVar(t, envVars, bindplaneOIDCCustomClaimsOrgAdminGroupNameEnvVar, "acme-org-admin") + requireEnvVar(t, envVars, bindplaneOIDCCustomClaimsProjectsGroupPrefixEnvVar, "acme-projects-") + requireEnvVar(t, envVars, bindplaneOIDCCustomClaimsAdminGroupNameEnvVar, "acme-admin") + requireEnvVar(t, envVars, bindplaneOIDCCustomClaimsUserGroupNameEnvVar, "acme-user") + requireEnvVar(t, envVars, bindplaneOIDCCustomClaimsViewerGroupNameEnvVar, "acme-viewer") + + if len(envVars) != 11 { + t.Errorf("expected 11 env vars, got %d: %v", len(envVars), envVars) + } +} + +func TestGetOIDCCustomClaimsEnvVars_PartialOmitsUnset(t *testing.T) { + customClaims := &bindplanev1alpha1.OIDCCustomClaimsConfig{ + Groups: "my_groups", + OrgAdminGroupName: "acme-org-admin", + } + envVars := getOIDCCustomClaimsEnvVars(customClaims) + + requireEnvVar(t, envVars, bindplaneOIDCCustomClaimsGroupsEnvVar, "my_groups") + requireEnvVar(t, envVars, bindplaneOIDCCustomClaimsOrgAdminGroupNameEnvVar, "acme-org-admin") + requireNoEnvVar(t, envVars, bindplaneOIDCCustomClaimsGroupIDsEnvVar) + requireNoEnvVar(t, envVars, bindplaneOIDCCustomClaimsRolesEnvVar) + requireNoEnvVar(t, envVars, bindplaneOIDCCustomClaimsOrganizationAdminEnvVar) + requireNoEnvVar(t, envVars, bindplaneOIDCCustomClaimsProjectsEnvVar) + requireNoEnvVar(t, envVars, bindplaneOIDCCustomClaimsDefaultRoleEnvVar) + requireNoEnvVar(t, envVars, bindplaneOIDCCustomClaimsProjectsGroupPrefixEnvVar) + requireNoEnvVar(t, envVars, bindplaneOIDCCustomClaimsAdminGroupNameEnvVar) + requireNoEnvVar(t, envVars, bindplaneOIDCCustomClaimsUserGroupNameEnvVar) + requireNoEnvVar(t, envVars, bindplaneOIDCCustomClaimsViewerGroupNameEnvVar) +} + +// TestGetOIDCEnvVars_CustomClaimsPlumbedThrough verifies custom claims reach the +// full OIDC env var set, not just the helper. +func TestGetOIDCEnvVars_CustomClaimsPlumbedThrough(t *testing.T) { + oidc := &bindplanev1alpha1.OIDCConfig{ + Issuer: "https://accounts.example.com", + CustomClaims: &bindplanev1alpha1.OIDCCustomClaimsConfig{ + Groups: "my_groups", + ViewerGroupName: "acme-viewer", + }, + } + envVars := getOIDCEnvVars(oidc) + requireEnvVar(t, envVars, bindplaneOIDCIssuerEnvVar, "https://accounts.example.com") + requireEnvVar(t, envVars, bindplaneOIDCCustomClaimsGroupsEnvVar, "my_groups") + requireEnvVar(t, envVars, bindplaneOIDCCustomClaimsViewerGroupNameEnvVar, "acme-viewer") +} + +func TestGetOIDCEnvVars_NoCustomClaimsEmitsNone(t *testing.T) { + oidc := &bindplanev1alpha1.OIDCConfig{Issuer: "https://accounts.example.com"} + envVars := getOIDCEnvVars(oidc) + requireNoEnvVar(t, envVars, bindplaneOIDCCustomClaimsGroupsEnvVar) + requireNoEnvVar(t, envVars, bindplaneOIDCCustomClaimsOrgAdminGroupNameEnvVar) +} + // ------- getNatsTLSEnvVars (SkipVerify) ------- func TestGetNatsTLSEnvVars_SkipVerifyEmittedWhenSet(t *testing.T) {