From e7f48824677f372069d75b47108ba332be1e0ef8 Mon Sep 17 00:00:00 2001 From: ian-flores Date: Mon, 2 Mar 2026 14:53:07 -0800 Subject: [PATCH 01/15] feat: add PPM authenticated repos via Identity Federation Enable Connect and Workbench to authenticate against PPM using Kubernetes Identity Federation (RFC 8693 token exchange). Adds OIDC and Identity Federation config types for PPM, shared token exchange init container and sidecar helpers, and opt-in AuthenticatedRepos flag on Connect/Workbench specs. --- api/core/v1beta1/connect_types.go | 12 + api/core/v1beta1/package_manager_config.go | 72 +++++- .../v1beta1/package_manager_config_test.go | 77 ++++++ api/core/v1beta1/packagemanager_types.go | 40 +++ api/core/v1beta1/site_types.go | 20 ++ api/core/v1beta1/workbench_types.go | 12 + api/core/v1beta1/zz_generated.deepcopy.go | 45 ++++ .../core/v1beta1/connectspec.go | 27 +++ .../core/v1beta1/internalconnectspec.go | 9 + .../v1beta1/internalpackagemanagerspec.go | 44 +++- .../core/v1beta1/internalworkbenchspec.go | 9 + .../core/v1beta1/packagemanagerconfig.go | 9 + .../packagemanageridentityfederationconfig.go | 71 ++++++ .../core/v1beta1/packagemanageroidcconfig.go | 80 ++++++ .../core/v1beta1/packagemanagerspec.go | 9 + .../core/v1beta1/sitespec.go | 9 + .../core/v1beta1/workbenchspec.go | 27 +++ client-go/applyconfiguration/utils.go | 4 + .../crd/bases/core.posit.team_connects.yaml | 12 + .../core.posit.team_packagemanagers.yaml | 22 ++ config/crd/bases/core.posit.team_sites.yaml | 69 ++++++ .../bases/core.posit.team_workbenches.yaml | 12 + .../crd/core.posit.team_connects.yaml | 12 + .../crd/core.posit.team_packagemanagers.yaml | 22 ++ .../templates/crd/core.posit.team_sites.yaml | 69 ++++++ .../crd/core.posit.team_workbenches.yaml | 12 + internal/controller/core/connect.go | 26 +- internal/controller/core/package_manager.go | 5 + internal/controller/core/ppm_auth.go | 227 ++++++++++++++++++ internal/controller/core/ppm_auth_test.go | 95 ++++++++ internal/controller/core/site_controller.go | 33 +++ .../core/site_controller_connect.go | 2 + .../core/site_controller_package_manager.go | 42 ++++ .../core/site_controller_workbench.go | 2 + internal/controller/core/workbench.go | 28 ++- 35 files changed, 1240 insertions(+), 26 deletions(-) create mode 100644 client-go/applyconfiguration/core/v1beta1/packagemanageridentityfederationconfig.go create mode 100644 client-go/applyconfiguration/core/v1beta1/packagemanageroidcconfig.go create mode 100644 internal/controller/core/ppm_auth.go create mode 100644 internal/controller/core/ppm_auth_test.go diff --git a/api/core/v1beta1/connect_types.go b/api/core/v1beta1/connect_types.go index d6e02640..f0bf6be2 100644 --- a/api/core/v1beta1/connect_types.go +++ b/api/core/v1beta1/connect_types.go @@ -147,6 +147,18 @@ type ConnectSpec struct { // ChronicleSidecarProductApiKeyEnabled assumes the api key for this product has been added to a secret and // injects the secret as an environment variable to the sidecar. **EXPERIMENTAL** ChronicleSidecarProductApiKeyEnabled bool `json:"chronicleSidecarProductApiKeyEnabled,omitempty"` + + // AuthenticatedRepos enables PPM authenticated repository access for Connect + // +optional + AuthenticatedRepos bool `json:"authenticatedRepos,omitempty"` + + // PPMAuthImage specifies the container image for PPM auth init/sidecar containers + // +optional + PPMAuthImage string `json:"ppmAuthImage,omitempty"` + + // PPMUrl specifies the PPM URL for authenticated repository access + // +optional + PPMUrl string `json:"ppmUrl,omitempty"` } // TODO: Validation should require Volume definition for off-host-execution... diff --git a/api/core/v1beta1/package_manager_config.go b/api/core/v1beta1/package_manager_config.go index b6dabf5d..6bb5916e 100644 --- a/api/core/v1beta1/package_manager_config.go +++ b/api/core/v1beta1/package_manager_config.go @@ -7,17 +7,19 @@ import ( ) type PackageManagerConfig struct { - Server *PackageManagerServerConfig `json:"Server,omitempty"` - Http *PackageManagerHttpConfig `json:"Http,omitempty"` - Git *PackageManagerGitConfig `json:"Git,omitempty"` - Database *PackageManagerDatabaseConfig `json:"Database,omitempty"` - Postgres *PackageManagerPostgresConfig `json:"Postgres,omitempty"` - Storage *PackageManagerStorageConfig `json:"Storage,omitempty"` - S3Storage *PackageManagerS3StorageConfig `json:"S3Storage,omitempty"` - Metrics *PackageManagerMetricsConfig `json:"Metrics,omitempty"` - Repos *PackageManagerReposConfig `json:"Repos,omitempty"` - Cran *PackageManagerCRANConfig `json:"CRAN,omitempty"` - Debug *PackageManagerDebugConfig `json:"Debug,omitempty"` + Server *PackageManagerServerConfig `json:"Server,omitempty"` + Http *PackageManagerHttpConfig `json:"Http,omitempty"` + Git *PackageManagerGitConfig `json:"Git,omitempty"` + Database *PackageManagerDatabaseConfig `json:"Database,omitempty"` + Postgres *PackageManagerPostgresConfig `json:"Postgres,omitempty"` + Storage *PackageManagerStorageConfig `json:"Storage,omitempty"` + S3Storage *PackageManagerS3StorageConfig `json:"S3Storage,omitempty"` + Metrics *PackageManagerMetricsConfig `json:"Metrics,omitempty"` + Repos *PackageManagerReposConfig `json:"Repos,omitempty"` + Cran *PackageManagerCRANConfig `json:"CRAN,omitempty"` + Debug *PackageManagerDebugConfig `json:"Debug,omitempty"` + OpenIDConnect *PackageManagerOIDCConfig `json:"OpenIDConnect,omitempty"` + IdentityFederation []PackageManagerIdentityFederationConfig `json:"-"` // AdditionalConfig allows appending arbitrary gcfg config content not covered by typed fields. // The value is appended verbatim after the generated config. gcfg parsing naturally handles @@ -53,6 +55,11 @@ func (configStruct *PackageManagerConfig) GenerateGcfg() (string, error) { continue } + // Skip IdentityFederation - handled specially after the main loop + if fieldName == "IdentityFederation" { + continue + } + if fieldValue.IsNil() { continue } @@ -107,6 +114,29 @@ func (configStruct *PackageManagerConfig) GenerateGcfg() (string, error) { } } + // Render named IdentityFederation sections (these use the gcfg named subsection syntax) + for _, idf := range configStruct.IdentityFederation { + builder.WriteString(fmt.Sprintf("\n[IdentityFederation \"%s\"]\n", idf.Name)) + if idf.Issuer != "" { + builder.WriteString("Issuer = " + idf.Issuer + "\n") + } + if idf.Audience != "" { + builder.WriteString("Audience = " + idf.Audience + "\n") + } + if idf.Subject != "" { + builder.WriteString("Subject = " + idf.Subject + "\n") + } + if idf.AuthorizedParty != "" { + builder.WriteString("AuthorizedParty = " + idf.AuthorizedParty + "\n") + } + if idf.Scope != "" { + builder.WriteString("Scope = " + idf.Scope + "\n") + } + if idf.TokenLifetime != "" { + builder.WriteString("TokenLifetime = " + idf.TokenLifetime + "\n") + } + } + if configStruct.AdditionalConfig != "" { builder.WriteString(configStruct.AdditionalConfig) } @@ -176,6 +206,26 @@ type PackageManagerDebugConfig struct { Log string `json:"Log,omitempty"` } +type PackageManagerOIDCConfig struct { + ClientId string `json:"ClientId,omitempty"` + ClientSecret string `json:"ClientSecret,omitempty"` + Issuer string `json:"Issuer,omitempty"` + RequireLogin bool `json:"RequireLogin,omitempty"` + Scope string `json:"Scope,omitempty"` + GroupsClaim string `json:"GroupsClaim,omitempty"` + RoleClaim string `json:"RoleClaim,omitempty"` +} + +type PackageManagerIdentityFederationConfig struct { + Name string `json:"-"` + Issuer string `json:"Issuer,omitempty"` + Audience string `json:"Audience,omitempty"` + Subject string `json:"Subject,omitempty"` + AuthorizedParty string `json:"AuthorizedParty,omitempty"` + Scope string `json:"Scope,omitempty"` + TokenLifetime string `json:"TokenLifetime,omitempty"` +} + // SSHKeyConfig defines SSH key configuration for Git authentication type SSHKeyConfig struct { // Name is a unique identifier for this SSH key configuration diff --git a/api/core/v1beta1/package_manager_config_test.go b/api/core/v1beta1/package_manager_config_test.go index ddf37a91..b451e766 100644 --- a/api/core/v1beta1/package_manager_config_test.go +++ b/api/core/v1beta1/package_manager_config_test.go @@ -62,3 +62,80 @@ func TestPackageManagerConfig_AdditionalConfigEmpty(t *testing.T) { require.Nil(t, err) require.Contains(t, str, "Address = some-address.com") } + +func TestPackageManagerConfig_OpenIDConnect(t *testing.T) { + cfg := PackageManagerConfig{ + OpenIDConnect: &PackageManagerOIDCConfig{ + ClientId: "my-client-id", + ClientSecret: "/etc/rstudio-pm/oidc-client-secret", + Issuer: "https://login.example.com", + RequireLogin: true, + }, + } + str, err := cfg.GenerateGcfg() + require.Nil(t, err) + require.Contains(t, str, "[OpenIDConnect]") + require.Contains(t, str, "ClientId = my-client-id") + require.Contains(t, str, "ClientSecret = /etc/rstudio-pm/oidc-client-secret") + require.Contains(t, str, "Issuer = https://login.example.com") + require.Contains(t, str, "RequireLogin = true") +} + +func TestPackageManagerConfig_IdentityFederation(t *testing.T) { + cfg := PackageManagerConfig{ + IdentityFederation: []PackageManagerIdentityFederationConfig{ + { + Name: "connect", + Issuer: "https://oidc.eks.us-east-1.amazonaws.com/id/EXAMPLE", + Audience: "sts.amazonaws.com", + Subject: "system:serviceaccount:posit-team:mysite-connect", + Scope: "repos:read:*", + }, + { + Name: "workbench", + Issuer: "https://oidc.eks.us-east-1.amazonaws.com/id/EXAMPLE", + Audience: "sts.amazonaws.com", + Subject: "system:serviceaccount:posit-team:mysite-workbench", + Scope: "repos:read:*", + }, + }, + } + str, err := cfg.GenerateGcfg() + require.Nil(t, err) + require.Contains(t, str, `[IdentityFederation "connect"]`) + require.Contains(t, str, `[IdentityFederation "workbench"]`) + require.Contains(t, str, "Issuer = https://oidc.eks.us-east-1.amazonaws.com/id/EXAMPLE") + require.Contains(t, str, "Audience = sts.amazonaws.com") + require.Contains(t, str, "Subject = system:serviceaccount:posit-team:mysite-connect") + require.Contains(t, str, "Scope = repos:read:*") +} + +func TestPackageManagerConfig_OpenIDConnectAndIdentityFederation(t *testing.T) { + cfg := PackageManagerConfig{ + Server: &PackageManagerServerConfig{ + Address: "https://packagemanager.example.com", + }, + OpenIDConnect: &PackageManagerOIDCConfig{ + ClientId: "ppm-client", + ClientSecret: "/etc/rstudio-pm/oidc-client-secret", + Issuer: "https://login.example.com", + RequireLogin: true, + }, + IdentityFederation: []PackageManagerIdentityFederationConfig{ + { + Name: "connect", + Issuer: "https://oidc.eks.us-east-1.amazonaws.com/id/EXAMPLE", + Audience: "sts.amazonaws.com", + Subject: "system:serviceaccount:posit-team:.*-connect", + Scope: "repos:read:*", + TokenLifetime: "1h", + }, + }, + } + str, err := cfg.GenerateGcfg() + require.Nil(t, err) + require.Contains(t, str, "[Server]") + require.Contains(t, str, "[OpenIDConnect]") + require.Contains(t, str, `[IdentityFederation "connect"]`) + require.Contains(t, str, "TokenLifetime = 1h") +} diff --git a/api/core/v1beta1/packagemanager_types.go b/api/core/v1beta1/packagemanager_types.go index 0163572b..9ea5a9fc 100644 --- a/api/core/v1beta1/packagemanager_types.go +++ b/api/core/v1beta1/packagemanager_types.go @@ -79,6 +79,11 @@ type PackageManagerSpec struct { // AzureFiles configures Azure Files integration for persistent storage // +optional AzureFiles *AzureFilesConfig `json:"azureFiles,omitempty"` + + // OIDCClientSecretKey is the key name in the vault for the OIDC client secret. + // When set, the client secret will be mounted at /etc/rstudio-pm/oidc-client-secret + // +optional + OIDCClientSecretKey string `json:"oidcClientSecretKey,omitempty"` } // PackageManagerStatus defines the observed state of PackageManager @@ -313,6 +318,24 @@ func (pm *PackageManager) CreateSecretVolumeFactory() *product.SecretVolumeFacto } } + // Add OIDC client secret volume mount if configured + if pm.Spec.OIDCClientSecretKey != "" { + vols["client-secret-volume"] = &product.VolumeDef{ + Source: &v1.VolumeSource{ + CSI: &v1.CSIVolumeSource{ + Driver: "secrets-store.csi.k8s.io", + ReadOnly: ptr.To(true), + VolumeAttributes: map[string]string{ + "secretProviderClass": pm.SecretProviderClassName(), + }, + }, + }, + Mounts: []*product.VolumeMountDef{ + {MountPath: "/etc/rstudio-pm/oidc-client-secret", SubPath: "oidc-client-secret", ReadOnly: true}, + }, + } + } + case product.SiteSecretKubernetes: vols["key-volume"] = &product.VolumeDef{ Env: []v1.EnvVar{ @@ -350,6 +373,23 @@ func (pm *PackageManager) CreateSecretVolumeFactory() *product.SecretVolumeFacto }, } + // Add OIDC client secret volume mount if configured + if pm.Spec.OIDCClientSecretKey != "" { + vols["client-secret-volume"] = &product.VolumeDef{ + Source: &v1.VolumeSource{ + Secret: &v1.SecretVolumeSource{ + SecretName: pm.GetSecretVaultName(), + Items: []v1.KeyToPath{ + {Key: pm.Spec.OIDCClientSecretKey, Path: "oidc-client-secret"}, + }, + }, + }, + Mounts: []*product.VolumeMountDef{ + {MountPath: "/etc/rstudio-pm/oidc-client-secret", SubPath: "oidc-client-secret", ReadOnly: true}, + }, + } + } + default: // uh oh... some other type of secret...? } diff --git a/api/core/v1beta1/site_types.go b/api/core/v1beta1/site_types.go index 44bb4261..4620bbb6 100644 --- a/api/core/v1beta1/site_types.go +++ b/api/core/v1beta1/site_types.go @@ -125,6 +125,10 @@ type SiteSpec struct { // Defaults to true. // +kubebuilder:default=true EnableFQDNHealthChecks *bool `json:"enableFqdnHealthChecks,omitempty"` + + // OIDCIssuerURL is the K8s cluster OIDC issuer URL (for EKS/AKS Identity Federation) + // +optional + OIDCIssuerURL string `json:"oidcIssuerUrl,omitempty"` } type ServiceAccountConfig struct { @@ -225,6 +229,14 @@ type InternalPackageManagerSpec struct { // AdditionalConfig allows appending arbitrary gcfg config content to the generated config. // +optional AdditionalConfig string `json:"additionalConfig,omitempty"` + + // Auth configures OIDC authentication for Package Manager's web UI + // +optional + Auth *AuthSpec `json:"auth,omitempty"` + + // OIDCClientSecretKey is the key in the vault for the OIDC client secret + // +optional + OIDCClientSecretKey string `json:"oidcClientSecretKey,omitempty"` } type InternalConnectSpec struct { @@ -298,6 +310,10 @@ type InternalConnectSpec struct { // AdditionalConfig allows appending arbitrary gcfg config content to the generated config. // +optional AdditionalConfig string `json:"additionalConfig,omitempty"` + + // AuthenticatedRepos enables PPM authenticated repository access for Connect + // +optional + AuthenticatedRepos bool `json:"authenticatedRepos,omitempty"` } type DatabaseSettings struct { @@ -435,6 +451,10 @@ type InternalWorkbenchSpec struct { // Keys are config file names (e.g., "rsession.conf", "repos.conf"). // +optional AdditionalSessionConfigs map[string]string `json:"additionalSessionConfigs,omitempty"` + + // AuthenticatedRepos enables PPM authenticated repository access for Workbench + // +optional + AuthenticatedRepos bool `json:"authenticatedRepos,omitempty"` } type InternalWorkbenchExperimentalFeatures struct { diff --git a/api/core/v1beta1/workbench_types.go b/api/core/v1beta1/workbench_types.go index 00cf0dbe..ec5df4fc 100644 --- a/api/core/v1beta1/workbench_types.go +++ b/api/core/v1beta1/workbench_types.go @@ -109,6 +109,18 @@ type WorkbenchSpec struct { // Empty or whitespace-only content will be ignored. // See: https://docs.posit.co/ide/server-pro/admin/authenticating_users/customizing_signin.html AuthLoginPageHtml string `json:"authLoginPageHtml,omitempty"` + + // AuthenticatedRepos enables PPM authenticated repository access for Workbench + // +optional + AuthenticatedRepos bool `json:"authenticatedRepos,omitempty"` + + // PPMAuthImage specifies the container image for PPM auth init/sidecar containers + // +optional + PPMAuthImage string `json:"ppmAuthImage,omitempty"` + + // PPMUrl specifies the PPM URL for authenticated repository access + // +optional + PPMUrl string `json:"ppmUrl,omitempty"` } // TODO: Validation should require Volume definition for off-host-execution... diff --git a/api/core/v1beta1/zz_generated.deepcopy.go b/api/core/v1beta1/zz_generated.deepcopy.go index dc0775bd..bef44048 100644 --- a/api/core/v1beta1/zz_generated.deepcopy.go +++ b/api/core/v1beta1/zz_generated.deepcopy.go @@ -1302,6 +1302,11 @@ func (in *InternalPackageManagerSpec) DeepCopyInto(out *InternalPackageManagerSp *out = new(AzureFilesConfig) **out = **in } + if in.Auth != nil { + in, out := &in.Auth, &out.Auth + *out = new(AuthSpec) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InternalPackageManagerSpec. @@ -1615,6 +1620,16 @@ func (in *PackageManagerConfig) DeepCopyInto(out *PackageManagerConfig) { *out = new(PackageManagerDebugConfig) **out = **in } + if in.OpenIDConnect != nil { + in, out := &in.OpenIDConnect, &out.OpenIDConnect + *out = new(PackageManagerOIDCConfig) + **out = **in + } + if in.IdentityFederation != nil { + in, out := &in.IdentityFederation, &out.IdentityFederation + *out = make([]PackageManagerIdentityFederationConfig, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PackageManagerConfig. @@ -1687,6 +1702,21 @@ func (in *PackageManagerHttpConfig) DeepCopy() *PackageManagerHttpConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PackageManagerIdentityFederationConfig) DeepCopyInto(out *PackageManagerIdentityFederationConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PackageManagerIdentityFederationConfig. +func (in *PackageManagerIdentityFederationConfig) DeepCopy() *PackageManagerIdentityFederationConfig { + if in == nil { + return nil + } + out := new(PackageManagerIdentityFederationConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PackageManagerList) DeepCopyInto(out *PackageManagerList) { *out = *in @@ -1734,6 +1764,21 @@ func (in *PackageManagerMetricsConfig) DeepCopy() *PackageManagerMetricsConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PackageManagerOIDCConfig) DeepCopyInto(out *PackageManagerOIDCConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PackageManagerOIDCConfig. +func (in *PackageManagerOIDCConfig) DeepCopy() *PackageManagerOIDCConfig { + if in == nil { + return nil + } + out := new(PackageManagerOIDCConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PackageManagerPostgresConfig) DeepCopyInto(out *PackageManagerPostgresConfig) { *out = *in diff --git a/client-go/applyconfiguration/core/v1beta1/connectspec.go b/client-go/applyconfiguration/core/v1beta1/connectspec.go index 6cd407ff..2913b92b 100644 --- a/client-go/applyconfiguration/core/v1beta1/connectspec.go +++ b/client-go/applyconfiguration/core/v1beta1/connectspec.go @@ -46,6 +46,9 @@ type ConnectSpecApplyConfiguration struct { Replicas *int `json:"replicas,omitempty"` DsnSecret *string `json:"dsnSecret,omitempty"` ChronicleSidecarProductApiKeyEnabled *bool `json:"chronicleSidecarProductApiKeyEnabled,omitempty"` + AuthenticatedRepos *bool `json:"authenticatedRepos,omitempty"` + PPMAuthImage *string `json:"ppmAuthImage,omitempty"` + PPMUrl *string `json:"ppmUrl,omitempty"` } // ConnectSpecApplyConfiguration constructs a declarative configuration of the ConnectSpec type for use with @@ -344,3 +347,27 @@ func (b *ConnectSpecApplyConfiguration) WithChronicleSidecarProductApiKeyEnabled b.ChronicleSidecarProductApiKeyEnabled = &value return b } + +// WithAuthenticatedRepos sets the AuthenticatedRepos field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the AuthenticatedRepos field is set to the value of the last call. +func (b *ConnectSpecApplyConfiguration) WithAuthenticatedRepos(value bool) *ConnectSpecApplyConfiguration { + b.AuthenticatedRepos = &value + return b +} + +// WithPPMAuthImage sets the PPMAuthImage field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the PPMAuthImage field is set to the value of the last call. +func (b *ConnectSpecApplyConfiguration) WithPPMAuthImage(value string) *ConnectSpecApplyConfiguration { + b.PPMAuthImage = &value + return b +} + +// WithPPMUrl sets the PPMUrl field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the PPMUrl field is set to the value of the last call. +func (b *ConnectSpecApplyConfiguration) WithPPMUrl(value string) *ConnectSpecApplyConfiguration { + b.PPMUrl = &value + return b +} diff --git a/client-go/applyconfiguration/core/v1beta1/internalconnectspec.go b/client-go/applyconfiguration/core/v1beta1/internalconnectspec.go index 94629195..e8bf6502 100644 --- a/client-go/applyconfiguration/core/v1beta1/internalconnectspec.go +++ b/client-go/applyconfiguration/core/v1beta1/internalconnectspec.go @@ -36,6 +36,7 @@ type InternalConnectSpecApplyConfiguration struct { ScheduleConcurrency *int `json:"scheduleConcurrency,omitempty"` AdditionalRuntimeImages []ConnectRuntimeImageSpecApplyConfiguration `json:"additionalRuntimeImages,omitempty"` AdditionalConfig *string `json:"additionalConfig,omitempty"` + AuthenticatedRepos *bool `json:"authenticatedRepos,omitempty"` } // InternalConnectSpecApplyConfiguration constructs a declarative configuration of the InternalConnectSpec type for use with @@ -244,3 +245,11 @@ func (b *InternalConnectSpecApplyConfiguration) WithAdditionalConfig(value strin b.AdditionalConfig = &value return b } + +// WithAuthenticatedRepos sets the AuthenticatedRepos field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the AuthenticatedRepos field is set to the value of the last call. +func (b *InternalConnectSpecApplyConfiguration) WithAuthenticatedRepos(value bool) *InternalConnectSpecApplyConfiguration { + b.AuthenticatedRepos = &value + return b +} diff --git a/client-go/applyconfiguration/core/v1beta1/internalpackagemanagerspec.go b/client-go/applyconfiguration/core/v1beta1/internalpackagemanagerspec.go index c9e12970..16a5df92 100644 --- a/client-go/applyconfiguration/core/v1beta1/internalpackagemanagerspec.go +++ b/client-go/applyconfiguration/core/v1beta1/internalpackagemanagerspec.go @@ -13,19 +13,21 @@ import ( // InternalPackageManagerSpecApplyConfiguration represents a declarative configuration of the InternalPackageManagerSpec type for use // with apply. type InternalPackageManagerSpecApplyConfiguration struct { - License *product.LicenseSpec `json:"license,omitempty"` - Volume *product.VolumeSpec `json:"volume,omitempty"` - NodeSelector map[string]string `json:"nodeSelector,omitempty"` - AddEnv map[string]string `json:"addEnv,omitempty"` - Image *string `json:"image,omitempty"` - ImagePullPolicy *v1.PullPolicy `json:"imagePullPolicy,omitempty"` - S3Bucket *string `json:"s3Bucket,omitempty"` - Replicas *int `json:"replicas,omitempty"` - DomainPrefix *string `json:"domainPrefix,omitempty"` - BaseDomain *string `json:"baseDomain,omitempty"` - GitSSHKeys []SSHKeyConfigApplyConfiguration `json:"gitSSHKeys,omitempty"` - AzureFiles *AzureFilesConfigApplyConfiguration `json:"azureFiles,omitempty"` - AdditionalConfig *string `json:"additionalConfig,omitempty"` + License *product.LicenseSpec `json:"license,omitempty"` + Volume *product.VolumeSpec `json:"volume,omitempty"` + NodeSelector map[string]string `json:"nodeSelector,omitempty"` + AddEnv map[string]string `json:"addEnv,omitempty"` + Image *string `json:"image,omitempty"` + ImagePullPolicy *v1.PullPolicy `json:"imagePullPolicy,omitempty"` + S3Bucket *string `json:"s3Bucket,omitempty"` + Replicas *int `json:"replicas,omitempty"` + DomainPrefix *string `json:"domainPrefix,omitempty"` + BaseDomain *string `json:"baseDomain,omitempty"` + GitSSHKeys []SSHKeyConfigApplyConfiguration `json:"gitSSHKeys,omitempty"` + AzureFiles *AzureFilesConfigApplyConfiguration `json:"azureFiles,omitempty"` + AdditionalConfig *string `json:"additionalConfig,omitempty"` + Auth *AuthSpecApplyConfiguration `json:"auth,omitempty"` + OIDCClientSecretKey *string `json:"oidcClientSecretKey,omitempty"` } // InternalPackageManagerSpecApplyConfiguration constructs a declarative configuration of the InternalPackageManagerSpec type for use with @@ -154,3 +156,19 @@ func (b *InternalPackageManagerSpecApplyConfiguration) WithAdditionalConfig(valu b.AdditionalConfig = &value return b } + +// WithAuth sets the Auth field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Auth field is set to the value of the last call. +func (b *InternalPackageManagerSpecApplyConfiguration) WithAuth(value *AuthSpecApplyConfiguration) *InternalPackageManagerSpecApplyConfiguration { + b.Auth = value + return b +} + +// WithOIDCClientSecretKey sets the OIDCClientSecretKey field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the OIDCClientSecretKey field is set to the value of the last call. +func (b *InternalPackageManagerSpecApplyConfiguration) WithOIDCClientSecretKey(value string) *InternalPackageManagerSpecApplyConfiguration { + b.OIDCClientSecretKey = &value + return b +} diff --git a/client-go/applyconfiguration/core/v1beta1/internalworkbenchspec.go b/client-go/applyconfiguration/core/v1beta1/internalworkbenchspec.go index 1776cfc0..2e1b0cfe 100644 --- a/client-go/applyconfiguration/core/v1beta1/internalworkbenchspec.go +++ b/client-go/applyconfiguration/core/v1beta1/internalworkbenchspec.go @@ -47,6 +47,7 @@ type InternalWorkbenchSpecApplyConfiguration struct { JupyterConfig *WorkbenchJupyterConfigApplyConfiguration `json:"jupyterConfig,omitempty"` AdditionalConfigs map[string]string `json:"additionalConfigs,omitempty"` AdditionalSessionConfigs map[string]string `json:"additionalSessionConfigs,omitempty"` + AuthenticatedRepos *bool `json:"authenticatedRepos,omitempty"` } // InternalWorkbenchSpecApplyConfiguration constructs a declarative configuration of the InternalWorkbenchSpec type for use with @@ -368,3 +369,11 @@ func (b *InternalWorkbenchSpecApplyConfiguration) WithAdditionalSessionConfigs(e } return b } + +// WithAuthenticatedRepos sets the AuthenticatedRepos field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the AuthenticatedRepos field is set to the value of the last call. +func (b *InternalWorkbenchSpecApplyConfiguration) WithAuthenticatedRepos(value bool) *InternalWorkbenchSpecApplyConfiguration { + b.AuthenticatedRepos = &value + return b +} diff --git a/client-go/applyconfiguration/core/v1beta1/packagemanagerconfig.go b/client-go/applyconfiguration/core/v1beta1/packagemanagerconfig.go index c853572d..fcb09438 100644 --- a/client-go/applyconfiguration/core/v1beta1/packagemanagerconfig.go +++ b/client-go/applyconfiguration/core/v1beta1/packagemanagerconfig.go @@ -19,6 +19,7 @@ type PackageManagerConfigApplyConfiguration struct { Repos *PackageManagerReposConfigApplyConfiguration `json:"Repos,omitempty"` Cran *PackageManagerCRANConfigApplyConfiguration `json:"CRAN,omitempty"` Debug *PackageManagerDebugConfigApplyConfiguration `json:"Debug,omitempty"` + OpenIDConnect *PackageManagerOIDCConfigApplyConfiguration `json:"OpenIDConnect,omitempty"` AdditionalConfig *string `json:"additionalConfig,omitempty"` } @@ -116,6 +117,14 @@ func (b *PackageManagerConfigApplyConfiguration) WithDebug(value *PackageManager return b } +// WithOpenIDConnect sets the OpenIDConnect field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the OpenIDConnect field is set to the value of the last call. +func (b *PackageManagerConfigApplyConfiguration) WithOpenIDConnect(value *PackageManagerOIDCConfigApplyConfiguration) *PackageManagerConfigApplyConfiguration { + b.OpenIDConnect = value + return b +} + // WithAdditionalConfig sets the AdditionalConfig field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the AdditionalConfig field is set to the value of the last call. diff --git a/client-go/applyconfiguration/core/v1beta1/packagemanageridentityfederationconfig.go b/client-go/applyconfiguration/core/v1beta1/packagemanageridentityfederationconfig.go new file mode 100644 index 00000000..5bf36ae5 --- /dev/null +++ b/client-go/applyconfiguration/core/v1beta1/packagemanageridentityfederationconfig.go @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2023-2026 Posit Software, PBC + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1beta1 + +// PackageManagerIdentityFederationConfigApplyConfiguration represents a declarative configuration of the PackageManagerIdentityFederationConfig type for use +// with apply. +type PackageManagerIdentityFederationConfigApplyConfiguration struct { + Issuer *string `json:"Issuer,omitempty"` + Audience *string `json:"Audience,omitempty"` + Subject *string `json:"Subject,omitempty"` + AuthorizedParty *string `json:"AuthorizedParty,omitempty"` + Scope *string `json:"Scope,omitempty"` + TokenLifetime *string `json:"TokenLifetime,omitempty"` +} + +// PackageManagerIdentityFederationConfigApplyConfiguration constructs a declarative configuration of the PackageManagerIdentityFederationConfig type for use with +// apply. +func PackageManagerIdentityFederationConfig() *PackageManagerIdentityFederationConfigApplyConfiguration { + return &PackageManagerIdentityFederationConfigApplyConfiguration{} +} + +// WithIssuer sets the Issuer field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Issuer field is set to the value of the last call. +func (b *PackageManagerIdentityFederationConfigApplyConfiguration) WithIssuer(value string) *PackageManagerIdentityFederationConfigApplyConfiguration { + b.Issuer = &value + return b +} + +// WithAudience sets the Audience field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Audience field is set to the value of the last call. +func (b *PackageManagerIdentityFederationConfigApplyConfiguration) WithAudience(value string) *PackageManagerIdentityFederationConfigApplyConfiguration { + b.Audience = &value + return b +} + +// WithSubject sets the Subject field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Subject field is set to the value of the last call. +func (b *PackageManagerIdentityFederationConfigApplyConfiguration) WithSubject(value string) *PackageManagerIdentityFederationConfigApplyConfiguration { + b.Subject = &value + return b +} + +// WithAuthorizedParty sets the AuthorizedParty field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the AuthorizedParty field is set to the value of the last call. +func (b *PackageManagerIdentityFederationConfigApplyConfiguration) WithAuthorizedParty(value string) *PackageManagerIdentityFederationConfigApplyConfiguration { + b.AuthorizedParty = &value + return b +} + +// WithScope sets the Scope field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Scope field is set to the value of the last call. +func (b *PackageManagerIdentityFederationConfigApplyConfiguration) WithScope(value string) *PackageManagerIdentityFederationConfigApplyConfiguration { + b.Scope = &value + return b +} + +// WithTokenLifetime sets the TokenLifetime field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the TokenLifetime field is set to the value of the last call. +func (b *PackageManagerIdentityFederationConfigApplyConfiguration) WithTokenLifetime(value string) *PackageManagerIdentityFederationConfigApplyConfiguration { + b.TokenLifetime = &value + return b +} diff --git a/client-go/applyconfiguration/core/v1beta1/packagemanageroidcconfig.go b/client-go/applyconfiguration/core/v1beta1/packagemanageroidcconfig.go new file mode 100644 index 00000000..8147332e --- /dev/null +++ b/client-go/applyconfiguration/core/v1beta1/packagemanageroidcconfig.go @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2023-2026 Posit Software, PBC + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1beta1 + +// PackageManagerOIDCConfigApplyConfiguration represents a declarative configuration of the PackageManagerOIDCConfig type for use +// with apply. +type PackageManagerOIDCConfigApplyConfiguration struct { + ClientId *string `json:"ClientId,omitempty"` + ClientSecret *string `json:"ClientSecret,omitempty"` + Issuer *string `json:"Issuer,omitempty"` + RequireLogin *bool `json:"RequireLogin,omitempty"` + Scope *string `json:"Scope,omitempty"` + GroupsClaim *string `json:"GroupsClaim,omitempty"` + RoleClaim *string `json:"RoleClaim,omitempty"` +} + +// PackageManagerOIDCConfigApplyConfiguration constructs a declarative configuration of the PackageManagerOIDCConfig type for use with +// apply. +func PackageManagerOIDCConfig() *PackageManagerOIDCConfigApplyConfiguration { + return &PackageManagerOIDCConfigApplyConfiguration{} +} + +// WithClientId sets the ClientId field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ClientId field is set to the value of the last call. +func (b *PackageManagerOIDCConfigApplyConfiguration) WithClientId(value string) *PackageManagerOIDCConfigApplyConfiguration { + b.ClientId = &value + return b +} + +// WithClientSecret sets the ClientSecret field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ClientSecret field is set to the value of the last call. +func (b *PackageManagerOIDCConfigApplyConfiguration) WithClientSecret(value string) *PackageManagerOIDCConfigApplyConfiguration { + b.ClientSecret = &value + return b +} + +// WithIssuer sets the Issuer field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Issuer field is set to the value of the last call. +func (b *PackageManagerOIDCConfigApplyConfiguration) WithIssuer(value string) *PackageManagerOIDCConfigApplyConfiguration { + b.Issuer = &value + return b +} + +// WithRequireLogin sets the RequireLogin field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the RequireLogin field is set to the value of the last call. +func (b *PackageManagerOIDCConfigApplyConfiguration) WithRequireLogin(value bool) *PackageManagerOIDCConfigApplyConfiguration { + b.RequireLogin = &value + return b +} + +// WithScope sets the Scope field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Scope field is set to the value of the last call. +func (b *PackageManagerOIDCConfigApplyConfiguration) WithScope(value string) *PackageManagerOIDCConfigApplyConfiguration { + b.Scope = &value + return b +} + +// WithGroupsClaim sets the GroupsClaim field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the GroupsClaim field is set to the value of the last call. +func (b *PackageManagerOIDCConfigApplyConfiguration) WithGroupsClaim(value string) *PackageManagerOIDCConfigApplyConfiguration { + b.GroupsClaim = &value + return b +} + +// WithRoleClaim sets the RoleClaim field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the RoleClaim field is set to the value of the last call. +func (b *PackageManagerOIDCConfigApplyConfiguration) WithRoleClaim(value string) *PackageManagerOIDCConfigApplyConfiguration { + b.RoleClaim = &value + return b +} diff --git a/client-go/applyconfiguration/core/v1beta1/packagemanagerspec.go b/client-go/applyconfiguration/core/v1beta1/packagemanagerspec.go index 742eb42b..4df5b1a1 100644 --- a/client-go/applyconfiguration/core/v1beta1/packagemanagerspec.go +++ b/client-go/applyconfiguration/core/v1beta1/packagemanagerspec.go @@ -37,6 +37,7 @@ type PackageManagerSpecApplyConfiguration struct { Replicas *int `json:"replicas,omitempty"` GitSSHKeys []SSHKeyConfigApplyConfiguration `json:"gitSSHKeys,omitempty"` AzureFiles *AzureFilesConfigApplyConfiguration `json:"azureFiles,omitempty"` + OIDCClientSecretKey *string `json:"oidcClientSecretKey,omitempty"` } // PackageManagerSpecApplyConfiguration constructs a declarative configuration of the PackageManagerSpec type for use with @@ -261,3 +262,11 @@ func (b *PackageManagerSpecApplyConfiguration) WithAzureFiles(value *AzureFilesC b.AzureFiles = value return b } + +// WithOIDCClientSecretKey sets the OIDCClientSecretKey field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the OIDCClientSecretKey field is set to the value of the last call. +func (b *PackageManagerSpecApplyConfiguration) WithOIDCClientSecretKey(value string) *PackageManagerSpecApplyConfiguration { + b.OIDCClientSecretKey = &value + return b +} diff --git a/client-go/applyconfiguration/core/v1beta1/sitespec.go b/client-go/applyconfiguration/core/v1beta1/sitespec.go index b13749bf..962a0a4b 100644 --- a/client-go/applyconfiguration/core/v1beta1/sitespec.go +++ b/client-go/applyconfiguration/core/v1beta1/sitespec.go @@ -43,6 +43,7 @@ type SiteSpecApplyConfiguration struct { EFSEnabled *bool `json:"efsEnabled,omitempty"` VPCCIDR *string `json:"vpcCIDR,omitempty"` EnableFQDNHealthChecks *bool `json:"enableFqdnHealthChecks,omitempty"` + OIDCIssuerURL *string `json:"oidcIssuerUrl,omitempty"` } // SiteSpecApplyConfiguration constructs a declarative configuration of the SiteSpec type for use with @@ -303,3 +304,11 @@ func (b *SiteSpecApplyConfiguration) WithEnableFQDNHealthChecks(value bool) *Sit b.EnableFQDNHealthChecks = &value return b } + +// WithOIDCIssuerURL sets the OIDCIssuerURL field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the OIDCIssuerURL field is set to the value of the last call. +func (b *SiteSpecApplyConfiguration) WithOIDCIssuerURL(value string) *SiteSpecApplyConfiguration { + b.OIDCIssuerURL = &value + return b +} diff --git a/client-go/applyconfiguration/core/v1beta1/workbenchspec.go b/client-go/applyconfiguration/core/v1beta1/workbenchspec.go index e7ce73e5..2725cbd7 100644 --- a/client-go/applyconfiguration/core/v1beta1/workbenchspec.go +++ b/client-go/applyconfiguration/core/v1beta1/workbenchspec.go @@ -47,6 +47,9 @@ type WorkbenchSpecApplyConfiguration struct { DsnSecret *string `json:"dsnSecret,omitempty"` ChronicleSidecarProductApiKeyEnabled *bool `json:"chronicleSidecarProductApiKeyEnabled,omitempty"` AuthLoginPageHtml *string `json:"authLoginPageHtml,omitempty"` + AuthenticatedRepos *bool `json:"authenticatedRepos,omitempty"` + PPMAuthImage *string `json:"ppmAuthImage,omitempty"` + PPMUrl *string `json:"ppmUrl,omitempty"` } // WorkbenchSpecApplyConfiguration constructs a declarative configuration of the WorkbenchSpec type for use with @@ -350,3 +353,27 @@ func (b *WorkbenchSpecApplyConfiguration) WithAuthLoginPageHtml(value string) *W b.AuthLoginPageHtml = &value return b } + +// WithAuthenticatedRepos sets the AuthenticatedRepos field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the AuthenticatedRepos field is set to the value of the last call. +func (b *WorkbenchSpecApplyConfiguration) WithAuthenticatedRepos(value bool) *WorkbenchSpecApplyConfiguration { + b.AuthenticatedRepos = &value + return b +} + +// WithPPMAuthImage sets the PPMAuthImage field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the PPMAuthImage field is set to the value of the last call. +func (b *WorkbenchSpecApplyConfiguration) WithPPMAuthImage(value string) *WorkbenchSpecApplyConfiguration { + b.PPMAuthImage = &value + return b +} + +// WithPPMUrl sets the PPMUrl field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the PPMUrl field is set to the value of the last call. +func (b *WorkbenchSpecApplyConfiguration) WithPPMUrl(value string) *WorkbenchSpecApplyConfiguration { + b.PPMUrl = &value + return b +} diff --git a/client-go/applyconfiguration/utils.go b/client-go/applyconfiguration/utils.go index bba2e5a1..52bb4299 100644 --- a/client-go/applyconfiguration/utils.go +++ b/client-go/applyconfiguration/utils.go @@ -137,8 +137,12 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &corev1beta1.PackageManagerGitConfigApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("PackageManagerHttpConfig"): return &corev1beta1.PackageManagerHttpConfigApplyConfiguration{} + case v1beta1.SchemeGroupVersion.WithKind("PackageManagerIdentityFederationConfig"): + return &corev1beta1.PackageManagerIdentityFederationConfigApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("PackageManagerMetricsConfig"): return &corev1beta1.PackageManagerMetricsConfigApplyConfiguration{} + case v1beta1.SchemeGroupVersion.WithKind("PackageManagerOIDCConfig"): + return &corev1beta1.PackageManagerOIDCConfigApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("PackageManagerPostgresConfig"): return &corev1beta1.PackageManagerPostgresConfigApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("PackageManagerReposConfig"): diff --git a/config/crd/bases/core.posit.team_connects.yaml b/config/crd/bases/core.posit.team_connects.yaml index 94495a6e..5c0eef57 100644 --- a/config/crd/bases/core.posit.team_connects.yaml +++ b/config/crd/bases/core.posit.team_connects.yaml @@ -178,6 +178,10 @@ spec: type: string type: array type: object + authenticatedRepos: + description: AuthenticatedRepos enables PPM authenticated repository + access for Connect + type: boolean awsAccountId: description: AwsAccountId is the account Id for this AWS Account. It is used to create EKS-to-IAM annotations @@ -500,6 +504,14 @@ spec: type: object offHostExecution: type: boolean + ppmAuthImage: + description: PPMAuthImage specifies the container image for PPM auth + init/sidecar containers + type: string + ppmUrl: + description: PPMUrl specifies the PPM URL for authenticated repository + access + type: string registerOnFirstLogin: description: |- RegisterOnFirstLogin controls whether new users are automatically registered diff --git a/config/crd/bases/core.posit.team_packagemanagers.yaml b/config/crd/bases/core.posit.team_packagemanagers.yaml index 72619fa8..14b827c1 100644 --- a/config/crd/bases/core.posit.team_packagemanagers.yaml +++ b/config/crd/bases/core.posit.team_packagemanagers.yaml @@ -107,6 +107,23 @@ spec: Enabled: type: boolean type: object + OpenIDConnect: + properties: + ClientId: + type: string + ClientSecret: + type: string + GroupsClaim: + type: string + Issuer: + type: string + RequireLogin: + type: boolean + RoleClaim: + type: string + Scope: + type: string + type: object Postgres: properties: URL: @@ -304,6 +321,11 @@ spec: additionalProperties: type: string type: object + oidcClientSecretKey: + description: |- + OIDCClientSecretKey is the key name in the vault for the OIDC client secret. + When set, the client secret will be mounted at /etc/rstudio-pm/oidc-client-secret + type: string replicas: type: integer secret: diff --git a/config/crd/bases/core.posit.team_sites.yaml b/config/crd/bases/core.posit.team_sites.yaml index 647b764b..c9bc3a2a 100644 --- a/config/crd/bases/core.posit.team_sites.yaml +++ b/config/crd/bases/core.posit.team_sites.yaml @@ -168,6 +168,10 @@ spec: type: string type: array type: object + authenticatedRepos: + description: AuthenticatedRepos enables PPM authenticated repository + access for Connect + type: boolean baseDomain: description: |- BaseDomain overrides site.Spec.Domain for this product's URL construction. @@ -622,6 +626,10 @@ spec: maximum: 100 minimum: 0 type: integer + oidcIssuerUrl: + description: OIDCIssuerURL is the K8s cluster OIDC issuer URL (for + EKS/AKS Identity Federation) + type: string packageManager: description: PackageManager contains Posit Package Manager configuration properties: @@ -633,6 +641,59 @@ spec: description: AdditionalConfig allows appending arbitrary gcfg config content to the generated config. type: string + auth: + description: Auth configures OIDC authentication for Package Manager's + web UI + properties: + administratorRoleMapping: + items: + type: string + type: array + clientId: + type: string + disableGroupsClaim: + type: boolean + emailClaim: + type: string + groups: + type: boolean + groupsClaim: + type: string + issuer: + type: string + publisherRoleMapping: + items: + type: string + type: array + samlEmailAttribute: + type: string + samlFirstNameAttribute: + type: string + samlIdPAttributeProfile: + description: SAML-specific attribute mappings (mutually exclusive + with SamlIdPAttributeProfile) + type: string + samlLastNameAttribute: + type: string + samlMetadataUrl: + type: string + samlUsernameAttribute: + type: string + scopes: + items: + type: string + type: array + type: + type: string + uniqueIdClaim: + type: string + usernameClaim: + type: string + viewerRoleMapping: + items: + type: string + type: array + type: object azureFiles: description: AzureFiles configures Azure Files integration for persistent storage @@ -762,6 +823,10 @@ spec: additionalProperties: type: string type: object + oidcClientSecretKey: + description: OIDCClientSecretKey is the key in the vault for the + OIDC client secret + type: string replicas: type: integer s3Bucket: @@ -1056,6 +1121,10 @@ spec: authLoginPageHtml: description: Workbench Auth/Login Landing Page Customization HTML type: string + authenticatedRepos: + description: AuthenticatedRepos enables PPM authenticated repository + access for Workbench + type: boolean baseDomain: description: |- BaseDomain overrides site.Spec.Domain for this product's URL construction. diff --git a/config/crd/bases/core.posit.team_workbenches.yaml b/config/crd/bases/core.posit.team_workbenches.yaml index d047931a..e59ed08c 100644 --- a/config/crd/bases/core.posit.team_workbenches.yaml +++ b/config/crd/bases/core.posit.team_workbenches.yaml @@ -151,6 +151,10 @@ spec: Empty or whitespace-only content will be ignored. See: https://docs.posit.co/ide/server-pro/admin/authenticating_users/customizing_signin.html type: string + authenticatedRepos: + description: AuthenticatedRepos enables PPM authenticated repository + access for Workbench + type: boolean awsAccountId: description: AwsAccountId is the account Id for this AWS Account. It is used to create EKS-to-IAM annotations @@ -691,6 +695,14 @@ spec: type: boolean parentUrl: type: string + ppmAuthImage: + description: PPMAuthImage specifies the container image for PPM auth + init/sidecar containers + type: string + ppmUrl: + description: PPMUrl specifies the PPM URL for authenticated repository + access + type: string replicas: type: integer secret: diff --git a/dist/chart/templates/crd/core.posit.team_connects.yaml b/dist/chart/templates/crd/core.posit.team_connects.yaml index 1a5664de..d0ae7846 100755 --- a/dist/chart/templates/crd/core.posit.team_connects.yaml +++ b/dist/chart/templates/crd/core.posit.team_connects.yaml @@ -199,6 +199,10 @@ spec: type: string type: array type: object + authenticatedRepos: + description: AuthenticatedRepos enables PPM authenticated repository + access for Connect + type: boolean awsAccountId: description: AwsAccountId is the account Id for this AWS Account. It is used to create EKS-to-IAM annotations @@ -521,6 +525,14 @@ spec: type: object offHostExecution: type: boolean + ppmAuthImage: + description: PPMAuthImage specifies the container image for PPM auth + init/sidecar containers + type: string + ppmUrl: + description: PPMUrl specifies the PPM URL for authenticated repository + access + type: string registerOnFirstLogin: description: |- RegisterOnFirstLogin controls whether new users are automatically registered diff --git a/dist/chart/templates/crd/core.posit.team_packagemanagers.yaml b/dist/chart/templates/crd/core.posit.team_packagemanagers.yaml index 323d55ae..baddc971 100755 --- a/dist/chart/templates/crd/core.posit.team_packagemanagers.yaml +++ b/dist/chart/templates/crd/core.posit.team_packagemanagers.yaml @@ -128,6 +128,23 @@ spec: Enabled: type: boolean type: object + OpenIDConnect: + properties: + ClientId: + type: string + ClientSecret: + type: string + GroupsClaim: + type: string + Issuer: + type: string + RequireLogin: + type: boolean + RoleClaim: + type: string + Scope: + type: string + type: object Postgres: properties: URL: @@ -325,6 +342,11 @@ spec: additionalProperties: type: string type: object + oidcClientSecretKey: + description: |- + OIDCClientSecretKey is the key name in the vault for the OIDC client secret. + When set, the client secret will be mounted at /etc/rstudio-pm/oidc-client-secret + type: string replicas: type: integer secret: diff --git a/dist/chart/templates/crd/core.posit.team_sites.yaml b/dist/chart/templates/crd/core.posit.team_sites.yaml index e0bd60ea..0eda9c5b 100755 --- a/dist/chart/templates/crd/core.posit.team_sites.yaml +++ b/dist/chart/templates/crd/core.posit.team_sites.yaml @@ -189,6 +189,10 @@ spec: type: string type: array type: object + authenticatedRepos: + description: AuthenticatedRepos enables PPM authenticated repository + access for Connect + type: boolean baseDomain: description: |- BaseDomain overrides site.Spec.Domain for this product's URL construction. @@ -643,6 +647,10 @@ spec: maximum: 100 minimum: 0 type: integer + oidcIssuerUrl: + description: OIDCIssuerURL is the K8s cluster OIDC issuer URL (for + EKS/AKS Identity Federation) + type: string packageManager: description: PackageManager contains Posit Package Manager configuration properties: @@ -654,6 +662,59 @@ spec: description: AdditionalConfig allows appending arbitrary gcfg config content to the generated config. type: string + auth: + description: Auth configures OIDC authentication for Package Manager's + web UI + properties: + administratorRoleMapping: + items: + type: string + type: array + clientId: + type: string + disableGroupsClaim: + type: boolean + emailClaim: + type: string + groups: + type: boolean + groupsClaim: + type: string + issuer: + type: string + publisherRoleMapping: + items: + type: string + type: array + samlEmailAttribute: + type: string + samlFirstNameAttribute: + type: string + samlIdPAttributeProfile: + description: SAML-specific attribute mappings (mutually exclusive + with SamlIdPAttributeProfile) + type: string + samlLastNameAttribute: + type: string + samlMetadataUrl: + type: string + samlUsernameAttribute: + type: string + scopes: + items: + type: string + type: array + type: + type: string + uniqueIdClaim: + type: string + usernameClaim: + type: string + viewerRoleMapping: + items: + type: string + type: array + type: object azureFiles: description: AzureFiles configures Azure Files integration for persistent storage @@ -783,6 +844,10 @@ spec: additionalProperties: type: string type: object + oidcClientSecretKey: + description: OIDCClientSecretKey is the key in the vault for the + OIDC client secret + type: string replicas: type: integer s3Bucket: @@ -1077,6 +1142,10 @@ spec: authLoginPageHtml: description: Workbench Auth/Login Landing Page Customization HTML type: string + authenticatedRepos: + description: AuthenticatedRepos enables PPM authenticated repository + access for Workbench + type: boolean baseDomain: description: |- BaseDomain overrides site.Spec.Domain for this product's URL construction. diff --git a/dist/chart/templates/crd/core.posit.team_workbenches.yaml b/dist/chart/templates/crd/core.posit.team_workbenches.yaml index ff0ed92c..4b4b7e5a 100755 --- a/dist/chart/templates/crd/core.posit.team_workbenches.yaml +++ b/dist/chart/templates/crd/core.posit.team_workbenches.yaml @@ -172,6 +172,10 @@ spec: Empty or whitespace-only content will be ignored. See: https://docs.posit.co/ide/server-pro/admin/authenticating_users/customizing_signin.html type: string + authenticatedRepos: + description: AuthenticatedRepos enables PPM authenticated repository + access for Workbench + type: boolean awsAccountId: description: AwsAccountId is the account Id for this AWS Account. It is used to create EKS-to-IAM annotations @@ -712,6 +716,14 @@ spec: type: boolean parentUrl: type: string + ppmAuthImage: + description: PPMAuthImage specifies the container image for PPM auth + init/sidecar containers + type: string + ppmUrl: + description: PPMUrl specifies the PPM URL for authenticated repository + access + type: string replicas: type: integer secret: diff --git a/internal/controller/core/connect.go b/internal/controller/core/connect.go index ec507619..2c891ad1 100644 --- a/internal/controller/core/connect.go +++ b/internal/controller/core/connect.go @@ -586,6 +586,25 @@ func (r *ConnectReconciler) ensureDeployedService(ctx context.Context, req ctrl. volumeFactory := c.CreateVolumeFactory(configCopy) secretVolumeFactory := c.CreateSecretVolumeFactory(configCopy) + // PPM authenticated repos support + var ppmAuthVolumes []corev1.Volume + var ppmAuthVolumeMounts []corev1.VolumeMount + var ppmAuthEnvVars []corev1.EnvVar + var ppmAuthInitContainers []corev1.Container + var ppmAuthSidecarContainers []corev1.Container + if c.Spec.AuthenticatedRepos { + ppmURL := fmt.Sprintf("https://%s", c.Spec.PPMUrl) + ppmAuthVolumes = PPMAuthVolumes(c.SiteName(), ppmURL) + ppmAuthVolumeMounts = PPMAuthVolumeMounts() + ppmAuthEnvVars = PPMAuthEnvVars() + ppmAuthInitContainers = []corev1.Container{ + PPMAuthInitContainer(c.Spec.PPMAuthImage, ppmURL), + } + ppmAuthSidecarContainers = []corev1.Container{ + PPMAuthSidecarContainer(c.Spec.PPMAuthImage, ppmURL, ""), + } + } + var chronicleSeededEnv []corev1.EnvVar if c.Spec.ChronicleSidecarProductApiKeyEnabled { chronicleSeededEnv = []corev1.EnvVar{ @@ -631,7 +650,8 @@ func (r *ConnectReconciler) ensureDeployedService(ctx context.Context, req ctrl. ImagePullSecrets: pullSecrets, ServiceAccountName: maybeServiceAccountName, // TODO: go back to automounting service token... - AutomountServiceAccountToken: ptr.To(false), + AutomountServiceAccountToken: ptr.To(c.Spec.AuthenticatedRepos), + InitContainers: ppmAuthInitContainers, Containers: product.ConcatLists([]corev1.Container{ { Name: "connect", @@ -643,6 +663,7 @@ func (r *ConnectReconciler) ensureDeployedService(ctx context.Context, req ctrl. volumeFactory.EnvVars(), secretVolumeFactory.EnvVars(), product.StringMapToEnvVars(c.Spec.AddEnv), + ppmAuthEnvVars, []corev1.EnvVar{ { Name: "LAUNCHER_INSTANCE_ID", @@ -670,6 +691,7 @@ func (r *ConnectReconciler) ensureDeployedService(ctx context.Context, req ctrl. volumeFactory.VolumeMounts(), secretVolumeFactory.VolumeMounts(), c.TokenVolumeMounts(), + ppmAuthVolumeMounts, ), Resources: corev1.ResourceRequirements{ Requests: corev1.ResourceList{ @@ -702,6 +724,7 @@ func (r *ConnectReconciler) ensureDeployedService(ctx context.Context, req ctrl. }, }, product.ChronicleSidecar(c, chronicleSeededEnv), + ppmAuthSidecarContainers, ), Affinity: &corev1.Affinity{ PodAntiAffinity: positcov1beta1.ComponentSpecPodAntiAffinity(c, req.Namespace), @@ -713,6 +736,7 @@ func (r *ConnectReconciler) ensureDeployedService(ctx context.Context, req ctrl. volumeFactory.Volumes(), secretVolumeFactory.Volumes(), c.TokenVolumes(), + ppmAuthVolumes, ), }, }, diff --git a/internal/controller/core/package_manager.go b/internal/controller/core/package_manager.go index af2fef6a..5ff81477 100644 --- a/internal/controller/core/package_manager.go +++ b/internal/controller/core/package_manager.go @@ -272,6 +272,11 @@ func (r *PackageManagerReconciler) ensureDeployedService(ctx context.Context, re "password": "pkg-db-password", } + // Add OIDC client secret to SecretProviderClass when configured + if pm.Spec.OIDCClientSecretKey != "" { + secretRefs["oidc-client-secret"] = pm.Spec.OIDCClientSecretKey + } + if targetSpc, err := product.GetSecretProviderClassForAllSecrets( pm, pm.SecretProviderClassName(), req.Namespace, pm.Spec.Secret.VaultName, diff --git a/internal/controller/core/ppm_auth.go b/internal/controller/core/ppm_auth.go new file mode 100644 index 00000000..900c2c83 --- /dev/null +++ b/internal/controller/core/ppm_auth.go @@ -0,0 +1,227 @@ +package core + +import ( + "fmt" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + + "github.com/posit-dev/team-operator/api/product" + "github.com/rstudio/goex/ptr" +) + +const ( + ppmAuthScriptVolumeName = "ppm-auth-script" + ppmAuthTokenVolumeName = "ppm-sa-token" + ppmAuthNetrcVolumeName = "ppm-auth" + ppmAuthNetrcMountPath = "/mnt/ppm-auth" + ppmAuthNetrcPath = "/mnt/ppm-auth/netrc" + ppmAuthCurlrcPath = "/mnt/ppm-auth/.curlrc" + ppmAuthTokenMountPath = "/var/run/secrets/ppm-auth" + ppmAuthScriptMountPath = "/scripts" + ppmAuthDefaultImage = "alpine:3" + ppmAuthDefaultRefresh = "3000" // 50 minutes (for 60 min token lifetime) +) + +// PPMAuthTokenExchangeScript returns the shell script content for the token exchange +// init container and sidecar. The script exchanges a K8s service account token for a +// PPM API token via RFC 8693 token exchange, then writes a netrc file and a curlrc +// file (so R's libcurl can also authenticate via --netrc-file). +func PPMAuthTokenExchangeScript() string { + return `#!/bin/sh +set -e + +SA_TOKEN_PATH="${SA_TOKEN_PATH:-/var/run/secrets/ppm-auth/token}" +NETRC_PATH="${NETRC_PATH:-/mnt/ppm-auth/netrc}" +CURLRC_PATH="${CURLRC_PATH:-/mnt/ppm-auth/.curlrc}" +PPM_URL="${PPM_URL}" +REFRESH_INTERVAL="${REFRESH_INTERVAL:-3000}" + +exchange_token() { + SA_TOKEN=$(cat "$SA_TOKEN_PATH") + RESPONSE=$(curl -sf -X POST "${PPM_URL}/__api__/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \ + -d "subject_token=${SA_TOKEN}" \ + -d "subject_token_type=urn:ietf:params:oauth:token-type:id_token") + + PPM_TOKEN=$(echo "$RESPONSE" | jq -r '.access_token') + PPM_HOST=$(echo "$PPM_URL" | sed 's|https\?://||' | sed 's|/.*||') + + # Write netrc (atomic: write to temp, then rename) + TMPFILE=$(mktemp "${NETRC_PATH}.XXXXXX") + printf "machine %s\nlogin __token__\npassword %s\n" "$PPM_HOST" "$PPM_TOKEN" > "$TMPFILE" + mv "$TMPFILE" "$NETRC_PATH" + chmod 600 "$NETRC_PATH" + + # Write curlrc so R's libcurl uses the netrc file + printf -- "--netrc-file %s\n" "$NETRC_PATH" > "$CURLRC_PATH" + chmod 600 "$CURLRC_PATH" +} + +exchange_token + +if [ "${MODE}" = "sidecar" ]; then + while true; do + sleep "$REFRESH_INTERVAL" + exchange_token + done +fi +` +} + +// PPMAuthConfigMapName returns the name of the ConfigMap containing the token exchange script +func PPMAuthConfigMapName(siteName string) string { + return fmt.Sprintf("%s-ppm-auth-script", siteName) +} + +// PPMAuthInitContainer returns the init container spec for the PPM token exchange +func PPMAuthInitContainer(image, ppmURL string) corev1.Container { + if image == "" { + image = ppmAuthDefaultImage + } + return corev1.Container{ + Name: "ppm-auth-init", + Image: image, + Command: []string{"/scripts/token-exchange.sh"}, + Env: []corev1.EnvVar{ + {Name: "PPM_URL", Value: ppmURL}, + {Name: "MODE", Value: "init"}, + }, + VolumeMounts: ppmAuthContainerVolumeMounts(), + SecurityContext: &corev1.SecurityContext{ + RunAsNonRoot: ptr.To(true), + AllowPrivilegeEscalation: ptr.To(false), + }, + } +} + +// PPMAuthSidecarContainer returns the sidecar container spec for the PPM token refresh +func PPMAuthSidecarContainer(image, ppmURL, refreshInterval string) corev1.Container { + if image == "" { + image = ppmAuthDefaultImage + } + if refreshInterval == "" { + refreshInterval = ppmAuthDefaultRefresh + } + return corev1.Container{ + Name: "ppm-auth-sidecar", + Image: image, + Command: []string{"/scripts/token-exchange.sh"}, + Env: []corev1.EnvVar{ + {Name: "PPM_URL", Value: ppmURL}, + {Name: "MODE", Value: "sidecar"}, + {Name: "REFRESH_INTERVAL", Value: refreshInterval}, + }, + VolumeMounts: ppmAuthContainerVolumeMounts(), + SecurityContext: &corev1.SecurityContext{ + RunAsNonRoot: ptr.To(true), + AllowPrivilegeEscalation: ptr.To(false), + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("10m"), + corev1.ResourceMemory: resource.MustParse("16Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("50m"), + corev1.ResourceMemory: resource.MustParse("32Mi"), + }, + }, + } +} + +// ppmAuthContainerVolumeMounts returns the volume mounts used by both init and sidecar containers +func ppmAuthContainerVolumeMounts() []corev1.VolumeMount { + return []corev1.VolumeMount{ + { + Name: ppmAuthTokenVolumeName, + MountPath: ppmAuthTokenMountPath, + ReadOnly: true, + }, + { + Name: ppmAuthNetrcVolumeName, + MountPath: ppmAuthNetrcMountPath, + }, + { + Name: ppmAuthScriptVolumeName, + MountPath: ppmAuthScriptMountPath, + ReadOnly: true, + }, + } +} + +// PPMAuthVolumes returns the volumes needed for PPM authenticated repo access: +// 1. Projected SA token volume (for K8s Identity Federation) +// 2. Shared emptyDir for netrc file +// 3. ConfigMap volume with the token exchange script +func PPMAuthVolumes(siteName, ppmURL string) []corev1.Volume { + // Extract audience from PPM URL (the PPM URL itself is the audience for the projected token) + audience := ppmURL + + return []corev1.Volume{ + { + Name: ppmAuthTokenVolumeName, + VolumeSource: corev1.VolumeSource{ + Projected: &corev1.ProjectedVolumeSource{ + Sources: []corev1.VolumeProjection{ + { + ServiceAccountToken: &corev1.ServiceAccountTokenProjection{ + Path: "token", + ExpirationSeconds: ptr.To(int64(3600)), + Audience: audience, + }, + }, + }, + DefaultMode: ptr.To(product.MustParseOctal("0644")), + }, + }, + }, + { + Name: ppmAuthNetrcVolumeName, + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{}, + }, + }, + { + Name: ppmAuthScriptVolumeName, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: PPMAuthConfigMapName(siteName), + }, + DefaultMode: ptr.To(product.MustParseOctal("0755")), + }, + }, + }, + } +} + +// PPMAuthVolumeMounts returns the volume mounts to add to the main product container +// for accessing the netrc file written by the init/sidecar containers +func PPMAuthVolumeMounts() []corev1.VolumeMount { + return []corev1.VolumeMount{ + { + Name: ppmAuthNetrcVolumeName, + MountPath: ppmAuthNetrcMountPath, + ReadOnly: true, + }, + } +} + +// PPMAuthEnvVars returns the environment variables to add to the main product container +// for authenticated PPM repo access: +// - NETRC: tells Python/pip where to find the netrc file +// - CURL_HOME: tells R's libcurl where to find the .curlrc file (which references the netrc) +func PPMAuthEnvVars() []corev1.EnvVar { + return []corev1.EnvVar{ + { + Name: "NETRC", + Value: ppmAuthNetrcPath, + }, + { + Name: "CURL_HOME", + Value: ppmAuthNetrcMountPath, + }, + } +} diff --git a/internal/controller/core/ppm_auth_test.go b/internal/controller/core/ppm_auth_test.go new file mode 100644 index 00000000..478f52e9 --- /dev/null +++ b/internal/controller/core/ppm_auth_test.go @@ -0,0 +1,95 @@ +package core + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPPMAuthTokenExchangeScript(t *testing.T) { + script := PPMAuthTokenExchangeScript() + require.Contains(t, script, "exchange_token") + require.Contains(t, script, "curl") + require.Contains(t, script, "grant_type=urn:ietf:params:oauth:grant-type:token-exchange") + require.Contains(t, script, "sidecar") + require.Contains(t, script, "netrc") + // Verify curlrc is written for R support + require.Contains(t, script, "CURLRC_PATH") + require.Contains(t, script, "--netrc-file") + require.Contains(t, script, ".curlrc") +} + +func TestPPMAuthConfigMapName(t *testing.T) { + name := PPMAuthConfigMapName("mysite") + require.Equal(t, "mysite-ppm-auth-script", name) +} + +func TestPPMAuthInitContainer(t *testing.T) { + c := PPMAuthInitContainer("", "https://packagemanager.example.com") + require.Equal(t, "ppm-auth-init", c.Name) + require.Equal(t, "alpine:3", c.Image) + require.Len(t, c.Env, 2) + require.Equal(t, "PPM_URL", c.Env[0].Name) + require.Equal(t, "https://packagemanager.example.com", c.Env[0].Value) + require.Equal(t, "MODE", c.Env[1].Name) + require.Equal(t, "init", c.Env[1].Value) + require.Len(t, c.VolumeMounts, 3) +} + +func TestPPMAuthInitContainerCustomImage(t *testing.T) { + c := PPMAuthInitContainer("custom-image:v1", "https://ppm.example.com") + require.Equal(t, "custom-image:v1", c.Image) +} + +func TestPPMAuthSidecarContainer(t *testing.T) { + c := PPMAuthSidecarContainer("", "https://packagemanager.example.com", "") + require.Equal(t, "ppm-auth-sidecar", c.Name) + require.Equal(t, "alpine:3", c.Image) + require.Len(t, c.Env, 3) + require.Equal(t, "MODE", c.Env[1].Name) + require.Equal(t, "sidecar", c.Env[1].Value) + require.Equal(t, "REFRESH_INTERVAL", c.Env[2].Name) + require.Equal(t, "3000", c.Env[2].Value) +} + +func TestPPMAuthSidecarContainerCustomRefresh(t *testing.T) { + c := PPMAuthSidecarContainer("", "https://ppm.example.com", "1800") + require.Equal(t, "1800", c.Env[2].Value) +} + +func TestPPMAuthVolumes(t *testing.T) { + vols := PPMAuthVolumes("mysite", "https://packagemanager.example.com") + require.Len(t, vols, 3) + + // Projected SA token volume + require.Equal(t, "ppm-sa-token", vols[0].Name) + require.NotNil(t, vols[0].Projected) + require.Len(t, vols[0].Projected.Sources, 1) + require.Equal(t, "https://packagemanager.example.com", vols[0].Projected.Sources[0].ServiceAccountToken.Audience) + + // Shared emptyDir + require.Equal(t, "ppm-auth", vols[1].Name) + require.NotNil(t, vols[1].EmptyDir) + + // Script ConfigMap + require.Equal(t, "ppm-auth-script", vols[2].Name) + require.NotNil(t, vols[2].ConfigMap) + require.Equal(t, "mysite-ppm-auth-script", vols[2].ConfigMap.Name) +} + +func TestPPMAuthVolumeMounts(t *testing.T) { + mounts := PPMAuthVolumeMounts() + require.Len(t, mounts, 1) + require.Equal(t, "ppm-auth", mounts[0].Name) + require.Equal(t, "/mnt/ppm-auth", mounts[0].MountPath) + require.True(t, mounts[0].ReadOnly) +} + +func TestPPMAuthEnvVars(t *testing.T) { + envs := PPMAuthEnvVars() + require.Len(t, envs, 2) + require.Equal(t, "NETRC", envs[0].Name) + require.Equal(t, "/mnt/ppm-auth/netrc", envs[0].Value) + require.Equal(t, "CURL_HOME", envs[1].Name) + require.Equal(t, "/mnt/ppm-auth", envs[1].Value) +} diff --git a/internal/controller/core/site_controller.go b/internal/controller/core/site_controller.go index 441df348..690e4e12 100644 --- a/internal/controller/core/site_controller.go +++ b/internal/controller/core/site_controller.go @@ -308,6 +308,14 @@ func (r *SiteReconciler) reconcileResources(ctx context.Context, req ctrl.Reques workbenchAdditionalVolumes := append([]product.VolumeSpec{}, additionalVolumes...) workbenchAdditionalVolumes = append(workbenchAdditionalVolumes, site.Spec.Workbench.AdditionalVolumes...) + // PPM AUTH CONFIGMAP + if site.Spec.Connect.AuthenticatedRepos || site.Spec.Workbench.AuthenticatedRepos { + if err := r.reconcilePPMAuthConfigMap(ctx, req, site); err != nil { + l.Error(err, "error reconciling PPM auth ConfigMap") + return ctrl.Result{}, err + } + } + // CONNECT if connectEnabled { // Connect is enabled - reconcile normally @@ -473,6 +481,31 @@ func (r *SiteReconciler) cleanupResources(ctx context.Context, req ctrl.Request) return ctrl.Result{}, nil } +func (r *SiteReconciler) reconcilePPMAuthConfigMap(ctx context.Context, req ctrl.Request, site *positcov1beta1.Site) error { + l := r.GetLogger(ctx).WithValues("event", "reconcile-ppm-auth-configmap") + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: PPMAuthConfigMapName(site.Name), + Namespace: req.Namespace, + }, + } + + if _, err := internal.CreateOrUpdateResource(ctx, r.Client, r.Scheme, l, cm, site, func() error { + cm.Labels = map[string]string{ + positcov1beta1.ManagedByLabelKey: positcov1beta1.ManagedByLabelValue, + } + cm.Data = map[string]string{ + "token-exchange.sh": PPMAuthTokenExchangeScript(), + } + return nil + }); err != nil { + return err + } + + return nil +} + // SetupWithManager sets up the controller with the Manager. func (r *SiteReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). diff --git a/internal/controller/core/site_controller_connect.go b/internal/controller/core/site_controller_connect.go index cd5ef5ad..091dfe65 100644 --- a/internal/controller/core/site_controller_connect.go +++ b/internal/controller/core/site_controller_connect.go @@ -154,6 +154,8 @@ func (r *SiteReconciler) reconcileConnect( WorkloadSecret: site.Spec.WorkloadSecret, Debug: connectDebugLog, Replicas: product.PassDefaultReplicas(site.Spec.Connect.Replicas, 1), + AuthenticatedRepos: site.Spec.Connect.AuthenticatedRepos, + PPMUrl: prefixDomain(site.Spec.PackageManager.DomainPrefix, getEffectiveBaseDomain(site.Spec.PackageManager.BaseDomain, site.Spec.Domain), v1beta1.SiteSubDomain), }, } diff --git a/internal/controller/core/site_controller_package_manager.go b/internal/controller/core/site_controller_package_manager.go index bca0a645..a478be92 100644 --- a/internal/controller/core/site_controller_package_manager.go +++ b/internal/controller/core/site_controller_package_manager.go @@ -2,6 +2,7 @@ package core import ( "context" + "fmt" "github.com/posit-dev/team-operator/api/core/v1beta1" "github.com/posit-dev/team-operator/api/product" @@ -117,6 +118,47 @@ func (r *SiteReconciler) reconcilePackageManager( // Propagate additional config from Site to PackageManager pm.Spec.Config.AdditionalConfig = site.Spec.PackageManager.AdditionalConfig + // Propagate OIDC authentication configuration + if site.Spec.PackageManager.Auth != nil && site.Spec.PackageManager.Auth.Type == v1beta1.AuthTypeOidc { + pm.Spec.Config.OpenIDConnect = &v1beta1.PackageManagerOIDCConfig{ + ClientId: site.Spec.PackageManager.Auth.ClientId, + ClientSecret: "/etc/rstudio-pm/oidc-client-secret", + Issuer: site.Spec.PackageManager.Auth.Issuer, + RequireLogin: true, + } + if site.Spec.PackageManager.Auth.GroupsClaim != "" { + pm.Spec.Config.OpenIDConnect.GroupsClaim = site.Spec.PackageManager.Auth.GroupsClaim + } + // Propagate the OIDC client secret key so the volume factory can mount it + pm.Spec.OIDCClientSecretKey = site.Spec.PackageManager.OIDCClientSecretKey + } + + // Auto-configure Identity Federation entries based on product flags + var idfEntries []v1beta1.PackageManagerIdentityFederationConfig + if site.Spec.OIDCIssuerURL != "" { + if site.Spec.Connect.AuthenticatedRepos { + idfEntries = append(idfEntries, v1beta1.PackageManagerIdentityFederationConfig{ + Name: "connect", + Issuer: site.Spec.OIDCIssuerURL, + Audience: "sts.amazonaws.com", + Subject: fmt.Sprintf("system:serviceaccount:%s:%s-connect", req.Namespace, req.Name), + Scope: "repos:read:*", + }) + } + if site.Spec.Workbench.AuthenticatedRepos { + idfEntries = append(idfEntries, v1beta1.PackageManagerIdentityFederationConfig{ + Name: "workbench", + Issuer: site.Spec.OIDCIssuerURL, + Audience: "sts.amazonaws.com", + Subject: fmt.Sprintf("system:serviceaccount:%s:%s-workbench", req.Namespace, req.Name), + Scope: "repos:read:*", + }) + } + } + if len(idfEntries) > 0 { + pm.Spec.Config.IdentityFederation = idfEntries + } + return nil }); err != nil { l.Error(err, "error creating package manager instance") diff --git a/internal/controller/core/site_controller_workbench.go b/internal/controller/core/site_controller_workbench.go index 428218ef..ace7a850 100644 --- a/internal/controller/core/site_controller_workbench.go +++ b/internal/controller/core/site_controller_workbench.go @@ -256,6 +256,8 @@ func (r *SiteReconciler) reconcileWorkbench( Secret: site.Spec.Secret, WorkloadSecret: site.Spec.WorkloadSecret, Replicas: product.PassDefaultReplicas(site.Spec.Workbench.Replicas, 1), + AuthenticatedRepos: site.Spec.Workbench.AuthenticatedRepos, + PPMUrl: prefixDomain(site.Spec.PackageManager.DomainPrefix, getEffectiveBaseDomain(site.Spec.PackageManager.BaseDomain, site.Spec.Domain), v1beta1.SiteSubDomain), }, } // potentially enable experimental features diff --git a/internal/controller/core/workbench.go b/internal/controller/core/workbench.go index 721c9d5f..151457ce 100644 --- a/internal/controller/core/workbench.go +++ b/internal/controller/core/workbench.go @@ -745,6 +745,25 @@ func (r *WorkbenchReconciler) ensureDeployedService(ctx context.Context, req ctr workbenchVolumeFactory := w.CreateVolumeFactory(configCopy) workbenchSecretVolumeFactory := w.CreateSecretVolumeFactory() + // PPM authenticated repos support + var ppmAuthVolumes []corev1.Volume + var ppmAuthVolumeMounts []corev1.VolumeMount + var ppmAuthEnvVars []corev1.EnvVar + var ppmAuthInitContainers []corev1.Container + var ppmAuthSidecarContainers []corev1.Container + if w.Spec.AuthenticatedRepos { + ppmURL := fmt.Sprintf("https://%s", w.Spec.PPMUrl) + ppmAuthVolumes = PPMAuthVolumes(w.SiteName(), ppmURL) + ppmAuthVolumeMounts = PPMAuthVolumeMounts() + ppmAuthEnvVars = PPMAuthEnvVars() + ppmAuthInitContainers = []corev1.Container{ + PPMAuthInitContainer(w.Spec.PPMAuthImage, ppmURL), + } + ppmAuthSidecarContainers = []corev1.Container{ + PPMAuthSidecarContainer(w.Spec.PPMAuthImage, ppmURL, ""), + } + } + var chronicleSeededEnv []corev1.EnvVar if w.Spec.ChronicleSidecarProductApiKeyEnabled { chronicleSeededEnv = []corev1.EnvVar{ @@ -794,7 +813,10 @@ func (r *WorkbenchReconciler) ensureDeployedService(ctx context.Context, req ctr ImagePullSecrets: pullSecrets, ServiceAccountName: maybeServiceAccountName, AutomountServiceAccountToken: ptr.To(true), - InitContainers: r.buildWorkbenchInitContainers(w), + InitContainers: product.ConcatLists( + r.buildWorkbenchInitContainers(w), + ppmAuthInitContainers, + ), Containers: product.ConcatLists( []corev1.Container{ { @@ -806,6 +828,7 @@ func (r *WorkbenchReconciler) ensureDeployedService(ctx context.Context, req ctr workbenchSecretVolumeFactory.EnvVars(), chronicleFactory.EnvVars(), product.StringMapToEnvVars(w.Spec.AddEnv), + ppmAuthEnvVars, []corev1.EnvVar{ { Name: "LAUNCHER_INSTANCE_ID", @@ -835,6 +858,7 @@ func (r *WorkbenchReconciler) ensureDeployedService(ctx context.Context, req ctr workbenchSecretVolumeFactory.VolumeMounts(), chronicleFactory.VolumeMounts(), r.buildLoadBalancerVolumeMounts(w), + ppmAuthVolumeMounts, ), Resources: corev1.ResourceRequirements{ Requests: corev1.ResourceList{ @@ -866,6 +890,7 @@ func (r *WorkbenchReconciler) ensureDeployedService(ctx context.Context, req ctr }, }, chronicleFactory.Sidecars(), + ppmAuthSidecarContainers, ), Affinity: &corev1.Affinity{ PodAntiAffinity: positcov1beta1.ComponentSpecPodAntiAffinity(w, req.Namespace), @@ -879,6 +904,7 @@ func (r *WorkbenchReconciler) ensureDeployedService(ctx context.Context, req ctr workbenchSecretVolumeFactory.Volumes(), chronicleFactory.Volumes(), r.buildLoadBalancerVolumes(w), + ppmAuthVolumes, ), }, }, From 642197155e0f3bf4a5806df997cb32c08b01f100 Mon Sep 17 00:00:00 2001 From: ian-flores Date: Mon, 2 Mar 2026 14:53:07 -0800 Subject: [PATCH 02/15] Address review findings (job 691) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 8 findings addressed. Build passes and tests pass. Here's the summary: Changes: - Install curl and jq in token exchange script (`apk add --no-cache`) so alpine:3 default image works - Add null/empty token validation after jq extraction to fail fast instead of writing "null" as password - Add `OIDCAudience` field to `SiteSpec` so OIDC audience is configurable (defaults to `sts.amazonaws.com` for backwards compatibility) - Revert `AutomountServiceAccountToken` to `ptr.To(false)` in Connect — projected volume works independently - Add `PPMAuthImage` to `InternalConnectSpec` and `InternalWorkbenchSpec` and propagate from Site controllers - Add gcfg injection validation for `IdentityFederation` Name (reject `"`, `]`, newlines) - Add `cleanupPPMAuthConfigMap` to delete the ConfigMap when authenticated repos feature is disabled - Add `SanitizePPMUrl` helper to strip existing scheme before prepending `https://`, preventing double-prefix --- .../affected-repos.txt | 1 + .../edited-files.log | 4 ++++ api/core/v1beta1/package_manager_config.go | 3 +++ api/core/v1beta1/site_types.go | 13 +++++++++++++ .../core/v1beta1/internalconnectspec.go | 9 +++++++++ .../core/v1beta1/internalworkbenchspec.go | 9 +++++++++ .../applyconfiguration/core/v1beta1/sitespec.go | 9 +++++++++ config/crd/bases/core.posit.team_sites.yaml | 13 +++++++++++++ .../templates/crd/core.posit.team_sites.yaml | 13 +++++++++++++ internal/controller/core/connect.go | 4 ++-- internal/controller/core/ppm_auth.go | 16 ++++++++++++++++ internal/controller/core/ppm_auth_test.go | 10 ++++++++++ internal/controller/core/site_controller.go | 16 ++++++++++++++++ .../controller/core/site_controller_connect.go | 1 + .../core/site_controller_package_manager.go | 8 ++++++-- .../controller/core/site_controller_workbench.go | 1 + internal/controller/core/workbench.go | 2 +- 17 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 .claude/tsc-cache/3a39c930-cf12-4be4-a2f2-eae1467b53c6/affected-repos.txt create mode 100644 .claude/tsc-cache/3a39c930-cf12-4be4-a2f2-eae1467b53c6/edited-files.log diff --git a/.claude/tsc-cache/3a39c930-cf12-4be4-a2f2-eae1467b53c6/affected-repos.txt b/.claude/tsc-cache/3a39c930-cf12-4be4-a2f2-eae1467b53c6/affected-repos.txt new file mode 100644 index 00000000..eedd89b4 --- /dev/null +++ b/.claude/tsc-cache/3a39c930-cf12-4be4-a2f2-eae1467b53c6/affected-repos.txt @@ -0,0 +1 @@ +api diff --git a/.claude/tsc-cache/3a39c930-cf12-4be4-a2f2-eae1467b53c6/edited-files.log b/.claude/tsc-cache/3a39c930-cf12-4be4-a2f2-eae1467b53c6/edited-files.log new file mode 100644 index 00000000..b63ee143 --- /dev/null +++ b/.claude/tsc-cache/3a39c930-cf12-4be4-a2f2-eae1467b53c6/edited-files.log @@ -0,0 +1,4 @@ +1772492278:/private/var/folders/n9/gx_3rrzs6kbbx833881fxrkm0000gn/T/roborev-refine-3778673750/api/core/v1beta1/site_types.go:api +1772492286:/private/var/folders/n9/gx_3rrzs6kbbx833881fxrkm0000gn/T/roborev-refine-3778673750/api/core/v1beta1/site_types.go:api +1772492292:/private/var/folders/n9/gx_3rrzs6kbbx833881fxrkm0000gn/T/roborev-refine-3778673750/api/core/v1beta1/site_types.go:api +1772492330:/private/var/folders/n9/gx_3rrzs6kbbx833881fxrkm0000gn/T/roborev-refine-3778673750/api/core/v1beta1/package_manager_config.go:api diff --git a/api/core/v1beta1/package_manager_config.go b/api/core/v1beta1/package_manager_config.go index 6bb5916e..e0d6ea36 100644 --- a/api/core/v1beta1/package_manager_config.go +++ b/api/core/v1beta1/package_manager_config.go @@ -116,6 +116,9 @@ func (configStruct *PackageManagerConfig) GenerateGcfg() (string, error) { // Render named IdentityFederation sections (these use the gcfg named subsection syntax) for _, idf := range configStruct.IdentityFederation { + if strings.ContainsAny(idf.Name, "\"]\n") { + return "", fmt.Errorf("invalid IdentityFederation name %q: must not contain '\"', ']', or newlines", idf.Name) + } builder.WriteString(fmt.Sprintf("\n[IdentityFederation \"%s\"]\n", idf.Name)) if idf.Issuer != "" { builder.WriteString("Issuer = " + idf.Issuer + "\n") diff --git a/api/core/v1beta1/site_types.go b/api/core/v1beta1/site_types.go index 4620bbb6..b0d9bad5 100644 --- a/api/core/v1beta1/site_types.go +++ b/api/core/v1beta1/site_types.go @@ -129,6 +129,11 @@ type SiteSpec struct { // OIDCIssuerURL is the K8s cluster OIDC issuer URL (for EKS/AKS Identity Federation) // +optional OIDCIssuerURL string `json:"oidcIssuerUrl,omitempty"` + + // OIDCAudience is the audience claim for the OIDC token used in Identity Federation. + // For EKS this is typically "sts.amazonaws.com", for AKS it varies by configuration. + // +optional + OIDCAudience string `json:"oidcAudience,omitempty"` } type ServiceAccountConfig struct { @@ -314,6 +319,10 @@ type InternalConnectSpec struct { // AuthenticatedRepos enables PPM authenticated repository access for Connect // +optional AuthenticatedRepos bool `json:"authenticatedRepos,omitempty"` + + // PPMAuthImage specifies the container image for PPM auth init/sidecar containers + // +optional + PPMAuthImage string `json:"ppmAuthImage,omitempty"` } type DatabaseSettings struct { @@ -455,6 +464,10 @@ type InternalWorkbenchSpec struct { // AuthenticatedRepos enables PPM authenticated repository access for Workbench // +optional AuthenticatedRepos bool `json:"authenticatedRepos,omitempty"` + + // PPMAuthImage specifies the container image for PPM auth init/sidecar containers + // +optional + PPMAuthImage string `json:"ppmAuthImage,omitempty"` } type InternalWorkbenchExperimentalFeatures struct { diff --git a/client-go/applyconfiguration/core/v1beta1/internalconnectspec.go b/client-go/applyconfiguration/core/v1beta1/internalconnectspec.go index e8bf6502..9a0f3819 100644 --- a/client-go/applyconfiguration/core/v1beta1/internalconnectspec.go +++ b/client-go/applyconfiguration/core/v1beta1/internalconnectspec.go @@ -37,6 +37,7 @@ type InternalConnectSpecApplyConfiguration struct { AdditionalRuntimeImages []ConnectRuntimeImageSpecApplyConfiguration `json:"additionalRuntimeImages,omitempty"` AdditionalConfig *string `json:"additionalConfig,omitempty"` AuthenticatedRepos *bool `json:"authenticatedRepos,omitempty"` + PPMAuthImage *string `json:"ppmAuthImage,omitempty"` } // InternalConnectSpecApplyConfiguration constructs a declarative configuration of the InternalConnectSpec type for use with @@ -253,3 +254,11 @@ func (b *InternalConnectSpecApplyConfiguration) WithAuthenticatedRepos(value boo b.AuthenticatedRepos = &value return b } + +// WithPPMAuthImage sets the PPMAuthImage field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the PPMAuthImage field is set to the value of the last call. +func (b *InternalConnectSpecApplyConfiguration) WithPPMAuthImage(value string) *InternalConnectSpecApplyConfiguration { + b.PPMAuthImage = &value + return b +} diff --git a/client-go/applyconfiguration/core/v1beta1/internalworkbenchspec.go b/client-go/applyconfiguration/core/v1beta1/internalworkbenchspec.go index 2e1b0cfe..fa05eeaa 100644 --- a/client-go/applyconfiguration/core/v1beta1/internalworkbenchspec.go +++ b/client-go/applyconfiguration/core/v1beta1/internalworkbenchspec.go @@ -48,6 +48,7 @@ type InternalWorkbenchSpecApplyConfiguration struct { AdditionalConfigs map[string]string `json:"additionalConfigs,omitempty"` AdditionalSessionConfigs map[string]string `json:"additionalSessionConfigs,omitempty"` AuthenticatedRepos *bool `json:"authenticatedRepos,omitempty"` + PPMAuthImage *string `json:"ppmAuthImage,omitempty"` } // InternalWorkbenchSpecApplyConfiguration constructs a declarative configuration of the InternalWorkbenchSpec type for use with @@ -377,3 +378,11 @@ func (b *InternalWorkbenchSpecApplyConfiguration) WithAuthenticatedRepos(value b b.AuthenticatedRepos = &value return b } + +// WithPPMAuthImage sets the PPMAuthImage field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the PPMAuthImage field is set to the value of the last call. +func (b *InternalWorkbenchSpecApplyConfiguration) WithPPMAuthImage(value string) *InternalWorkbenchSpecApplyConfiguration { + b.PPMAuthImage = &value + return b +} diff --git a/client-go/applyconfiguration/core/v1beta1/sitespec.go b/client-go/applyconfiguration/core/v1beta1/sitespec.go index 962a0a4b..d7f52178 100644 --- a/client-go/applyconfiguration/core/v1beta1/sitespec.go +++ b/client-go/applyconfiguration/core/v1beta1/sitespec.go @@ -44,6 +44,7 @@ type SiteSpecApplyConfiguration struct { VPCCIDR *string `json:"vpcCIDR,omitempty"` EnableFQDNHealthChecks *bool `json:"enableFqdnHealthChecks,omitempty"` OIDCIssuerURL *string `json:"oidcIssuerUrl,omitempty"` + OIDCAudience *string `json:"oidcAudience,omitempty"` } // SiteSpecApplyConfiguration constructs a declarative configuration of the SiteSpec type for use with @@ -312,3 +313,11 @@ func (b *SiteSpecApplyConfiguration) WithOIDCIssuerURL(value string) *SiteSpecAp b.OIDCIssuerURL = &value return b } + +// WithOIDCAudience sets the OIDCAudience field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the OIDCAudience field is set to the value of the last call. +func (b *SiteSpecApplyConfiguration) WithOIDCAudience(value string) *SiteSpecApplyConfiguration { + b.OIDCAudience = &value + return b +} diff --git a/config/crd/bases/core.posit.team_sites.yaml b/config/crd/bases/core.posit.team_sites.yaml index c9bc3a2a..00efc4de 100644 --- a/config/crd/bases/core.posit.team_sites.yaml +++ b/config/crd/bases/core.posit.team_sites.yaml @@ -434,6 +434,10 @@ spec: additionalProperties: type: string type: object + ppmAuthImage: + description: PPMAuthImage specifies the container image for PPM + auth init/sidecar containers + type: string publicWarning: type: string registerOnFirstLogin: @@ -626,6 +630,11 @@ spec: maximum: 100 minimum: 0 type: integer + oidcAudience: + description: |- + OIDCAudience is the audience claim for the OIDC token used in Identity Federation. + For EKS this is typically "sts.amazonaws.com", for AKS it varies by configuration. + type: string oidcIssuerUrl: description: OIDCIssuerURL is the K8s cluster OIDC issuer URL (for EKS/AKS Identity Federation) @@ -1530,6 +1539,10 @@ spec: x-kubernetes-preserve-unknown-fields: true type: object type: object + ppmAuthImage: + description: PPMAuthImage specifies the container image for PPM + auth init/sidecar containers + type: string replicas: type: integer sessionInitContainerImageName: diff --git a/dist/chart/templates/crd/core.posit.team_sites.yaml b/dist/chart/templates/crd/core.posit.team_sites.yaml index 0eda9c5b..e307cb39 100755 --- a/dist/chart/templates/crd/core.posit.team_sites.yaml +++ b/dist/chart/templates/crd/core.posit.team_sites.yaml @@ -193,6 +193,10 @@ spec: description: AuthenticatedRepos enables PPM authenticated repository access for Connect type: boolean + ppmAuthImage: + description: PPMAuthImage specifies the container image for PPM + auth init/sidecar containers + type: string baseDomain: description: |- BaseDomain overrides site.Spec.Domain for this product's URL construction. @@ -647,6 +651,11 @@ spec: maximum: 100 minimum: 0 type: integer + oidcAudience: + description: |- + OIDCAudience is the audience claim for the OIDC token used in Identity Federation. + For EKS this is typically "sts.amazonaws.com", for AKS it varies by configuration. + type: string oidcIssuerUrl: description: OIDCIssuerURL is the K8s cluster OIDC issuer URL (for EKS/AKS Identity Federation) @@ -1146,6 +1155,10 @@ spec: description: AuthenticatedRepos enables PPM authenticated repository access for Workbench type: boolean + ppmAuthImage: + description: PPMAuthImage specifies the container image for PPM + auth init/sidecar containers + type: string baseDomain: description: |- BaseDomain overrides site.Spec.Domain for this product's URL construction. diff --git a/internal/controller/core/connect.go b/internal/controller/core/connect.go index 2c891ad1..6e3d8667 100644 --- a/internal/controller/core/connect.go +++ b/internal/controller/core/connect.go @@ -593,7 +593,7 @@ func (r *ConnectReconciler) ensureDeployedService(ctx context.Context, req ctrl. var ppmAuthInitContainers []corev1.Container var ppmAuthSidecarContainers []corev1.Container if c.Spec.AuthenticatedRepos { - ppmURL := fmt.Sprintf("https://%s", c.Spec.PPMUrl) + ppmURL := SanitizePPMUrl(c.Spec.PPMUrl) ppmAuthVolumes = PPMAuthVolumes(c.SiteName(), ppmURL) ppmAuthVolumeMounts = PPMAuthVolumeMounts() ppmAuthEnvVars = PPMAuthEnvVars() @@ -650,7 +650,7 @@ func (r *ConnectReconciler) ensureDeployedService(ctx context.Context, req ctrl. ImagePullSecrets: pullSecrets, ServiceAccountName: maybeServiceAccountName, // TODO: go back to automounting service token... - AutomountServiceAccountToken: ptr.To(c.Spec.AuthenticatedRepos), + AutomountServiceAccountToken: ptr.To(false), InitContainers: ppmAuthInitContainers, Containers: product.ConcatLists([]corev1.Container{ { diff --git a/internal/controller/core/ppm_auth.go b/internal/controller/core/ppm_auth.go index 900c2c83..58ca9d8c 100644 --- a/internal/controller/core/ppm_auth.go +++ b/internal/controller/core/ppm_auth.go @@ -2,6 +2,7 @@ package core import ( "fmt" + "strings" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" @@ -31,6 +32,9 @@ func PPMAuthTokenExchangeScript() string { return `#!/bin/sh set -e +# Install required tools (alpine base image does not include curl or jq) +apk add --no-cache curl jq >/dev/null 2>&1 + SA_TOKEN_PATH="${SA_TOKEN_PATH:-/var/run/secrets/ppm-auth/token}" NETRC_PATH="${NETRC_PATH:-/mnt/ppm-auth/netrc}" CURLRC_PATH="${CURLRC_PATH:-/mnt/ppm-auth/.curlrc}" @@ -46,6 +50,12 @@ exchange_token() { -d "subject_token_type=urn:ietf:params:oauth:token-type:id_token") PPM_TOKEN=$(echo "$RESPONSE" | jq -r '.access_token') + + if [ -z "$PPM_TOKEN" ] || [ "$PPM_TOKEN" = "null" ]; then + echo "ERROR: Failed to extract access_token from PPM response" >&2 + exit 1 + fi + PPM_HOST=$(echo "$PPM_URL" | sed 's|https\?://||' | sed 's|/.*||') # Write netrc (atomic: write to temp, then rename) @@ -225,3 +235,9 @@ func PPMAuthEnvVars() []corev1.EnvVar { }, } } + +// SanitizePPMUrl strips any existing scheme from the URL and prepends https:// +func SanitizePPMUrl(rawUrl string) string { + host := strings.TrimPrefix(strings.TrimPrefix(rawUrl, "https://"), "http://") + return fmt.Sprintf("https://%s", host) +} diff --git a/internal/controller/core/ppm_auth_test.go b/internal/controller/core/ppm_auth_test.go index 478f52e9..a1a7d859 100644 --- a/internal/controller/core/ppm_auth_test.go +++ b/internal/controller/core/ppm_auth_test.go @@ -17,6 +17,10 @@ func TestPPMAuthTokenExchangeScript(t *testing.T) { require.Contains(t, script, "CURLRC_PATH") require.Contains(t, script, "--netrc-file") require.Contains(t, script, ".curlrc") + // Verify curl and jq are installed + require.Contains(t, script, "apk add --no-cache curl jq") + // Verify null token validation + require.Contains(t, script, `[ "$PPM_TOKEN" = "null" ]`) } func TestPPMAuthConfigMapName(t *testing.T) { @@ -93,3 +97,9 @@ func TestPPMAuthEnvVars(t *testing.T) { require.Equal(t, "CURL_HOME", envs[1].Name) require.Equal(t, "/mnt/ppm-auth", envs[1].Value) } + +func TestSanitizePPMUrl(t *testing.T) { + require.Equal(t, "https://ppm.example.com", SanitizePPMUrl("ppm.example.com")) + require.Equal(t, "https://ppm.example.com", SanitizePPMUrl("https://ppm.example.com")) + require.Equal(t, "https://ppm.example.com", SanitizePPMUrl("http://ppm.example.com")) +} diff --git a/internal/controller/core/site_controller.go b/internal/controller/core/site_controller.go index 690e4e12..26cc9bd1 100644 --- a/internal/controller/core/site_controller.go +++ b/internal/controller/core/site_controller.go @@ -314,6 +314,10 @@ func (r *SiteReconciler) reconcileResources(ctx context.Context, req ctrl.Reques l.Error(err, "error reconciling PPM auth ConfigMap") return ctrl.Result{}, err } + } else { + if err := r.cleanupPPMAuthConfigMap(ctx, req, site); err != nil { + l.Error(err, "error cleaning up PPM auth ConfigMap") + } } // CONNECT @@ -506,6 +510,18 @@ func (r *SiteReconciler) reconcilePPMAuthConfigMap(ctx context.Context, req ctrl return nil } +func (r *SiteReconciler) cleanupPPMAuthConfigMap(ctx context.Context, req ctrl.Request, site *positcov1beta1.Site) error { + cm := &corev1.ConfigMap{} + key := client.ObjectKey{Name: PPMAuthConfigMapName(site.Name), Namespace: req.Namespace} + if err := r.Get(ctx, key, cm); err != nil { + if apierrors.IsNotFound(err) { + return nil + } + return err + } + return r.Delete(ctx, cm) +} + // SetupWithManager sets up the controller with the Manager. func (r *SiteReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). diff --git a/internal/controller/core/site_controller_connect.go b/internal/controller/core/site_controller_connect.go index 091dfe65..edb11576 100644 --- a/internal/controller/core/site_controller_connect.go +++ b/internal/controller/core/site_controller_connect.go @@ -155,6 +155,7 @@ func (r *SiteReconciler) reconcileConnect( Debug: connectDebugLog, Replicas: product.PassDefaultReplicas(site.Spec.Connect.Replicas, 1), AuthenticatedRepos: site.Spec.Connect.AuthenticatedRepos, + PPMAuthImage: site.Spec.Connect.PPMAuthImage, PPMUrl: prefixDomain(site.Spec.PackageManager.DomainPrefix, getEffectiveBaseDomain(site.Spec.PackageManager.BaseDomain, site.Spec.Domain), v1beta1.SiteSubDomain), }, } diff --git a/internal/controller/core/site_controller_package_manager.go b/internal/controller/core/site_controller_package_manager.go index a478be92..3eff5581 100644 --- a/internal/controller/core/site_controller_package_manager.go +++ b/internal/controller/core/site_controller_package_manager.go @@ -136,11 +136,15 @@ func (r *SiteReconciler) reconcilePackageManager( // Auto-configure Identity Federation entries based on product flags var idfEntries []v1beta1.PackageManagerIdentityFederationConfig if site.Spec.OIDCIssuerURL != "" { + audience := site.Spec.OIDCAudience + if audience == "" { + audience = "sts.amazonaws.com" + } if site.Spec.Connect.AuthenticatedRepos { idfEntries = append(idfEntries, v1beta1.PackageManagerIdentityFederationConfig{ Name: "connect", Issuer: site.Spec.OIDCIssuerURL, - Audience: "sts.amazonaws.com", + Audience: audience, Subject: fmt.Sprintf("system:serviceaccount:%s:%s-connect", req.Namespace, req.Name), Scope: "repos:read:*", }) @@ -149,7 +153,7 @@ func (r *SiteReconciler) reconcilePackageManager( idfEntries = append(idfEntries, v1beta1.PackageManagerIdentityFederationConfig{ Name: "workbench", Issuer: site.Spec.OIDCIssuerURL, - Audience: "sts.amazonaws.com", + Audience: audience, Subject: fmt.Sprintf("system:serviceaccount:%s:%s-workbench", req.Namespace, req.Name), Scope: "repos:read:*", }) diff --git a/internal/controller/core/site_controller_workbench.go b/internal/controller/core/site_controller_workbench.go index ace7a850..cae45f64 100644 --- a/internal/controller/core/site_controller_workbench.go +++ b/internal/controller/core/site_controller_workbench.go @@ -257,6 +257,7 @@ func (r *SiteReconciler) reconcileWorkbench( WorkloadSecret: site.Spec.WorkloadSecret, Replicas: product.PassDefaultReplicas(site.Spec.Workbench.Replicas, 1), AuthenticatedRepos: site.Spec.Workbench.AuthenticatedRepos, + PPMAuthImage: site.Spec.Workbench.PPMAuthImage, PPMUrl: prefixDomain(site.Spec.PackageManager.DomainPrefix, getEffectiveBaseDomain(site.Spec.PackageManager.BaseDomain, site.Spec.Domain), v1beta1.SiteSubDomain), }, } diff --git a/internal/controller/core/workbench.go b/internal/controller/core/workbench.go index 151457ce..7cbd321f 100644 --- a/internal/controller/core/workbench.go +++ b/internal/controller/core/workbench.go @@ -752,7 +752,7 @@ func (r *WorkbenchReconciler) ensureDeployedService(ctx context.Context, req ctr var ppmAuthInitContainers []corev1.Container var ppmAuthSidecarContainers []corev1.Container if w.Spec.AuthenticatedRepos { - ppmURL := fmt.Sprintf("https://%s", w.Spec.PPMUrl) + ppmURL := SanitizePPMUrl(w.Spec.PPMUrl) ppmAuthVolumes = PPMAuthVolumes(w.SiteName(), ppmURL) ppmAuthVolumeMounts = PPMAuthVolumeMounts() ppmAuthEnvVars = PPMAuthEnvVars() From 4a1e85d048f82ab9e45fd2bba6e0b899d439553b Mon Sep 17 00:00:00 2001 From: ian-flores Date: Mon, 2 Mar 2026 15:27:36 -0800 Subject: [PATCH 03/15] fix: regenerate Helm chart after OIDCAudience field addition --- .../affected-repos.txt | 1 - .../edited-files.log | 4 ---- .../templates/crd/core.posit.team_sites.yaml | 16 ++++++++-------- 3 files changed, 8 insertions(+), 13 deletions(-) delete mode 100644 .claude/tsc-cache/3a39c930-cf12-4be4-a2f2-eae1467b53c6/affected-repos.txt delete mode 100644 .claude/tsc-cache/3a39c930-cf12-4be4-a2f2-eae1467b53c6/edited-files.log diff --git a/.claude/tsc-cache/3a39c930-cf12-4be4-a2f2-eae1467b53c6/affected-repos.txt b/.claude/tsc-cache/3a39c930-cf12-4be4-a2f2-eae1467b53c6/affected-repos.txt deleted file mode 100644 index eedd89b4..00000000 --- a/.claude/tsc-cache/3a39c930-cf12-4be4-a2f2-eae1467b53c6/affected-repos.txt +++ /dev/null @@ -1 +0,0 @@ -api diff --git a/.claude/tsc-cache/3a39c930-cf12-4be4-a2f2-eae1467b53c6/edited-files.log b/.claude/tsc-cache/3a39c930-cf12-4be4-a2f2-eae1467b53c6/edited-files.log deleted file mode 100644 index b63ee143..00000000 --- a/.claude/tsc-cache/3a39c930-cf12-4be4-a2f2-eae1467b53c6/edited-files.log +++ /dev/null @@ -1,4 +0,0 @@ -1772492278:/private/var/folders/n9/gx_3rrzs6kbbx833881fxrkm0000gn/T/roborev-refine-3778673750/api/core/v1beta1/site_types.go:api -1772492286:/private/var/folders/n9/gx_3rrzs6kbbx833881fxrkm0000gn/T/roborev-refine-3778673750/api/core/v1beta1/site_types.go:api -1772492292:/private/var/folders/n9/gx_3rrzs6kbbx833881fxrkm0000gn/T/roborev-refine-3778673750/api/core/v1beta1/site_types.go:api -1772492330:/private/var/folders/n9/gx_3rrzs6kbbx833881fxrkm0000gn/T/roborev-refine-3778673750/api/core/v1beta1/package_manager_config.go:api diff --git a/dist/chart/templates/crd/core.posit.team_sites.yaml b/dist/chart/templates/crd/core.posit.team_sites.yaml index e307cb39..1a0f7ba9 100755 --- a/dist/chart/templates/crd/core.posit.team_sites.yaml +++ b/dist/chart/templates/crd/core.posit.team_sites.yaml @@ -193,10 +193,6 @@ spec: description: AuthenticatedRepos enables PPM authenticated repository access for Connect type: boolean - ppmAuthImage: - description: PPMAuthImage specifies the container image for PPM - auth init/sidecar containers - type: string baseDomain: description: |- BaseDomain overrides site.Spec.Domain for this product's URL construction. @@ -459,6 +455,10 @@ spec: additionalProperties: type: string type: object + ppmAuthImage: + description: PPMAuthImage specifies the container image for PPM + auth init/sidecar containers + type: string publicWarning: type: string registerOnFirstLogin: @@ -1155,10 +1155,6 @@ spec: description: AuthenticatedRepos enables PPM authenticated repository access for Workbench type: boolean - ppmAuthImage: - description: PPMAuthImage specifies the container image for PPM - auth init/sidecar containers - type: string baseDomain: description: |- BaseDomain overrides site.Spec.Domain for this product's URL construction. @@ -1564,6 +1560,10 @@ spec: x-kubernetes-preserve-unknown-fields: true type: object type: object + ppmAuthImage: + description: PPMAuthImage specifies the container image for PPM + auth init/sidecar containers + type: string replicas: type: integer sessionInitContainerImageName: From 6fa8672f3f8e8482ad2a09b8dc6fb9c7e0e2017a Mon Sep 17 00:00:00 2001 From: ian-flores Date: Tue, 3 Mar 2026 10:02:39 -0800 Subject: [PATCH 04/15] fix: address PR review findings for PPM auth - Fix audience mismatch: thread OIDCAudience from site spec through Connect/Workbench to projected SA token volumes - Remove runtime apk add from token exchange script (image must have curl+jq pre-installed) - Add sidecar resilience: catch refresh failures instead of dying - Remove unused ppmAuthCurlrcPath constant - Clarify AutomountServiceAccountToken comment re: projected volumes - Add .claude/tsc-cache to .gitignore --- .gitignore | 3 +++ api/core/v1beta1/connect_types.go | 4 ++++ api/core/v1beta1/workbench_types.go | 4 ++++ config/crd/bases/core.posit.team_connects.yaml | 4 ++++ .../crd/bases/core.posit.team_workbenches.yaml | 4 ++++ .../templates/crd/core.posit.team_connects.yaml | 4 ++++ .../crd/core.posit.team_workbenches.yaml | 4 ++++ internal/controller/core/connect.go | 6 ++++-- internal/controller/core/ppm_auth.go | 16 +++++----------- internal/controller/core/ppm_auth_test.go | 8 ++++---- .../controller/core/site_controller_connect.go | 1 + .../controller/core/site_controller_workbench.go | 1 + internal/controller/core/workbench.go | 2 +- 13 files changed, 43 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index acea8a96..62f3ac22 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ go.work.sum # Editor/IDE # .idea/ # .vscode/ + +# Claude Code cache +.claude/tsc-cache/ diff --git a/api/core/v1beta1/connect_types.go b/api/core/v1beta1/connect_types.go index f0bf6be2..b9195b03 100644 --- a/api/core/v1beta1/connect_types.go +++ b/api/core/v1beta1/connect_types.go @@ -159,6 +159,10 @@ type ConnectSpec struct { // PPMUrl specifies the PPM URL for authenticated repository access // +optional PPMUrl string `json:"ppmUrl,omitempty"` + + // PPMAuthAudience is the audience claim for the projected SA token used in PPM Identity Federation + // +optional + PPMAuthAudience string `json:"ppmAuthAudience,omitempty"` } // TODO: Validation should require Volume definition for off-host-execution... diff --git a/api/core/v1beta1/workbench_types.go b/api/core/v1beta1/workbench_types.go index ec5df4fc..d8289aa4 100644 --- a/api/core/v1beta1/workbench_types.go +++ b/api/core/v1beta1/workbench_types.go @@ -121,6 +121,10 @@ type WorkbenchSpec struct { // PPMUrl specifies the PPM URL for authenticated repository access // +optional PPMUrl string `json:"ppmUrl,omitempty"` + + // PPMAuthAudience is the audience claim for the projected SA token used in PPM Identity Federation + // +optional + PPMAuthAudience string `json:"ppmAuthAudience,omitempty"` } // TODO: Validation should require Volume definition for off-host-execution... diff --git a/config/crd/bases/core.posit.team_connects.yaml b/config/crd/bases/core.posit.team_connects.yaml index 5c0eef57..2f28ff90 100644 --- a/config/crd/bases/core.posit.team_connects.yaml +++ b/config/crd/bases/core.posit.team_connects.yaml @@ -504,6 +504,10 @@ spec: type: object offHostExecution: type: boolean + ppmAuthAudience: + description: PPMAuthAudience is the audience claim for the projected + SA token used in PPM Identity Federation + type: string ppmAuthImage: description: PPMAuthImage specifies the container image for PPM auth init/sidecar containers diff --git a/config/crd/bases/core.posit.team_workbenches.yaml b/config/crd/bases/core.posit.team_workbenches.yaml index e59ed08c..f4d9e9be 100644 --- a/config/crd/bases/core.posit.team_workbenches.yaml +++ b/config/crd/bases/core.posit.team_workbenches.yaml @@ -695,6 +695,10 @@ spec: type: boolean parentUrl: type: string + ppmAuthAudience: + description: PPMAuthAudience is the audience claim for the projected + SA token used in PPM Identity Federation + type: string ppmAuthImage: description: PPMAuthImage specifies the container image for PPM auth init/sidecar containers diff --git a/dist/chart/templates/crd/core.posit.team_connects.yaml b/dist/chart/templates/crd/core.posit.team_connects.yaml index d0ae7846..94bb31c1 100755 --- a/dist/chart/templates/crd/core.posit.team_connects.yaml +++ b/dist/chart/templates/crd/core.posit.team_connects.yaml @@ -525,6 +525,10 @@ spec: type: object offHostExecution: type: boolean + ppmAuthAudience: + description: PPMAuthAudience is the audience claim for the projected + SA token used in PPM Identity Federation + type: string ppmAuthImage: description: PPMAuthImage specifies the container image for PPM auth init/sidecar containers diff --git a/dist/chart/templates/crd/core.posit.team_workbenches.yaml b/dist/chart/templates/crd/core.posit.team_workbenches.yaml index 4b4b7e5a..90e77d71 100755 --- a/dist/chart/templates/crd/core.posit.team_workbenches.yaml +++ b/dist/chart/templates/crd/core.posit.team_workbenches.yaml @@ -716,6 +716,10 @@ spec: type: boolean parentUrl: type: string + ppmAuthAudience: + description: PPMAuthAudience is the audience claim for the projected + SA token used in PPM Identity Federation + type: string ppmAuthImage: description: PPMAuthImage specifies the container image for PPM auth init/sidecar containers diff --git a/internal/controller/core/connect.go b/internal/controller/core/connect.go index 6e3d8667..a472c7da 100644 --- a/internal/controller/core/connect.go +++ b/internal/controller/core/connect.go @@ -594,7 +594,7 @@ func (r *ConnectReconciler) ensureDeployedService(ctx context.Context, req ctrl. var ppmAuthSidecarContainers []corev1.Container if c.Spec.AuthenticatedRepos { ppmURL := SanitizePPMUrl(c.Spec.PPMUrl) - ppmAuthVolumes = PPMAuthVolumes(c.SiteName(), ppmURL) + ppmAuthVolumes = PPMAuthVolumes(c.SiteName(), ppmURL, c.Spec.PPMAuthAudience) ppmAuthVolumeMounts = PPMAuthVolumeMounts() ppmAuthEnvVars = PPMAuthEnvVars() ppmAuthInitContainers = []corev1.Container{ @@ -649,7 +649,9 @@ func (r *ConnectReconciler) ensureDeployedService(ctx context.Context, req ctrl. NodeSelector: c.Spec.NodeSelector, ImagePullSecrets: pullSecrets, ServiceAccountName: maybeServiceAccountName, - // TODO: go back to automounting service token... + // AutomountServiceAccountToken is false to avoid mounting the default SA token. + // This does NOT affect projected SA token volumes (used by PPM auth), which are + // explicit volume mounts independent of the automount setting. AutomountServiceAccountToken: ptr.To(false), InitContainers: ppmAuthInitContainers, Containers: product.ConcatLists([]corev1.Container{ diff --git a/internal/controller/core/ppm_auth.go b/internal/controller/core/ppm_auth.go index 58ca9d8c..b6899fc6 100644 --- a/internal/controller/core/ppm_auth.go +++ b/internal/controller/core/ppm_auth.go @@ -17,11 +17,11 @@ const ( ppmAuthNetrcVolumeName = "ppm-auth" ppmAuthNetrcMountPath = "/mnt/ppm-auth" ppmAuthNetrcPath = "/mnt/ppm-auth/netrc" - ppmAuthCurlrcPath = "/mnt/ppm-auth/.curlrc" ppmAuthTokenMountPath = "/var/run/secrets/ppm-auth" ppmAuthScriptMountPath = "/scripts" - ppmAuthDefaultImage = "alpine:3" - ppmAuthDefaultRefresh = "3000" // 50 minutes (for 60 min token lifetime) + // ppmAuthDefaultImage is expected to have curl and jq pre-installed + ppmAuthDefaultImage = "alpine:3" + ppmAuthDefaultRefresh = "3000" // 50 minutes (for 60 min token lifetime) ) // PPMAuthTokenExchangeScript returns the shell script content for the token exchange @@ -32,9 +32,6 @@ func PPMAuthTokenExchangeScript() string { return `#!/bin/sh set -e -# Install required tools (alpine base image does not include curl or jq) -apk add --no-cache curl jq >/dev/null 2>&1 - SA_TOKEN_PATH="${SA_TOKEN_PATH:-/var/run/secrets/ppm-auth/token}" NETRC_PATH="${NETRC_PATH:-/mnt/ppm-auth/netrc}" CURLRC_PATH="${CURLRC_PATH:-/mnt/ppm-auth/.curlrc}" @@ -74,7 +71,7 @@ exchange_token if [ "${MODE}" = "sidecar" ]; then while true; do sleep "$REFRESH_INTERVAL" - exchange_token + exchange_token || echo "WARNING: token refresh failed, will retry" >&2 done fi ` @@ -165,10 +162,7 @@ func ppmAuthContainerVolumeMounts() []corev1.VolumeMount { // 1. Projected SA token volume (for K8s Identity Federation) // 2. Shared emptyDir for netrc file // 3. ConfigMap volume with the token exchange script -func PPMAuthVolumes(siteName, ppmURL string) []corev1.Volume { - // Extract audience from PPM URL (the PPM URL itself is the audience for the projected token) - audience := ppmURL - +func PPMAuthVolumes(siteName, ppmURL, audience string) []corev1.Volume { return []corev1.Volume{ { Name: ppmAuthTokenVolumeName, diff --git a/internal/controller/core/ppm_auth_test.go b/internal/controller/core/ppm_auth_test.go index a1a7d859..caa917b5 100644 --- a/internal/controller/core/ppm_auth_test.go +++ b/internal/controller/core/ppm_auth_test.go @@ -17,10 +17,10 @@ func TestPPMAuthTokenExchangeScript(t *testing.T) { require.Contains(t, script, "CURLRC_PATH") require.Contains(t, script, "--netrc-file") require.Contains(t, script, ".curlrc") - // Verify curl and jq are installed - require.Contains(t, script, "apk add --no-cache curl jq") // Verify null token validation require.Contains(t, script, `[ "$PPM_TOKEN" = "null" ]`) + // Verify sidecar resilience + require.Contains(t, script, "WARNING: token refresh failed, will retry") } func TestPPMAuthConfigMapName(t *testing.T) { @@ -62,14 +62,14 @@ func TestPPMAuthSidecarContainerCustomRefresh(t *testing.T) { } func TestPPMAuthVolumes(t *testing.T) { - vols := PPMAuthVolumes("mysite", "https://packagemanager.example.com") + vols := PPMAuthVolumes("mysite", "https://packagemanager.example.com", "sts.amazonaws.com") require.Len(t, vols, 3) // Projected SA token volume require.Equal(t, "ppm-sa-token", vols[0].Name) require.NotNil(t, vols[0].Projected) require.Len(t, vols[0].Projected.Sources, 1) - require.Equal(t, "https://packagemanager.example.com", vols[0].Projected.Sources[0].ServiceAccountToken.Audience) + require.Equal(t, "sts.amazonaws.com", vols[0].Projected.Sources[0].ServiceAccountToken.Audience) // Shared emptyDir require.Equal(t, "ppm-auth", vols[1].Name) diff --git a/internal/controller/core/site_controller_connect.go b/internal/controller/core/site_controller_connect.go index edb11576..f7024048 100644 --- a/internal/controller/core/site_controller_connect.go +++ b/internal/controller/core/site_controller_connect.go @@ -157,6 +157,7 @@ func (r *SiteReconciler) reconcileConnect( AuthenticatedRepos: site.Spec.Connect.AuthenticatedRepos, PPMAuthImage: site.Spec.Connect.PPMAuthImage, PPMUrl: prefixDomain(site.Spec.PackageManager.DomainPrefix, getEffectiveBaseDomain(site.Spec.PackageManager.BaseDomain, site.Spec.Domain), v1beta1.SiteSubDomain), + PPMAuthAudience: site.Spec.OIDCAudience, }, } diff --git a/internal/controller/core/site_controller_workbench.go b/internal/controller/core/site_controller_workbench.go index cae45f64..08370ea1 100644 --- a/internal/controller/core/site_controller_workbench.go +++ b/internal/controller/core/site_controller_workbench.go @@ -259,6 +259,7 @@ func (r *SiteReconciler) reconcileWorkbench( AuthenticatedRepos: site.Spec.Workbench.AuthenticatedRepos, PPMAuthImage: site.Spec.Workbench.PPMAuthImage, PPMUrl: prefixDomain(site.Spec.PackageManager.DomainPrefix, getEffectiveBaseDomain(site.Spec.PackageManager.BaseDomain, site.Spec.Domain), v1beta1.SiteSubDomain), + PPMAuthAudience: site.Spec.OIDCAudience, }, } // potentially enable experimental features diff --git a/internal/controller/core/workbench.go b/internal/controller/core/workbench.go index 7cbd321f..adb9cc71 100644 --- a/internal/controller/core/workbench.go +++ b/internal/controller/core/workbench.go @@ -753,7 +753,7 @@ func (r *WorkbenchReconciler) ensureDeployedService(ctx context.Context, req ctr var ppmAuthSidecarContainers []corev1.Container if w.Spec.AuthenticatedRepos { ppmURL := SanitizePPMUrl(w.Spec.PPMUrl) - ppmAuthVolumes = PPMAuthVolumes(w.SiteName(), ppmURL) + ppmAuthVolumes = PPMAuthVolumes(w.SiteName(), ppmURL, w.Spec.PPMAuthAudience) ppmAuthVolumeMounts = PPMAuthVolumeMounts() ppmAuthEnvVars = PPMAuthEnvVars() ppmAuthInitContainers = []corev1.Container{ From 46d71de0611bbf1d621d7621bb990293c84722fc Mon Sep 17 00:00:00 2001 From: ian-flores Date: Tue, 3 Mar 2026 10:12:59 -0800 Subject: [PATCH 05/15] feat: add full PPM authentication config field coverage Add typed support for all PPM authentication configuration fields: - New [Authentication] section (APITokenAuth, DeviceAuthType, session lifetime, etc.) - 12 new [OpenIDConnect] fields (ClientSecretFile, PKCE, claims customization, token lifetime, etc.) - 9 new [IdentityFederation] fields (claims, groups, roles, logging) --- api/core/v1beta1/package_manager_config.go | 86 +++++++++++++--- .../v1beta1/package_manager_config_test.go | 97 +++++++++++++++++++ .../core.posit.team_packagemanagers.yaml | 39 ++++++++ .../crd/core.posit.team_packagemanagers.yaml | 39 ++++++++ 4 files changed, 247 insertions(+), 14 deletions(-) diff --git a/api/core/v1beta1/package_manager_config.go b/api/core/v1beta1/package_manager_config.go index e0d6ea36..357cbc90 100644 --- a/api/core/v1beta1/package_manager_config.go +++ b/api/core/v1beta1/package_manager_config.go @@ -18,6 +18,7 @@ type PackageManagerConfig struct { Repos *PackageManagerReposConfig `json:"Repos,omitempty"` Cran *PackageManagerCRANConfig `json:"CRAN,omitempty"` Debug *PackageManagerDebugConfig `json:"Debug,omitempty"` + Authentication *PackageManagerAuthenticationConfig `json:"Authentication,omitempty"` OpenIDConnect *PackageManagerOIDCConfig `json:"OpenIDConnect,omitempty"` IdentityFederation []PackageManagerIdentityFederationConfig `json:"-"` @@ -123,6 +124,9 @@ func (configStruct *PackageManagerConfig) GenerateGcfg() (string, error) { if idf.Issuer != "" { builder.WriteString("Issuer = " + idf.Issuer + "\n") } + if idf.Logging { + builder.WriteString("Logging = true\n") + } if idf.Audience != "" { builder.WriteString("Audience = " + idf.Audience + "\n") } @@ -135,6 +139,30 @@ func (configStruct *PackageManagerConfig) GenerateGcfg() (string, error) { if idf.Scope != "" { builder.WriteString("Scope = " + idf.Scope + "\n") } + if idf.CustomScope != "" { + builder.WriteString("CustomScope = " + idf.CustomScope + "\n") + } + if idf.NoAutoGroupsScope { + builder.WriteString("NoAutoGroupsScope = true\n") + } + if idf.GroupsClaim != "" { + builder.WriteString("GroupsClaim = " + idf.GroupsClaim + "\n") + } + if idf.GroupsSeparator != "" { + builder.WriteString("GroupsSeparator = " + idf.GroupsSeparator + "\n") + } + if idf.RoleClaim != "" { + builder.WriteString("RoleClaim = " + idf.RoleClaim + "\n") + } + if idf.RolesSeparator != "" { + builder.WriteString("RolesSeparator = " + idf.RolesSeparator + "\n") + } + if idf.UniqueIdClaim != "" { + builder.WriteString("UniqueIdClaim = " + idf.UniqueIdClaim + "\n") + } + if idf.UsernameClaim != "" { + builder.WriteString("UsernameClaim = " + idf.UsernameClaim + "\n") + } if idf.TokenLifetime != "" { builder.WriteString("TokenLifetime = " + idf.TokenLifetime + "\n") } @@ -209,24 +237,54 @@ type PackageManagerDebugConfig struct { Log string `json:"Log,omitempty"` } +type PackageManagerAuthenticationConfig struct { + APITokenAuth bool `json:"APITokenAuth,omitempty"` + DeviceAuthType string `json:"DeviceAuthType,omitempty"` + NewReposAuthByDefault bool `json:"NewReposAuthByDefault,omitempty"` + Lifetime string `json:"Lifetime,omitempty"` + Inactivity string `json:"Inactivity,omitempty"` + CookieSweepDuration string `json:"CookieSweepDuration,omitempty"` +} + type PackageManagerOIDCConfig struct { - ClientId string `json:"ClientId,omitempty"` - ClientSecret string `json:"ClientSecret,omitempty"` - Issuer string `json:"Issuer,omitempty"` - RequireLogin bool `json:"RequireLogin,omitempty"` - Scope string `json:"Scope,omitempty"` - GroupsClaim string `json:"GroupsClaim,omitempty"` - RoleClaim string `json:"RoleClaim,omitempty"` + ClientId string `json:"ClientId,omitempty"` + ClientSecret string `json:"ClientSecret,omitempty"` + ClientSecretFile string `json:"ClientSecretFile,omitempty"` + Issuer string `json:"Issuer,omitempty"` + RequireLogin bool `json:"RequireLogin,omitempty"` + Logging bool `json:"Logging,omitempty"` + Scope string `json:"Scope,omitempty"` + CustomScope string `json:"CustomScope,omitempty"` + NoAutoGroupsScope bool `json:"NoAutoGroupsScope,omitempty"` + GroupsClaim string `json:"GroupsClaim,omitempty"` + GroupsSeparator string `json:"GroupsSeparator,omitempty"` + RoleClaim string `json:"RoleClaim,omitempty"` + RolesSeparator string `json:"RolesSeparator,omitempty"` + UniqueIdClaim string `json:"UniqueIdClaim,omitempty"` + UsernameClaim string `json:"UsernameClaim,omitempty"` + TokenLifetime string `json:"TokenLifetime,omitempty"` + MaxAuthenticationAge string `json:"MaxAuthenticationAge,omitempty"` + DisablePKCE bool `json:"DisablePKCE,omitempty"` + EnableDevicePKCE bool `json:"EnableDevicePKCE,omitempty"` } type PackageManagerIdentityFederationConfig struct { - Name string `json:"-"` - Issuer string `json:"Issuer,omitempty"` - Audience string `json:"Audience,omitempty"` - Subject string `json:"Subject,omitempty"` - AuthorizedParty string `json:"AuthorizedParty,omitempty"` - Scope string `json:"Scope,omitempty"` - TokenLifetime string `json:"TokenLifetime,omitempty"` + Name string `json:"-"` + Issuer string `json:"Issuer,omitempty"` + Logging bool `json:"Logging,omitempty"` + Audience string `json:"Audience,omitempty"` + Subject string `json:"Subject,omitempty"` + AuthorizedParty string `json:"AuthorizedParty,omitempty"` + Scope string `json:"Scope,omitempty"` + CustomScope string `json:"CustomScope,omitempty"` + NoAutoGroupsScope bool `json:"NoAutoGroupsScope,omitempty"` + GroupsClaim string `json:"GroupsClaim,omitempty"` + GroupsSeparator string `json:"GroupsSeparator,omitempty"` + RoleClaim string `json:"RoleClaim,omitempty"` + RolesSeparator string `json:"RolesSeparator,omitempty"` + UniqueIdClaim string `json:"UniqueIdClaim,omitempty"` + UsernameClaim string `json:"UsernameClaim,omitempty"` + TokenLifetime string `json:"TokenLifetime,omitempty"` } // SSHKeyConfig defines SSH key configuration for Git authentication diff --git a/api/core/v1beta1/package_manager_config_test.go b/api/core/v1beta1/package_manager_config_test.go index b451e766..8c021c0d 100644 --- a/api/core/v1beta1/package_manager_config_test.go +++ b/api/core/v1beta1/package_manager_config_test.go @@ -139,3 +139,100 @@ func TestPackageManagerConfig_OpenIDConnectAndIdentityFederation(t *testing.T) { require.Contains(t, str, `[IdentityFederation "connect"]`) require.Contains(t, str, "TokenLifetime = 1h") } + +func TestPackageManagerConfig_Authentication(t *testing.T) { + cfg := PackageManagerConfig{ + Authentication: &PackageManagerAuthenticationConfig{ + APITokenAuth: true, + DeviceAuthType: "oidc", + NewReposAuthByDefault: true, + Lifetime: "30d", + Inactivity: "12h", + CookieSweepDuration: "5m", + }, + } + str, err := cfg.GenerateGcfg() + require.Nil(t, err) + require.Contains(t, str, "[Authentication]") + require.Contains(t, str, "APITokenAuth = true") + require.Contains(t, str, "DeviceAuthType = oidc") + require.Contains(t, str, "NewReposAuthByDefault = true") + require.Contains(t, str, "Lifetime = 30d") + require.Contains(t, str, "Inactivity = 12h") + require.Contains(t, str, "CookieSweepDuration = 5m") +} + +func TestPackageManagerConfig_OIDCNewFields(t *testing.T) { + cfg := PackageManagerConfig{ + OpenIDConnect: &PackageManagerOIDCConfig{ + ClientId: "my-client", + ClientSecretFile: "/etc/rstudio-pm/oidc-secret", + Issuer: "https://auth.example.com", + Logging: true, + TokenLifetime: "1h", + DisablePKCE: true, + UniqueIdClaim: "sub", + UsernameClaim: "preferred_username", + MaxAuthenticationAge: "24h", + GroupsSeparator: ",", + RolesSeparator: ";", + CustomScope: "profile email groups", + NoAutoGroupsScope: true, + EnableDevicePKCE: true, + }, + } + str, err := cfg.GenerateGcfg() + require.Nil(t, err) + require.Contains(t, str, "[OpenIDConnect]") + require.Contains(t, str, "ClientId = my-client") + require.Contains(t, str, "ClientSecretFile = /etc/rstudio-pm/oidc-secret") + require.Contains(t, str, "Issuer = https://auth.example.com") + require.Contains(t, str, "Logging = true") + require.Contains(t, str, "TokenLifetime = 1h") + require.Contains(t, str, "DisablePKCE = true") + require.Contains(t, str, "UniqueIdClaim = sub") + require.Contains(t, str, "UsernameClaim = preferred_username") + require.Contains(t, str, "MaxAuthenticationAge = 24h") + require.Contains(t, str, "GroupsSeparator = ,") + require.Contains(t, str, "RolesSeparator = ;") + require.Contains(t, str, "CustomScope = profile email groups") + require.Contains(t, str, "NoAutoGroupsScope = true") + require.Contains(t, str, "EnableDevicePKCE = true") +} + +func TestPackageManagerConfig_IdentityFederationNewFields(t *testing.T) { + cfg := PackageManagerConfig{ + IdentityFederation: []PackageManagerIdentityFederationConfig{ + { + Name: "my-idp", + Issuer: "https://issuer.example.com", + Logging: true, + Audience: "my-audience", + CustomScope: "read write", + NoAutoGroupsScope: true, + GroupsClaim: "groups", + GroupsSeparator: ",", + RoleClaim: "roles", + RolesSeparator: ";", + UniqueIdClaim: "sub", + UsernameClaim: "preferred_username", + TokenLifetime: "2h", + }, + }, + } + str, err := cfg.GenerateGcfg() + require.Nil(t, err) + require.Contains(t, str, `[IdentityFederation "my-idp"]`) + require.Contains(t, str, "Issuer = https://issuer.example.com") + require.Contains(t, str, "Logging = true") + require.Contains(t, str, "Audience = my-audience") + require.Contains(t, str, "CustomScope = read write") + require.Contains(t, str, "NoAutoGroupsScope = true") + require.Contains(t, str, "GroupsClaim = groups") + require.Contains(t, str, "GroupsSeparator = ,") + require.Contains(t, str, "RoleClaim = roles") + require.Contains(t, str, "RolesSeparator = ;") + require.Contains(t, str, "UniqueIdClaim = sub") + require.Contains(t, str, "UsernameClaim = preferred_username") + require.Contains(t, str, "TokenLifetime = 2h") +} diff --git a/config/crd/bases/core.posit.team_packagemanagers.yaml b/config/crd/bases/core.posit.team_packagemanagers.yaml index 14b827c1..23f682dc 100644 --- a/config/crd/bases/core.posit.team_packagemanagers.yaml +++ b/config/crd/bases/core.posit.team_packagemanagers.yaml @@ -75,6 +75,21 @@ spec: type: string config: properties: + Authentication: + properties: + APITokenAuth: + type: boolean + CookieSweepDuration: + type: string + DeviceAuthType: + type: string + Inactivity: + type: string + Lifetime: + type: string + NewReposAuthByDefault: + type: boolean + type: object CRAN: description: 'PackageManagerCRANConfig is deprecated TODO: deprecated! We will remove this soon!' @@ -113,16 +128,40 @@ spec: type: string ClientSecret: type: string + ClientSecretFile: + type: string + CustomScope: + type: string + DisablePKCE: + type: boolean + EnableDevicePKCE: + type: boolean GroupsClaim: type: string + GroupsSeparator: + type: string Issuer: type: string + Logging: + type: boolean + MaxAuthenticationAge: + type: string + NoAutoGroupsScope: + type: boolean RequireLogin: type: boolean RoleClaim: type: string + RolesSeparator: + type: string Scope: type: string + TokenLifetime: + type: string + UniqueIdClaim: + type: string + UsernameClaim: + type: string type: object Postgres: properties: diff --git a/dist/chart/templates/crd/core.posit.team_packagemanagers.yaml b/dist/chart/templates/crd/core.posit.team_packagemanagers.yaml index baddc971..fd1d978c 100755 --- a/dist/chart/templates/crd/core.posit.team_packagemanagers.yaml +++ b/dist/chart/templates/crd/core.posit.team_packagemanagers.yaml @@ -96,6 +96,21 @@ spec: type: string config: properties: + Authentication: + properties: + APITokenAuth: + type: boolean + CookieSweepDuration: + type: string + DeviceAuthType: + type: string + Inactivity: + type: string + Lifetime: + type: string + NewReposAuthByDefault: + type: boolean + type: object CRAN: description: 'PackageManagerCRANConfig is deprecated TODO: deprecated! We will remove this soon!' @@ -134,16 +149,40 @@ spec: type: string ClientSecret: type: string + ClientSecretFile: + type: string + CustomScope: + type: string + DisablePKCE: + type: boolean + EnableDevicePKCE: + type: boolean GroupsClaim: type: string + GroupsSeparator: + type: string Issuer: type: string + Logging: + type: boolean + MaxAuthenticationAge: + type: string + NoAutoGroupsScope: + type: boolean RequireLogin: type: boolean RoleClaim: type: string + RolesSeparator: + type: string Scope: type: string + TokenLifetime: + type: string + UniqueIdClaim: + type: string + UsernameClaim: + type: string type: object Postgres: properties: From 7a13ad137e1f7a872e741c347654cd27070a3b32 Mon Sep 17 00:00:00 2001 From: ian-flores Date: Tue, 3 Mar 2026 10:27:06 -0800 Subject: [PATCH 06/15] fix: regenerate deepcopy and client-go after config field additions --- api/core/v1beta1/zz_generated.deepcopy.go | 20 +++ .../core/v1beta1/connectspec.go | 9 ++ .../core/v1beta1/packagemanagerconfig.go | 35 +++-- .../packagemanageridentityfederationconfig.go | 93 ++++++++++++- .../core/v1beta1/packagemanageroidcconfig.go | 122 +++++++++++++++++- .../core/v1beta1/workbenchspec.go | 9 ++ client-go/applyconfiguration/utils.go | 2 + 7 files changed, 264 insertions(+), 26 deletions(-) diff --git a/api/core/v1beta1/zz_generated.deepcopy.go b/api/core/v1beta1/zz_generated.deepcopy.go index bef44048..b4bda363 100644 --- a/api/core/v1beta1/zz_generated.deepcopy.go +++ b/api/core/v1beta1/zz_generated.deepcopy.go @@ -1547,6 +1547,21 @@ func (in *PackageManager) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PackageManagerAuthenticationConfig) DeepCopyInto(out *PackageManagerAuthenticationConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PackageManagerAuthenticationConfig. +func (in *PackageManagerAuthenticationConfig) DeepCopy() *PackageManagerAuthenticationConfig { + if in == nil { + return nil + } + out := new(PackageManagerAuthenticationConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PackageManagerCRANConfig) DeepCopyInto(out *PackageManagerCRANConfig) { *out = *in @@ -1620,6 +1635,11 @@ func (in *PackageManagerConfig) DeepCopyInto(out *PackageManagerConfig) { *out = new(PackageManagerDebugConfig) **out = **in } + if in.Authentication != nil { + in, out := &in.Authentication, &out.Authentication + *out = new(PackageManagerAuthenticationConfig) + **out = **in + } if in.OpenIDConnect != nil { in, out := &in.OpenIDConnect, &out.OpenIDConnect *out = new(PackageManagerOIDCConfig) diff --git a/client-go/applyconfiguration/core/v1beta1/connectspec.go b/client-go/applyconfiguration/core/v1beta1/connectspec.go index 2913b92b..e327b5c4 100644 --- a/client-go/applyconfiguration/core/v1beta1/connectspec.go +++ b/client-go/applyconfiguration/core/v1beta1/connectspec.go @@ -49,6 +49,7 @@ type ConnectSpecApplyConfiguration struct { AuthenticatedRepos *bool `json:"authenticatedRepos,omitempty"` PPMAuthImage *string `json:"ppmAuthImage,omitempty"` PPMUrl *string `json:"ppmUrl,omitempty"` + PPMAuthAudience *string `json:"ppmAuthAudience,omitempty"` } // ConnectSpecApplyConfiguration constructs a declarative configuration of the ConnectSpec type for use with @@ -371,3 +372,11 @@ func (b *ConnectSpecApplyConfiguration) WithPPMUrl(value string) *ConnectSpecApp b.PPMUrl = &value return b } + +// WithPPMAuthAudience sets the PPMAuthAudience field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the PPMAuthAudience field is set to the value of the last call. +func (b *ConnectSpecApplyConfiguration) WithPPMAuthAudience(value string) *ConnectSpecApplyConfiguration { + b.PPMAuthAudience = &value + return b +} diff --git a/client-go/applyconfiguration/core/v1beta1/packagemanagerconfig.go b/client-go/applyconfiguration/core/v1beta1/packagemanagerconfig.go index fcb09438..545182e8 100644 --- a/client-go/applyconfiguration/core/v1beta1/packagemanagerconfig.go +++ b/client-go/applyconfiguration/core/v1beta1/packagemanagerconfig.go @@ -8,19 +8,20 @@ package v1beta1 // PackageManagerConfigApplyConfiguration represents a declarative configuration of the PackageManagerConfig type for use // with apply. type PackageManagerConfigApplyConfiguration struct { - Server *PackageManagerServerConfigApplyConfiguration `json:"Server,omitempty"` - Http *PackageManagerHttpConfigApplyConfiguration `json:"Http,omitempty"` - Git *PackageManagerGitConfigApplyConfiguration `json:"Git,omitempty"` - Database *PackageManagerDatabaseConfigApplyConfiguration `json:"Database,omitempty"` - Postgres *PackageManagerPostgresConfigApplyConfiguration `json:"Postgres,omitempty"` - Storage *PackageManagerStorageConfigApplyConfiguration `json:"Storage,omitempty"` - S3Storage *PackageManagerS3StorageConfigApplyConfiguration `json:"S3Storage,omitempty"` - Metrics *PackageManagerMetricsConfigApplyConfiguration `json:"Metrics,omitempty"` - Repos *PackageManagerReposConfigApplyConfiguration `json:"Repos,omitempty"` - Cran *PackageManagerCRANConfigApplyConfiguration `json:"CRAN,omitempty"` - Debug *PackageManagerDebugConfigApplyConfiguration `json:"Debug,omitempty"` - OpenIDConnect *PackageManagerOIDCConfigApplyConfiguration `json:"OpenIDConnect,omitempty"` - AdditionalConfig *string `json:"additionalConfig,omitempty"` + Server *PackageManagerServerConfigApplyConfiguration `json:"Server,omitempty"` + Http *PackageManagerHttpConfigApplyConfiguration `json:"Http,omitempty"` + Git *PackageManagerGitConfigApplyConfiguration `json:"Git,omitempty"` + Database *PackageManagerDatabaseConfigApplyConfiguration `json:"Database,omitempty"` + Postgres *PackageManagerPostgresConfigApplyConfiguration `json:"Postgres,omitempty"` + Storage *PackageManagerStorageConfigApplyConfiguration `json:"Storage,omitempty"` + S3Storage *PackageManagerS3StorageConfigApplyConfiguration `json:"S3Storage,omitempty"` + Metrics *PackageManagerMetricsConfigApplyConfiguration `json:"Metrics,omitempty"` + Repos *PackageManagerReposConfigApplyConfiguration `json:"Repos,omitempty"` + Cran *PackageManagerCRANConfigApplyConfiguration `json:"CRAN,omitempty"` + Debug *PackageManagerDebugConfigApplyConfiguration `json:"Debug,omitempty"` + Authentication *PackageManagerAuthenticationConfigApplyConfiguration `json:"Authentication,omitempty"` + OpenIDConnect *PackageManagerOIDCConfigApplyConfiguration `json:"OpenIDConnect,omitempty"` + AdditionalConfig *string `json:"additionalConfig,omitempty"` } // PackageManagerConfigApplyConfiguration constructs a declarative configuration of the PackageManagerConfig type for use with @@ -117,6 +118,14 @@ func (b *PackageManagerConfigApplyConfiguration) WithDebug(value *PackageManager return b } +// WithAuthentication sets the Authentication field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Authentication field is set to the value of the last call. +func (b *PackageManagerConfigApplyConfiguration) WithAuthentication(value *PackageManagerAuthenticationConfigApplyConfiguration) *PackageManagerConfigApplyConfiguration { + b.Authentication = value + return b +} + // WithOpenIDConnect sets the OpenIDConnect field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the OpenIDConnect field is set to the value of the last call. diff --git a/client-go/applyconfiguration/core/v1beta1/packagemanageridentityfederationconfig.go b/client-go/applyconfiguration/core/v1beta1/packagemanageridentityfederationconfig.go index 5bf36ae5..ca7d7d18 100644 --- a/client-go/applyconfiguration/core/v1beta1/packagemanageridentityfederationconfig.go +++ b/client-go/applyconfiguration/core/v1beta1/packagemanageridentityfederationconfig.go @@ -8,12 +8,21 @@ package v1beta1 // PackageManagerIdentityFederationConfigApplyConfiguration represents a declarative configuration of the PackageManagerIdentityFederationConfig type for use // with apply. type PackageManagerIdentityFederationConfigApplyConfiguration struct { - Issuer *string `json:"Issuer,omitempty"` - Audience *string `json:"Audience,omitempty"` - Subject *string `json:"Subject,omitempty"` - AuthorizedParty *string `json:"AuthorizedParty,omitempty"` - Scope *string `json:"Scope,omitempty"` - TokenLifetime *string `json:"TokenLifetime,omitempty"` + Issuer *string `json:"Issuer,omitempty"` + Logging *bool `json:"Logging,omitempty"` + Audience *string `json:"Audience,omitempty"` + Subject *string `json:"Subject,omitempty"` + AuthorizedParty *string `json:"AuthorizedParty,omitempty"` + Scope *string `json:"Scope,omitempty"` + CustomScope *string `json:"CustomScope,omitempty"` + NoAutoGroupsScope *bool `json:"NoAutoGroupsScope,omitempty"` + GroupsClaim *string `json:"GroupsClaim,omitempty"` + GroupsSeparator *string `json:"GroupsSeparator,omitempty"` + RoleClaim *string `json:"RoleClaim,omitempty"` + RolesSeparator *string `json:"RolesSeparator,omitempty"` + UniqueIdClaim *string `json:"UniqueIdClaim,omitempty"` + UsernameClaim *string `json:"UsernameClaim,omitempty"` + TokenLifetime *string `json:"TokenLifetime,omitempty"` } // PackageManagerIdentityFederationConfigApplyConfiguration constructs a declarative configuration of the PackageManagerIdentityFederationConfig type for use with @@ -30,6 +39,14 @@ func (b *PackageManagerIdentityFederationConfigApplyConfiguration) WithIssuer(va return b } +// WithLogging sets the Logging field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Logging field is set to the value of the last call. +func (b *PackageManagerIdentityFederationConfigApplyConfiguration) WithLogging(value bool) *PackageManagerIdentityFederationConfigApplyConfiguration { + b.Logging = &value + return b +} + // WithAudience sets the Audience field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the Audience field is set to the value of the last call. @@ -62,6 +79,70 @@ func (b *PackageManagerIdentityFederationConfigApplyConfiguration) WithScope(val return b } +// WithCustomScope sets the CustomScope field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the CustomScope field is set to the value of the last call. +func (b *PackageManagerIdentityFederationConfigApplyConfiguration) WithCustomScope(value string) *PackageManagerIdentityFederationConfigApplyConfiguration { + b.CustomScope = &value + return b +} + +// WithNoAutoGroupsScope sets the NoAutoGroupsScope field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the NoAutoGroupsScope field is set to the value of the last call. +func (b *PackageManagerIdentityFederationConfigApplyConfiguration) WithNoAutoGroupsScope(value bool) *PackageManagerIdentityFederationConfigApplyConfiguration { + b.NoAutoGroupsScope = &value + return b +} + +// WithGroupsClaim sets the GroupsClaim field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the GroupsClaim field is set to the value of the last call. +func (b *PackageManagerIdentityFederationConfigApplyConfiguration) WithGroupsClaim(value string) *PackageManagerIdentityFederationConfigApplyConfiguration { + b.GroupsClaim = &value + return b +} + +// WithGroupsSeparator sets the GroupsSeparator field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the GroupsSeparator field is set to the value of the last call. +func (b *PackageManagerIdentityFederationConfigApplyConfiguration) WithGroupsSeparator(value string) *PackageManagerIdentityFederationConfigApplyConfiguration { + b.GroupsSeparator = &value + return b +} + +// WithRoleClaim sets the RoleClaim field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the RoleClaim field is set to the value of the last call. +func (b *PackageManagerIdentityFederationConfigApplyConfiguration) WithRoleClaim(value string) *PackageManagerIdentityFederationConfigApplyConfiguration { + b.RoleClaim = &value + return b +} + +// WithRolesSeparator sets the RolesSeparator field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the RolesSeparator field is set to the value of the last call. +func (b *PackageManagerIdentityFederationConfigApplyConfiguration) WithRolesSeparator(value string) *PackageManagerIdentityFederationConfigApplyConfiguration { + b.RolesSeparator = &value + return b +} + +// WithUniqueIdClaim sets the UniqueIdClaim field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the UniqueIdClaim field is set to the value of the last call. +func (b *PackageManagerIdentityFederationConfigApplyConfiguration) WithUniqueIdClaim(value string) *PackageManagerIdentityFederationConfigApplyConfiguration { + b.UniqueIdClaim = &value + return b +} + +// WithUsernameClaim sets the UsernameClaim field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the UsernameClaim field is set to the value of the last call. +func (b *PackageManagerIdentityFederationConfigApplyConfiguration) WithUsernameClaim(value string) *PackageManagerIdentityFederationConfigApplyConfiguration { + b.UsernameClaim = &value + return b +} + // WithTokenLifetime sets the TokenLifetime field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the TokenLifetime field is set to the value of the last call. diff --git a/client-go/applyconfiguration/core/v1beta1/packagemanageroidcconfig.go b/client-go/applyconfiguration/core/v1beta1/packagemanageroidcconfig.go index 8147332e..aac85e68 100644 --- a/client-go/applyconfiguration/core/v1beta1/packagemanageroidcconfig.go +++ b/client-go/applyconfiguration/core/v1beta1/packagemanageroidcconfig.go @@ -8,13 +8,25 @@ package v1beta1 // PackageManagerOIDCConfigApplyConfiguration represents a declarative configuration of the PackageManagerOIDCConfig type for use // with apply. type PackageManagerOIDCConfigApplyConfiguration struct { - ClientId *string `json:"ClientId,omitempty"` - ClientSecret *string `json:"ClientSecret,omitempty"` - Issuer *string `json:"Issuer,omitempty"` - RequireLogin *bool `json:"RequireLogin,omitempty"` - Scope *string `json:"Scope,omitempty"` - GroupsClaim *string `json:"GroupsClaim,omitempty"` - RoleClaim *string `json:"RoleClaim,omitempty"` + ClientId *string `json:"ClientId,omitempty"` + ClientSecret *string `json:"ClientSecret,omitempty"` + ClientSecretFile *string `json:"ClientSecretFile,omitempty"` + Issuer *string `json:"Issuer,omitempty"` + RequireLogin *bool `json:"RequireLogin,omitempty"` + Logging *bool `json:"Logging,omitempty"` + Scope *string `json:"Scope,omitempty"` + CustomScope *string `json:"CustomScope,omitempty"` + NoAutoGroupsScope *bool `json:"NoAutoGroupsScope,omitempty"` + GroupsClaim *string `json:"GroupsClaim,omitempty"` + GroupsSeparator *string `json:"GroupsSeparator,omitempty"` + RoleClaim *string `json:"RoleClaim,omitempty"` + RolesSeparator *string `json:"RolesSeparator,omitempty"` + UniqueIdClaim *string `json:"UniqueIdClaim,omitempty"` + UsernameClaim *string `json:"UsernameClaim,omitempty"` + TokenLifetime *string `json:"TokenLifetime,omitempty"` + MaxAuthenticationAge *string `json:"MaxAuthenticationAge,omitempty"` + DisablePKCE *bool `json:"DisablePKCE,omitempty"` + EnableDevicePKCE *bool `json:"EnableDevicePKCE,omitempty"` } // PackageManagerOIDCConfigApplyConfiguration constructs a declarative configuration of the PackageManagerOIDCConfig type for use with @@ -39,6 +51,14 @@ func (b *PackageManagerOIDCConfigApplyConfiguration) WithClientSecret(value stri return b } +// WithClientSecretFile sets the ClientSecretFile field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ClientSecretFile field is set to the value of the last call. +func (b *PackageManagerOIDCConfigApplyConfiguration) WithClientSecretFile(value string) *PackageManagerOIDCConfigApplyConfiguration { + b.ClientSecretFile = &value + return b +} + // WithIssuer sets the Issuer field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the Issuer field is set to the value of the last call. @@ -55,6 +75,14 @@ func (b *PackageManagerOIDCConfigApplyConfiguration) WithRequireLogin(value bool return b } +// WithLogging sets the Logging field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Logging field is set to the value of the last call. +func (b *PackageManagerOIDCConfigApplyConfiguration) WithLogging(value bool) *PackageManagerOIDCConfigApplyConfiguration { + b.Logging = &value + return b +} + // WithScope sets the Scope field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the Scope field is set to the value of the last call. @@ -63,6 +91,22 @@ func (b *PackageManagerOIDCConfigApplyConfiguration) WithScope(value string) *Pa return b } +// WithCustomScope sets the CustomScope field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the CustomScope field is set to the value of the last call. +func (b *PackageManagerOIDCConfigApplyConfiguration) WithCustomScope(value string) *PackageManagerOIDCConfigApplyConfiguration { + b.CustomScope = &value + return b +} + +// WithNoAutoGroupsScope sets the NoAutoGroupsScope field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the NoAutoGroupsScope field is set to the value of the last call. +func (b *PackageManagerOIDCConfigApplyConfiguration) WithNoAutoGroupsScope(value bool) *PackageManagerOIDCConfigApplyConfiguration { + b.NoAutoGroupsScope = &value + return b +} + // WithGroupsClaim sets the GroupsClaim field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the GroupsClaim field is set to the value of the last call. @@ -71,6 +115,14 @@ func (b *PackageManagerOIDCConfigApplyConfiguration) WithGroupsClaim(value strin return b } +// WithGroupsSeparator sets the GroupsSeparator field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the GroupsSeparator field is set to the value of the last call. +func (b *PackageManagerOIDCConfigApplyConfiguration) WithGroupsSeparator(value string) *PackageManagerOIDCConfigApplyConfiguration { + b.GroupsSeparator = &value + return b +} + // WithRoleClaim sets the RoleClaim field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the RoleClaim field is set to the value of the last call. @@ -78,3 +130,59 @@ func (b *PackageManagerOIDCConfigApplyConfiguration) WithRoleClaim(value string) b.RoleClaim = &value return b } + +// WithRolesSeparator sets the RolesSeparator field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the RolesSeparator field is set to the value of the last call. +func (b *PackageManagerOIDCConfigApplyConfiguration) WithRolesSeparator(value string) *PackageManagerOIDCConfigApplyConfiguration { + b.RolesSeparator = &value + return b +} + +// WithUniqueIdClaim sets the UniqueIdClaim field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the UniqueIdClaim field is set to the value of the last call. +func (b *PackageManagerOIDCConfigApplyConfiguration) WithUniqueIdClaim(value string) *PackageManagerOIDCConfigApplyConfiguration { + b.UniqueIdClaim = &value + return b +} + +// WithUsernameClaim sets the UsernameClaim field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the UsernameClaim field is set to the value of the last call. +func (b *PackageManagerOIDCConfigApplyConfiguration) WithUsernameClaim(value string) *PackageManagerOIDCConfigApplyConfiguration { + b.UsernameClaim = &value + return b +} + +// WithTokenLifetime sets the TokenLifetime field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the TokenLifetime field is set to the value of the last call. +func (b *PackageManagerOIDCConfigApplyConfiguration) WithTokenLifetime(value string) *PackageManagerOIDCConfigApplyConfiguration { + b.TokenLifetime = &value + return b +} + +// WithMaxAuthenticationAge sets the MaxAuthenticationAge field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the MaxAuthenticationAge field is set to the value of the last call. +func (b *PackageManagerOIDCConfigApplyConfiguration) WithMaxAuthenticationAge(value string) *PackageManagerOIDCConfigApplyConfiguration { + b.MaxAuthenticationAge = &value + return b +} + +// WithDisablePKCE sets the DisablePKCE field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the DisablePKCE field is set to the value of the last call. +func (b *PackageManagerOIDCConfigApplyConfiguration) WithDisablePKCE(value bool) *PackageManagerOIDCConfigApplyConfiguration { + b.DisablePKCE = &value + return b +} + +// WithEnableDevicePKCE sets the EnableDevicePKCE field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the EnableDevicePKCE field is set to the value of the last call. +func (b *PackageManagerOIDCConfigApplyConfiguration) WithEnableDevicePKCE(value bool) *PackageManagerOIDCConfigApplyConfiguration { + b.EnableDevicePKCE = &value + return b +} diff --git a/client-go/applyconfiguration/core/v1beta1/workbenchspec.go b/client-go/applyconfiguration/core/v1beta1/workbenchspec.go index 2725cbd7..8a9a498d 100644 --- a/client-go/applyconfiguration/core/v1beta1/workbenchspec.go +++ b/client-go/applyconfiguration/core/v1beta1/workbenchspec.go @@ -50,6 +50,7 @@ type WorkbenchSpecApplyConfiguration struct { AuthenticatedRepos *bool `json:"authenticatedRepos,omitempty"` PPMAuthImage *string `json:"ppmAuthImage,omitempty"` PPMUrl *string `json:"ppmUrl,omitempty"` + PPMAuthAudience *string `json:"ppmAuthAudience,omitempty"` } // WorkbenchSpecApplyConfiguration constructs a declarative configuration of the WorkbenchSpec type for use with @@ -377,3 +378,11 @@ func (b *WorkbenchSpecApplyConfiguration) WithPPMUrl(value string) *WorkbenchSpe b.PPMUrl = &value return b } + +// WithPPMAuthAudience sets the PPMAuthAudience field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the PPMAuthAudience field is set to the value of the last call. +func (b *WorkbenchSpecApplyConfiguration) WithPPMAuthAudience(value string) *WorkbenchSpecApplyConfiguration { + b.PPMAuthAudience = &value + return b +} diff --git a/client-go/applyconfiguration/utils.go b/client-go/applyconfiguration/utils.go index 52bb4299..dbb7d774 100644 --- a/client-go/applyconfiguration/utils.go +++ b/client-go/applyconfiguration/utils.go @@ -125,6 +125,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &corev1beta1.InternalWorkbenchSpecApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("PackageManager"): return &corev1beta1.PackageManagerApplyConfiguration{} + case v1beta1.SchemeGroupVersion.WithKind("PackageManagerAuthenticationConfig"): + return &corev1beta1.PackageManagerAuthenticationConfigApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("PackageManagerConfig"): return &corev1beta1.PackageManagerConfigApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("PackageManagerCRANConfig"): From 0e6428f5e882942f91a70db05a12a5124d44d628 Mon Sep 17 00:00:00 2001 From: ian-flores Date: Wed, 4 Mar 2026 08:59:18 -0800 Subject: [PATCH 07/15] fix: set RunAsUser for PPM auth containers and add default OIDC scope alpine:3 runs as root by default, which fails pod security contexts that require runAsNonRoot. Set RunAsUser=65534 (nobody) on both init and sidecar containers. PPM requires Scope, RoleClaim, or GroupToScopeMapping when OIDC is configured. Default to "repos:read:*" in the site controller. --- .../packagemanagerauthenticationconfig.go | 71 +++++++++++++++++++ internal/controller/core/ppm_auth.go | 2 + internal/controller/core/ppm_auth_test.go | 10 +++ .../core/site_controller_package_manager.go | 1 + 4 files changed, 84 insertions(+) create mode 100644 client-go/applyconfiguration/core/v1beta1/packagemanagerauthenticationconfig.go diff --git a/client-go/applyconfiguration/core/v1beta1/packagemanagerauthenticationconfig.go b/client-go/applyconfiguration/core/v1beta1/packagemanagerauthenticationconfig.go new file mode 100644 index 00000000..24fca6f5 --- /dev/null +++ b/client-go/applyconfiguration/core/v1beta1/packagemanagerauthenticationconfig.go @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2023-2026 Posit Software, PBC + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1beta1 + +// PackageManagerAuthenticationConfigApplyConfiguration represents a declarative configuration of the PackageManagerAuthenticationConfig type for use +// with apply. +type PackageManagerAuthenticationConfigApplyConfiguration struct { + APITokenAuth *bool `json:"APITokenAuth,omitempty"` + DeviceAuthType *string `json:"DeviceAuthType,omitempty"` + NewReposAuthByDefault *bool `json:"NewReposAuthByDefault,omitempty"` + Lifetime *string `json:"Lifetime,omitempty"` + Inactivity *string `json:"Inactivity,omitempty"` + CookieSweepDuration *string `json:"CookieSweepDuration,omitempty"` +} + +// PackageManagerAuthenticationConfigApplyConfiguration constructs a declarative configuration of the PackageManagerAuthenticationConfig type for use with +// apply. +func PackageManagerAuthenticationConfig() *PackageManagerAuthenticationConfigApplyConfiguration { + return &PackageManagerAuthenticationConfigApplyConfiguration{} +} + +// WithAPITokenAuth sets the APITokenAuth field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the APITokenAuth field is set to the value of the last call. +func (b *PackageManagerAuthenticationConfigApplyConfiguration) WithAPITokenAuth(value bool) *PackageManagerAuthenticationConfigApplyConfiguration { + b.APITokenAuth = &value + return b +} + +// WithDeviceAuthType sets the DeviceAuthType field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the DeviceAuthType field is set to the value of the last call. +func (b *PackageManagerAuthenticationConfigApplyConfiguration) WithDeviceAuthType(value string) *PackageManagerAuthenticationConfigApplyConfiguration { + b.DeviceAuthType = &value + return b +} + +// WithNewReposAuthByDefault sets the NewReposAuthByDefault field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the NewReposAuthByDefault field is set to the value of the last call. +func (b *PackageManagerAuthenticationConfigApplyConfiguration) WithNewReposAuthByDefault(value bool) *PackageManagerAuthenticationConfigApplyConfiguration { + b.NewReposAuthByDefault = &value + return b +} + +// WithLifetime sets the Lifetime field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Lifetime field is set to the value of the last call. +func (b *PackageManagerAuthenticationConfigApplyConfiguration) WithLifetime(value string) *PackageManagerAuthenticationConfigApplyConfiguration { + b.Lifetime = &value + return b +} + +// WithInactivity sets the Inactivity field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Inactivity field is set to the value of the last call. +func (b *PackageManagerAuthenticationConfigApplyConfiguration) WithInactivity(value string) *PackageManagerAuthenticationConfigApplyConfiguration { + b.Inactivity = &value + return b +} + +// WithCookieSweepDuration sets the CookieSweepDuration field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the CookieSweepDuration field is set to the value of the last call. +func (b *PackageManagerAuthenticationConfigApplyConfiguration) WithCookieSweepDuration(value string) *PackageManagerAuthenticationConfigApplyConfiguration { + b.CookieSweepDuration = &value + return b +} diff --git a/internal/controller/core/ppm_auth.go b/internal/controller/core/ppm_auth.go index b6899fc6..4491502a 100644 --- a/internal/controller/core/ppm_auth.go +++ b/internal/controller/core/ppm_auth.go @@ -98,6 +98,7 @@ func PPMAuthInitContainer(image, ppmURL string) corev1.Container { VolumeMounts: ppmAuthContainerVolumeMounts(), SecurityContext: &corev1.SecurityContext{ RunAsNonRoot: ptr.To(true), + RunAsUser: ptr.To(int64(65534)), // nobody AllowPrivilegeEscalation: ptr.To(false), }, } @@ -123,6 +124,7 @@ func PPMAuthSidecarContainer(image, ppmURL, refreshInterval string) corev1.Conta VolumeMounts: ppmAuthContainerVolumeMounts(), SecurityContext: &corev1.SecurityContext{ RunAsNonRoot: ptr.To(true), + RunAsUser: ptr.To(int64(65534)), // nobody AllowPrivilegeEscalation: ptr.To(false), }, Resources: corev1.ResourceRequirements{ diff --git a/internal/controller/core/ppm_auth_test.go b/internal/controller/core/ppm_auth_test.go index caa917b5..50b39d4b 100644 --- a/internal/controller/core/ppm_auth_test.go +++ b/internal/controller/core/ppm_auth_test.go @@ -38,6 +38,11 @@ func TestPPMAuthInitContainer(t *testing.T) { require.Equal(t, "MODE", c.Env[1].Name) require.Equal(t, "init", c.Env[1].Value) require.Len(t, c.VolumeMounts, 3) + // Verify non-root security context (alpine:3 runs as root by default) + require.NotNil(t, c.SecurityContext) + require.NotNil(t, c.SecurityContext.RunAsUser) + require.Equal(t, int64(65534), *c.SecurityContext.RunAsUser) + require.True(t, *c.SecurityContext.RunAsNonRoot) } func TestPPMAuthInitContainerCustomImage(t *testing.T) { @@ -54,6 +59,11 @@ func TestPPMAuthSidecarContainer(t *testing.T) { require.Equal(t, "sidecar", c.Env[1].Value) require.Equal(t, "REFRESH_INTERVAL", c.Env[2].Name) require.Equal(t, "3000", c.Env[2].Value) + // Verify non-root security context (alpine:3 runs as root by default) + require.NotNil(t, c.SecurityContext) + require.NotNil(t, c.SecurityContext.RunAsUser) + require.Equal(t, int64(65534), *c.SecurityContext.RunAsUser) + require.True(t, *c.SecurityContext.RunAsNonRoot) } func TestPPMAuthSidecarContainerCustomRefresh(t *testing.T) { diff --git a/internal/controller/core/site_controller_package_manager.go b/internal/controller/core/site_controller_package_manager.go index 3eff5581..a1770a9a 100644 --- a/internal/controller/core/site_controller_package_manager.go +++ b/internal/controller/core/site_controller_package_manager.go @@ -125,6 +125,7 @@ func (r *SiteReconciler) reconcilePackageManager( ClientSecret: "/etc/rstudio-pm/oidc-client-secret", Issuer: site.Spec.PackageManager.Auth.Issuer, RequireLogin: true, + Scope: "repos:read:*", } if site.Spec.PackageManager.Auth.GroupsClaim != "" { pm.Spec.Config.OpenIDConnect.GroupsClaim = site.Spec.PackageManager.Auth.GroupsClaim From 4736a6950347d3f56820eae84c531126e16da0d4 Mon Sep 17 00:00:00 2001 From: ian-flores Date: Wed, 4 Mar 2026 10:24:49 -0800 Subject: [PATCH 08/15] fix: use wget+sed instead of curl+jq in token exchange script alpine:3 doesn't include curl or jq, and apk install requires root. Replace curl with wget (BusyBox built-in) and jq with a sed-based JSON field extractor for zero-dependency operation. --- internal/controller/core/ppm_auth.go | 22 +++++++++++++++------- internal/controller/core/ppm_auth_test.go | 4 +++- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/internal/controller/core/ppm_auth.go b/internal/controller/core/ppm_auth.go index 4491502a..be91e0cd 100644 --- a/internal/controller/core/ppm_auth.go +++ b/internal/controller/core/ppm_auth.go @@ -19,7 +19,7 @@ const ( ppmAuthNetrcPath = "/mnt/ppm-auth/netrc" ppmAuthTokenMountPath = "/var/run/secrets/ppm-auth" ppmAuthScriptMountPath = "/scripts" - // ppmAuthDefaultImage is expected to have curl and jq pre-installed + // ppmAuthDefaultImage uses alpine:3 which has wget and sed via BusyBox ppmAuthDefaultImage = "alpine:3" ppmAuthDefaultRefresh = "3000" // 50 minutes (for 60 min token lifetime) ) @@ -38,18 +38,26 @@ CURLRC_PATH="${CURLRC_PATH:-/mnt/ppm-auth/.curlrc}" PPM_URL="${PPM_URL}" REFRESH_INTERVAL="${REFRESH_INTERVAL:-3000}" +# extract_json_field extracts a string value from a JSON object using only +# shell builtins and sed. This avoids requiring jq in the container image. +# Usage: extract_json_field '{"key":"value"}' "key" +extract_json_field() { + echo "$1" | sed -n 's/.*"'"$2"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' +} + exchange_token() { SA_TOKEN=$(cat "$SA_TOKEN_PATH") - RESPONSE=$(curl -sf -X POST "${PPM_URL}/__api__/token" \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \ - -d "subject_token=${SA_TOKEN}" \ - -d "subject_token_type=urn:ietf:params:oauth:token-type:id_token") - PPM_TOKEN=$(echo "$RESPONSE" | jq -r '.access_token') + # Use wget (BusyBox built-in) instead of curl for zero-dependency operation + RESPONSE=$(wget -qO- --header="Content-Type: application/x-www-form-urlencoded" \ + --post-data="grant_type=urn:ietf:params:oauth:grant-type:token-exchange&subject_token=${SA_TOKEN}&subject_token_type=urn:ietf:params:oauth:token-type:id_token" \ + "${PPM_URL}/__api__/token") + + PPM_TOKEN=$(extract_json_field "$RESPONSE" "access_token") if [ -z "$PPM_TOKEN" ] || [ "$PPM_TOKEN" = "null" ]; then echo "ERROR: Failed to extract access_token from PPM response" >&2 + echo "Response was: $RESPONSE" >&2 exit 1 fi diff --git a/internal/controller/core/ppm_auth_test.go b/internal/controller/core/ppm_auth_test.go index 50b39d4b..38258af0 100644 --- a/internal/controller/core/ppm_auth_test.go +++ b/internal/controller/core/ppm_auth_test.go @@ -9,7 +9,7 @@ import ( func TestPPMAuthTokenExchangeScript(t *testing.T) { script := PPMAuthTokenExchangeScript() require.Contains(t, script, "exchange_token") - require.Contains(t, script, "curl") + require.Contains(t, script, "wget") require.Contains(t, script, "grant_type=urn:ietf:params:oauth:grant-type:token-exchange") require.Contains(t, script, "sidecar") require.Contains(t, script, "netrc") @@ -21,6 +21,8 @@ func TestPPMAuthTokenExchangeScript(t *testing.T) { require.Contains(t, script, `[ "$PPM_TOKEN" = "null" ]`) // Verify sidecar resilience require.Contains(t, script, "WARNING: token refresh failed, will retry") + // Verify extract_json_field helper is present + require.Contains(t, script, "extract_json_field") } func TestPPMAuthConfigMapName(t *testing.T) { From 662afca31d03b7c0903f5fadd251928b27ca835a Mon Sep 17 00:00:00 2001 From: ian-flores Date: Wed, 4 Mar 2026 11:13:43 -0800 Subject: [PATCH 09/15] fix: serialize IdentityFederation to JSON for K8s round-trip The field was tagged json:"-" which prevented it from being stored in the PackageManager CR. The site controller sets it in memory but it was lost when the PM controller read the CR back from the API. --- api/core/v1beta1/package_manager_config.go | 2 +- .../core/v1beta1/packagemanagerconfig.go | 42 ++++++++++++------- .../core.posit.team_packagemanagers.yaml | 35 ++++++++++++++++ 3 files changed, 64 insertions(+), 15 deletions(-) diff --git a/api/core/v1beta1/package_manager_config.go b/api/core/v1beta1/package_manager_config.go index 357cbc90..a034f812 100644 --- a/api/core/v1beta1/package_manager_config.go +++ b/api/core/v1beta1/package_manager_config.go @@ -20,7 +20,7 @@ type PackageManagerConfig struct { Debug *PackageManagerDebugConfig `json:"Debug,omitempty"` Authentication *PackageManagerAuthenticationConfig `json:"Authentication,omitempty"` OpenIDConnect *PackageManagerOIDCConfig `json:"OpenIDConnect,omitempty"` - IdentityFederation []PackageManagerIdentityFederationConfig `json:"-"` + IdentityFederation []PackageManagerIdentityFederationConfig `json:"identityFederation,omitempty"` // AdditionalConfig allows appending arbitrary gcfg config content not covered by typed fields. // The value is appended verbatim after the generated config. gcfg parsing naturally handles diff --git a/client-go/applyconfiguration/core/v1beta1/packagemanagerconfig.go b/client-go/applyconfiguration/core/v1beta1/packagemanagerconfig.go index 545182e8..7e9ba640 100644 --- a/client-go/applyconfiguration/core/v1beta1/packagemanagerconfig.go +++ b/client-go/applyconfiguration/core/v1beta1/packagemanagerconfig.go @@ -8,20 +8,21 @@ package v1beta1 // PackageManagerConfigApplyConfiguration represents a declarative configuration of the PackageManagerConfig type for use // with apply. type PackageManagerConfigApplyConfiguration struct { - Server *PackageManagerServerConfigApplyConfiguration `json:"Server,omitempty"` - Http *PackageManagerHttpConfigApplyConfiguration `json:"Http,omitempty"` - Git *PackageManagerGitConfigApplyConfiguration `json:"Git,omitempty"` - Database *PackageManagerDatabaseConfigApplyConfiguration `json:"Database,omitempty"` - Postgres *PackageManagerPostgresConfigApplyConfiguration `json:"Postgres,omitempty"` - Storage *PackageManagerStorageConfigApplyConfiguration `json:"Storage,omitempty"` - S3Storage *PackageManagerS3StorageConfigApplyConfiguration `json:"S3Storage,omitempty"` - Metrics *PackageManagerMetricsConfigApplyConfiguration `json:"Metrics,omitempty"` - Repos *PackageManagerReposConfigApplyConfiguration `json:"Repos,omitempty"` - Cran *PackageManagerCRANConfigApplyConfiguration `json:"CRAN,omitempty"` - Debug *PackageManagerDebugConfigApplyConfiguration `json:"Debug,omitempty"` - Authentication *PackageManagerAuthenticationConfigApplyConfiguration `json:"Authentication,omitempty"` - OpenIDConnect *PackageManagerOIDCConfigApplyConfiguration `json:"OpenIDConnect,omitempty"` - AdditionalConfig *string `json:"additionalConfig,omitempty"` + Server *PackageManagerServerConfigApplyConfiguration `json:"Server,omitempty"` + Http *PackageManagerHttpConfigApplyConfiguration `json:"Http,omitempty"` + Git *PackageManagerGitConfigApplyConfiguration `json:"Git,omitempty"` + Database *PackageManagerDatabaseConfigApplyConfiguration `json:"Database,omitempty"` + Postgres *PackageManagerPostgresConfigApplyConfiguration `json:"Postgres,omitempty"` + Storage *PackageManagerStorageConfigApplyConfiguration `json:"Storage,omitempty"` + S3Storage *PackageManagerS3StorageConfigApplyConfiguration `json:"S3Storage,omitempty"` + Metrics *PackageManagerMetricsConfigApplyConfiguration `json:"Metrics,omitempty"` + Repos *PackageManagerReposConfigApplyConfiguration `json:"Repos,omitempty"` + Cran *PackageManagerCRANConfigApplyConfiguration `json:"CRAN,omitempty"` + Debug *PackageManagerDebugConfigApplyConfiguration `json:"Debug,omitempty"` + Authentication *PackageManagerAuthenticationConfigApplyConfiguration `json:"Authentication,omitempty"` + OpenIDConnect *PackageManagerOIDCConfigApplyConfiguration `json:"OpenIDConnect,omitempty"` + IdentityFederation []PackageManagerIdentityFederationConfigApplyConfiguration `json:"identityFederation,omitempty"` + AdditionalConfig *string `json:"additionalConfig,omitempty"` } // PackageManagerConfigApplyConfiguration constructs a declarative configuration of the PackageManagerConfig type for use with @@ -134,6 +135,19 @@ func (b *PackageManagerConfigApplyConfiguration) WithOpenIDConnect(value *Packag return b } +// WithIdentityFederation adds the given value to the IdentityFederation field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the IdentityFederation field. +func (b *PackageManagerConfigApplyConfiguration) WithIdentityFederation(values ...*PackageManagerIdentityFederationConfigApplyConfiguration) *PackageManagerConfigApplyConfiguration { + for i := range values { + if values[i] == nil { + panic("nil value passed to WithIdentityFederation") + } + b.IdentityFederation = append(b.IdentityFederation, *values[i]) + } + return b +} + // WithAdditionalConfig sets the AdditionalConfig field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the AdditionalConfig field is set to the value of the last call. diff --git a/config/crd/bases/core.posit.team_packagemanagers.yaml b/config/crd/bases/core.posit.team_packagemanagers.yaml index 23f682dc..dab3c049 100644 --- a/config/crd/bases/core.posit.team_packagemanagers.yaml +++ b/config/crd/bases/core.posit.team_packagemanagers.yaml @@ -216,6 +216,41 @@ spec: The value is appended verbatim after the generated config. gcfg parsing naturally handles conflicts: list values are combined, scalar values use the last occurrence. type: string + identityFederation: + items: + properties: + Audience: + type: string + AuthorizedParty: + type: string + CustomScope: + type: string + GroupsClaim: + type: string + GroupsSeparator: + type: string + Issuer: + type: string + Logging: + type: boolean + NoAutoGroupsScope: + type: boolean + RoleClaim: + type: string + RolesSeparator: + type: string + Scope: + type: string + Subject: + type: string + TokenLifetime: + type: string + UniqueIdClaim: + type: string + UsernameClaim: + type: string + type: object + type: array type: object databaseConfig: properties: From 9815e5da53f321a5a96f21e3462996a005159b2b Mon Sep 17 00:00:00 2001 From: ian-flores Date: Wed, 4 Mar 2026 11:57:19 -0800 Subject: [PATCH 10/15] fix: regenerate Helm chart after IdentityFederation field change --- .../crd/core.posit.team_packagemanagers.yaml | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/dist/chart/templates/crd/core.posit.team_packagemanagers.yaml b/dist/chart/templates/crd/core.posit.team_packagemanagers.yaml index fd1d978c..b52ce973 100755 --- a/dist/chart/templates/crd/core.posit.team_packagemanagers.yaml +++ b/dist/chart/templates/crd/core.posit.team_packagemanagers.yaml @@ -237,6 +237,41 @@ spec: The value is appended verbatim after the generated config. gcfg parsing naturally handles conflicts: list values are combined, scalar values use the last occurrence. type: string + identityFederation: + items: + properties: + Audience: + type: string + AuthorizedParty: + type: string + CustomScope: + type: string + GroupsClaim: + type: string + GroupsSeparator: + type: string + Issuer: + type: string + Logging: + type: boolean + NoAutoGroupsScope: + type: boolean + RoleClaim: + type: string + RolesSeparator: + type: string + Scope: + type: string + Subject: + type: string + TokenLifetime: + type: string + UniqueIdClaim: + type: string + UsernameClaim: + type: string + type: object + type: array type: object databaseConfig: properties: From 538d07f827cc2be32eb764c9433c59cb96429893 Mon Sep 17 00:00:00 2001 From: ian-flores Date: Wed, 4 Mar 2026 13:34:58 -0800 Subject: [PATCH 11/15] fix: serialize IdentityFederation Name field for K8s round-trip The Name field was tagged json:"-" so it was lost during Kubernetes API serialization. This caused empty section names in the generated gcfg: [IdentityFederation ""] instead of [IdentityFederation "connect"]. --- api/core/v1beta1/package_manager_config.go | 2 +- .../v1beta1/packagemanageridentityfederationconfig.go | 9 +++++++++ config/crd/bases/core.posit.team_packagemanagers.yaml | 2 ++ .../templates/crd/core.posit.team_packagemanagers.yaml | 2 ++ 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/api/core/v1beta1/package_manager_config.go b/api/core/v1beta1/package_manager_config.go index a034f812..eeb95c8c 100644 --- a/api/core/v1beta1/package_manager_config.go +++ b/api/core/v1beta1/package_manager_config.go @@ -269,7 +269,7 @@ type PackageManagerOIDCConfig struct { } type PackageManagerIdentityFederationConfig struct { - Name string `json:"-"` + Name string `json:"name,omitempty"` Issuer string `json:"Issuer,omitempty"` Logging bool `json:"Logging,omitempty"` Audience string `json:"Audience,omitempty"` diff --git a/client-go/applyconfiguration/core/v1beta1/packagemanageridentityfederationconfig.go b/client-go/applyconfiguration/core/v1beta1/packagemanageridentityfederationconfig.go index ca7d7d18..32072f85 100644 --- a/client-go/applyconfiguration/core/v1beta1/packagemanageridentityfederationconfig.go +++ b/client-go/applyconfiguration/core/v1beta1/packagemanageridentityfederationconfig.go @@ -8,6 +8,7 @@ package v1beta1 // PackageManagerIdentityFederationConfigApplyConfiguration represents a declarative configuration of the PackageManagerIdentityFederationConfig type for use // with apply. type PackageManagerIdentityFederationConfigApplyConfiguration struct { + Name *string `json:"name,omitempty"` Issuer *string `json:"Issuer,omitempty"` Logging *bool `json:"Logging,omitempty"` Audience *string `json:"Audience,omitempty"` @@ -31,6 +32,14 @@ func PackageManagerIdentityFederationConfig() *PackageManagerIdentityFederationC return &PackageManagerIdentityFederationConfigApplyConfiguration{} } +// WithName sets the Name field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Name field is set to the value of the last call. +func (b *PackageManagerIdentityFederationConfigApplyConfiguration) WithName(value string) *PackageManagerIdentityFederationConfigApplyConfiguration { + b.Name = &value + return b +} + // WithIssuer sets the Issuer field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the Issuer field is set to the value of the last call. diff --git a/config/crd/bases/core.posit.team_packagemanagers.yaml b/config/crd/bases/core.posit.team_packagemanagers.yaml index dab3c049..0ccfd9f4 100644 --- a/config/crd/bases/core.posit.team_packagemanagers.yaml +++ b/config/crd/bases/core.posit.team_packagemanagers.yaml @@ -249,6 +249,8 @@ spec: type: string UsernameClaim: type: string + name: + type: string type: object type: array type: object diff --git a/dist/chart/templates/crd/core.posit.team_packagemanagers.yaml b/dist/chart/templates/crd/core.posit.team_packagemanagers.yaml index b52ce973..082dbaae 100755 --- a/dist/chart/templates/crd/core.posit.team_packagemanagers.yaml +++ b/dist/chart/templates/crd/core.posit.team_packagemanagers.yaml @@ -270,6 +270,8 @@ spec: type: string UsernameClaim: type: string + name: + type: string type: object type: array type: object From 052e816b3bd4ef52821fcf9161c5e74f879ce4d7 Mon Sep 17 00:00:00 2001 From: ian-flores Date: Wed, 4 Mar 2026 14:00:42 -0800 Subject: [PATCH 12/15] fix: use ClientSecretFile and set UsernameClaim for Identity Federation PPM treats ClientSecret as a literal value, not a file path. Use ClientSecretFile instead so PPM reads the secret from the mounted file. K8s SA tokens don't have a preferred_username claim. Set UniqueIdClaim and UsernameClaim to "sub" for Identity Federation entries. --- .../core/site_controller_package_manager.go | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/internal/controller/core/site_controller_package_manager.go b/internal/controller/core/site_controller_package_manager.go index a1770a9a..8bef064b 100644 --- a/internal/controller/core/site_controller_package_manager.go +++ b/internal/controller/core/site_controller_package_manager.go @@ -121,8 +121,8 @@ func (r *SiteReconciler) reconcilePackageManager( // Propagate OIDC authentication configuration if site.Spec.PackageManager.Auth != nil && site.Spec.PackageManager.Auth.Type == v1beta1.AuthTypeOidc { pm.Spec.Config.OpenIDConnect = &v1beta1.PackageManagerOIDCConfig{ - ClientId: site.Spec.PackageManager.Auth.ClientId, - ClientSecret: "/etc/rstudio-pm/oidc-client-secret", + ClientId: site.Spec.PackageManager.Auth.ClientId, + ClientSecretFile: "/etc/rstudio-pm/oidc-client-secret", Issuer: site.Spec.PackageManager.Auth.Issuer, RequireLogin: true, Scope: "repos:read:*", @@ -143,20 +143,24 @@ func (r *SiteReconciler) reconcilePackageManager( } if site.Spec.Connect.AuthenticatedRepos { idfEntries = append(idfEntries, v1beta1.PackageManagerIdentityFederationConfig{ - Name: "connect", - Issuer: site.Spec.OIDCIssuerURL, - Audience: audience, - Subject: fmt.Sprintf("system:serviceaccount:%s:%s-connect", req.Namespace, req.Name), - Scope: "repos:read:*", + Name: "connect", + Issuer: site.Spec.OIDCIssuerURL, + Audience: audience, + Subject: fmt.Sprintf("system:serviceaccount:%s:%s-connect", req.Namespace, req.Name), + Scope: "repos:read:*", + UniqueIdClaim: "sub", + UsernameClaim: "sub", }) } if site.Spec.Workbench.AuthenticatedRepos { idfEntries = append(idfEntries, v1beta1.PackageManagerIdentityFederationConfig{ - Name: "workbench", - Issuer: site.Spec.OIDCIssuerURL, - Audience: audience, - Subject: fmt.Sprintf("system:serviceaccount:%s:%s-workbench", req.Namespace, req.Name), - Scope: "repos:read:*", + Name: "workbench", + Issuer: site.Spec.OIDCIssuerURL, + Audience: audience, + Subject: fmt.Sprintf("system:serviceaccount:%s:%s-workbench", req.Namespace, req.Name), + Scope: "repos:read:*", + UniqueIdClaim: "sub", + UsernameClaim: "sub", }) } } From 76e03dd11b3fe1aef8df43ed7b7761045a432555 Mon Sep 17 00:00:00 2001 From: ian-flores Date: Wed, 4 Mar 2026 14:10:27 -0800 Subject: [PATCH 13/15] style: fix gofmt alignment in package manager OIDC config --- internal/controller/core/site_controller_package_manager.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/controller/core/site_controller_package_manager.go b/internal/controller/core/site_controller_package_manager.go index 8bef064b..563ece2b 100644 --- a/internal/controller/core/site_controller_package_manager.go +++ b/internal/controller/core/site_controller_package_manager.go @@ -123,9 +123,9 @@ func (r *SiteReconciler) reconcilePackageManager( pm.Spec.Config.OpenIDConnect = &v1beta1.PackageManagerOIDCConfig{ ClientId: site.Spec.PackageManager.Auth.ClientId, ClientSecretFile: "/etc/rstudio-pm/oidc-client-secret", - Issuer: site.Spec.PackageManager.Auth.Issuer, - RequireLogin: true, - Scope: "repos:read:*", + Issuer: site.Spec.PackageManager.Auth.Issuer, + RequireLogin: true, + Scope: "repos:read:*", } if site.Spec.PackageManager.Auth.GroupsClaim != "" { pm.Spec.Config.OpenIDConnect.GroupsClaim = site.Spec.PackageManager.Auth.GroupsClaim From d898d58b3e8cecd1bdf674a7da50a5b80139b6dd Mon Sep 17 00:00:00 2001 From: ian-flores Date: Wed, 4 Mar 2026 15:37:14 -0800 Subject: [PATCH 14/15] Address review findings (job 692) All builds pass and tests are green. Findings #1 (PPMAuthImage propagation), #2 (cache files), and #3 (apk add stderr) were already addressed in prior commits. I fixed the two remaining issues. Changes: - `SanitizePPMUrl` now returns empty string for empty input instead of invalid `"https://"` - Added edge case tests for `SanitizePPMUrl`: empty string and URL with port/path --- internal/controller/core/ppm_auth.go | 4 ++++ internal/controller/core/ppm_auth_test.go | 2 ++ 2 files changed, 6 insertions(+) diff --git a/internal/controller/core/ppm_auth.go b/internal/controller/core/ppm_auth.go index be91e0cd..cdcf5f99 100644 --- a/internal/controller/core/ppm_auth.go +++ b/internal/controller/core/ppm_auth.go @@ -241,7 +241,11 @@ func PPMAuthEnvVars() []corev1.EnvVar { } // SanitizePPMUrl strips any existing scheme from the URL and prepends https:// +// Returns an empty string if the input is empty. func SanitizePPMUrl(rawUrl string) string { host := strings.TrimPrefix(strings.TrimPrefix(rawUrl, "https://"), "http://") + if host == "" { + return "" + } return fmt.Sprintf("https://%s", host) } diff --git a/internal/controller/core/ppm_auth_test.go b/internal/controller/core/ppm_auth_test.go index 38258af0..61982e05 100644 --- a/internal/controller/core/ppm_auth_test.go +++ b/internal/controller/core/ppm_auth_test.go @@ -114,4 +114,6 @@ func TestSanitizePPMUrl(t *testing.T) { require.Equal(t, "https://ppm.example.com", SanitizePPMUrl("ppm.example.com")) require.Equal(t, "https://ppm.example.com", SanitizePPMUrl("https://ppm.example.com")) require.Equal(t, "https://ppm.example.com", SanitizePPMUrl("http://ppm.example.com")) + require.Equal(t, "", SanitizePPMUrl("")) + require.Equal(t, "https://ppm.example.com:8080/api", SanitizePPMUrl("ppm.example.com:8080/api")) } From 564684ce498adbea07f656916b1cc5b9637501a6 Mon Sep 17 00:00:00 2001 From: Ian Flores Siaca Date: Fri, 6 Mar 2026 09:47:14 -0800 Subject: [PATCH 15/15] fix: address code review findings for PPM authenticated repos --- .../v1beta1/_assets/usr/local/bin/pwb-psql | 20 ------ internal/controller/core/connect.go | 27 +++----- internal/controller/core/ppm_auth.go | 63 ++++++++++++++++++- internal/controller/core/ppm_auth_test.go | 2 +- internal/controller/core/site_controller.go | 9 +++ .../core/site_controller_package_manager.go | 3 + internal/controller/core/workbench.go | 27 +++----- 7 files changed, 93 insertions(+), 58 deletions(-) delete mode 100755 api/core/v1beta1/_assets/usr/local/bin/pwb-psql diff --git a/api/core/v1beta1/_assets/usr/local/bin/pwb-psql b/api/core/v1beta1/_assets/usr/local/bin/pwb-psql deleted file mode 100755 index 61b65aa1..00000000 --- a/api/core/v1beta1/_assets/usr/local/bin/pwb-psql +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -o errexit -set -o pipefail - -main() { - : "${DATABASE_CONF:=/mnt/secure-config/rstudio/database.conf}" - - local pguser pgpass pghost pgdatabase - - pguser="$(awk -F= '/^username/ { print $NF }' "${DATABASE_CONF}")" - pghost="$(awk -F= '/^host/ { print $NF }' "${DATABASE_CONF}")" - pgpass="$(printenv WORKBENCH_POSTGRES_PASSWORD)" - pgdatabase="$(awk -F= '/^database/ { print $NF }' "${DATABASE_CONF}")" - - apt update -y - apt install -y postgresql-client - exec psql -d "postgres://${pguser}:${pgpass}@${pghost}:5432/${pgdatabase}" -} - -main "${@}" diff --git a/internal/controller/core/connect.go b/internal/controller/core/connect.go index a472c7da..d607ff83 100644 --- a/internal/controller/core/connect.go +++ b/internal/controller/core/connect.go @@ -587,23 +587,16 @@ func (r *ConnectReconciler) ensureDeployedService(ctx context.Context, req ctrl. secretVolumeFactory := c.CreateSecretVolumeFactory(configCopy) // PPM authenticated repos support - var ppmAuthVolumes []corev1.Volume - var ppmAuthVolumeMounts []corev1.VolumeMount - var ppmAuthEnvVars []corev1.EnvVar - var ppmAuthInitContainers []corev1.Container - var ppmAuthSidecarContainers []corev1.Container - if c.Spec.AuthenticatedRepos { - ppmURL := SanitizePPMUrl(c.Spec.PPMUrl) - ppmAuthVolumes = PPMAuthVolumes(c.SiteName(), ppmURL, c.Spec.PPMAuthAudience) - ppmAuthVolumeMounts = PPMAuthVolumeMounts() - ppmAuthEnvVars = PPMAuthEnvVars() - ppmAuthInitContainers = []corev1.Container{ - PPMAuthInitContainer(c.Spec.PPMAuthImage, ppmURL), - } - ppmAuthSidecarContainers = []corev1.Container{ - PPMAuthSidecarContainer(c.Spec.PPMAuthImage, ppmURL, ""), - } - } + ppmAuthVolumes, ppmAuthVolumeMounts, ppmAuthEnvVars, ppmAuthInitContainers, ppmAuthSidecarContainers := UnpackPPMAuthSetup( + SetupPPMAuth( + c.Spec.AuthenticatedRepos, + c.Spec.PPMUrl, + c.Spec.PPMAuthImage, + c.Spec.PPMAuthAudience, + c.SiteName(), + l, + ), + ) var chronicleSeededEnv []corev1.EnvVar if c.Spec.ChronicleSidecarProductApiKeyEnabled { diff --git a/internal/controller/core/ppm_auth.go b/internal/controller/core/ppm_auth.go index cdcf5f99..e4fbca0d 100644 --- a/internal/controller/core/ppm_auth.go +++ b/internal/controller/core/ppm_auth.go @@ -48,16 +48,23 @@ extract_json_field() { exchange_token() { SA_TOKEN=$(cat "$SA_TOKEN_PATH") + # Write POST data to a temp file to avoid exposing token in process args + POST_DATA_FILE=$(mktemp) + printf "grant_type=urn:ietf:params:oauth:grant-type:token-exchange&subject_token=%s&subject_token_type=urn:ietf:params:oauth:token-type:id_token" "$SA_TOKEN" > "$POST_DATA_FILE" + # Use wget (BusyBox built-in) instead of curl for zero-dependency operation RESPONSE=$(wget -qO- --header="Content-Type: application/x-www-form-urlencoded" \ - --post-data="grant_type=urn:ietf:params:oauth:grant-type:token-exchange&subject_token=${SA_TOKEN}&subject_token_type=urn:ietf:params:oauth:token-type:id_token" \ + --post-file="$POST_DATA_FILE" \ "${PPM_URL}/__api__/token") + # Clean up temp file immediately + rm -f "$POST_DATA_FILE" + PPM_TOKEN=$(extract_json_field "$RESPONSE" "access_token") if [ -z "$PPM_TOKEN" ] || [ "$PPM_TOKEN" = "null" ]; then echo "ERROR: Failed to extract access_token from PPM response" >&2 - echo "Response was: $RESPONSE" >&2 + echo "Response length: ${#RESPONSE} bytes" >&2 exit 1 fi @@ -172,7 +179,7 @@ func ppmAuthContainerVolumeMounts() []corev1.VolumeMount { // 1. Projected SA token volume (for K8s Identity Federation) // 2. Shared emptyDir for netrc file // 3. ConfigMap volume with the token exchange script -func PPMAuthVolumes(siteName, ppmURL, audience string) []corev1.Volume { +func PPMAuthVolumes(siteName, audience string) []corev1.Volume { return []corev1.Volume{ { Name: ppmAuthTokenVolumeName, @@ -249,3 +256,53 @@ func SanitizePPMUrl(rawUrl string) string { } return fmt.Sprintf("https://%s", host) } + +// PPMAuthSetup contains the volumes, mounts, env vars, and containers needed for PPM auth +type PPMAuthSetup struct { + Volumes []corev1.Volume + VolumeMounts []corev1.VolumeMount + EnvVars []corev1.EnvVar + InitContainers []corev1.Container + SidecarContainer []corev1.Container +} + +// SetupPPMAuth configures PPM authenticated repos for a product if enabled. +// Returns empty setup if AuthenticatedRepos is false or PPMUrl is empty. +// Logs a warning if AuthenticatedRepos is true but PPMUrl is empty. +func SetupPPMAuth(authenticatedRepos bool, ppmURL, ppmAuthImage, ppmAuthAudience, siteName string, logger interface { + Info(msg string, keysAndValues ...interface{}) +}) PPMAuthSetup { + if !authenticatedRepos { + return PPMAuthSetup{} + } + + sanitizedURL := SanitizePPMUrl(ppmURL) + if sanitizedURL == "" { + logger.Info("AuthenticatedRepos is enabled but PPMUrl is empty; skipping PPM auth setup") + return PPMAuthSetup{} + } + + return PPMAuthSetup{ + Volumes: PPMAuthVolumes(siteName, ppmAuthAudience), + VolumeMounts: PPMAuthVolumeMounts(), + EnvVars: PPMAuthEnvVars(), + InitContainers: []corev1.Container{ + PPMAuthInitContainer(ppmAuthImage, sanitizedURL), + }, + SidecarContainer: []corev1.Container{ + PPMAuthSidecarContainer(ppmAuthImage, sanitizedURL, ""), + }, + } +} + +// UnpackPPMAuthSetup unpacks a PPMAuthSetup struct into individual slices for use in pod specs. +// This helper reduces duplication in Connect and Workbench reconcilers. +func UnpackPPMAuthSetup(setup PPMAuthSetup) ( + volumes []corev1.Volume, + volumeMounts []corev1.VolumeMount, + envVars []corev1.EnvVar, + initContainers []corev1.Container, + sidecarContainers []corev1.Container, +) { + return setup.Volumes, setup.VolumeMounts, setup.EnvVars, setup.InitContainers, setup.SidecarContainer +} diff --git a/internal/controller/core/ppm_auth_test.go b/internal/controller/core/ppm_auth_test.go index 61982e05..b6e9b6a9 100644 --- a/internal/controller/core/ppm_auth_test.go +++ b/internal/controller/core/ppm_auth_test.go @@ -74,7 +74,7 @@ func TestPPMAuthSidecarContainerCustomRefresh(t *testing.T) { } func TestPPMAuthVolumes(t *testing.T) { - vols := PPMAuthVolumes("mysite", "https://packagemanager.example.com", "sts.amazonaws.com") + vols := PPMAuthVolumes("mysite", "sts.amazonaws.com") require.Len(t, vols, 3) // Projected SA token volume diff --git a/internal/controller/core/site_controller.go b/internal/controller/core/site_controller.go index 26cc9bd1..4ee15338 100644 --- a/internal/controller/core/site_controller.go +++ b/internal/controller/core/site_controller.go @@ -511,6 +511,8 @@ func (r *SiteReconciler) reconcilePPMAuthConfigMap(ctx context.Context, req ctrl } func (r *SiteReconciler) cleanupPPMAuthConfigMap(ctx context.Context, req ctrl.Request, site *positcov1beta1.Site) error { + l := r.GetLogger(ctx).WithValues("event", "cleanup-ppm-auth-configmap") + cm := &corev1.ConfigMap{} key := client.ObjectKey{Name: PPMAuthConfigMapName(site.Name), Namespace: req.Namespace} if err := r.Get(ctx, key, cm); err != nil { @@ -519,6 +521,13 @@ func (r *SiteReconciler) cleanupPPMAuthConfigMap(ctx context.Context, req ctrl.R } return err } + + // Only delete if we own it (check ManagedByLabelKey) + if cm.Labels[positcov1beta1.ManagedByLabelKey] != positcov1beta1.ManagedByLabelValue { + l.Info("Skipping deletion of PPM auth ConfigMap - not managed by operator", "configmap", cm.Name, "label", cm.Labels[positcov1beta1.ManagedByLabelKey]) + return nil + } + return r.Delete(ctx, cm) } diff --git a/internal/controller/core/site_controller_package_manager.go b/internal/controller/core/site_controller_package_manager.go index 563ece2b..ca3a523a 100644 --- a/internal/controller/core/site_controller_package_manager.go +++ b/internal/controller/core/site_controller_package_manager.go @@ -140,6 +140,7 @@ func (r *SiteReconciler) reconcilePackageManager( audience := site.Spec.OIDCAudience if audience == "" { audience = "sts.amazonaws.com" + l.Info("Using default OIDC audience for EKS clusters", "audience", audience, "reason", "OIDCAudience not specified in Site spec") } if site.Spec.Connect.AuthenticatedRepos { idfEntries = append(idfEntries, v1beta1.PackageManagerIdentityFederationConfig{ @@ -163,6 +164,8 @@ func (r *SiteReconciler) reconcilePackageManager( UsernameClaim: "sub", }) } + } else if site.Spec.Connect.AuthenticatedRepos || site.Spec.Workbench.AuthenticatedRepos { + l.Info("AuthenticatedRepos is enabled but OIDCIssuerURL is empty; Identity Federation will not be configured") } if len(idfEntries) > 0 { pm.Spec.Config.IdentityFederation = idfEntries diff --git a/internal/controller/core/workbench.go b/internal/controller/core/workbench.go index adb9cc71..707b4ecd 100644 --- a/internal/controller/core/workbench.go +++ b/internal/controller/core/workbench.go @@ -746,23 +746,16 @@ func (r *WorkbenchReconciler) ensureDeployedService(ctx context.Context, req ctr workbenchSecretVolumeFactory := w.CreateSecretVolumeFactory() // PPM authenticated repos support - var ppmAuthVolumes []corev1.Volume - var ppmAuthVolumeMounts []corev1.VolumeMount - var ppmAuthEnvVars []corev1.EnvVar - var ppmAuthInitContainers []corev1.Container - var ppmAuthSidecarContainers []corev1.Container - if w.Spec.AuthenticatedRepos { - ppmURL := SanitizePPMUrl(w.Spec.PPMUrl) - ppmAuthVolumes = PPMAuthVolumes(w.SiteName(), ppmURL, w.Spec.PPMAuthAudience) - ppmAuthVolumeMounts = PPMAuthVolumeMounts() - ppmAuthEnvVars = PPMAuthEnvVars() - ppmAuthInitContainers = []corev1.Container{ - PPMAuthInitContainer(w.Spec.PPMAuthImage, ppmURL), - } - ppmAuthSidecarContainers = []corev1.Container{ - PPMAuthSidecarContainer(w.Spec.PPMAuthImage, ppmURL, ""), - } - } + ppmAuthVolumes, ppmAuthVolumeMounts, ppmAuthEnvVars, ppmAuthInitContainers, ppmAuthSidecarContainers := UnpackPPMAuthSetup( + SetupPPMAuth( + w.Spec.AuthenticatedRepos, + w.Spec.PPMUrl, + w.Spec.PPMAuthImage, + w.Spec.PPMAuthAudience, + w.SiteName(), + l, + ), + ) var chronicleSeededEnv []corev1.EnvVar if w.Spec.ChronicleSidecarProductApiKeyEnabled {