From becedae05901966430acb2aa745670210e429648 Mon Sep 17 00:00:00 2001 From: Elliot Murphy Date: Thu, 22 Jan 2026 23:02:13 -0500 Subject: [PATCH 1/8] feat: add support for workbench custom oauth integrations Adds support for configuring custom OAuth integrations in Workbench by implementing the oauth-clients.conf configuration file format. This feature allows users to configure multiple OAuth integrations (Box, Dropbox, Google Drive, etc.) for their Workbench instances, enabling data access from external APIs. Changes: - Add WorkbenchOAuthClientConfig struct to define OAuth client configuration - Add OAuthClients map field to WorkbenchSecretIniConfig - Add FetchAndSetOAuthClientSecrets() method to fetch secrets from vault - Integrate OAuth secret fetching into Workbench reconciliation - Add OAuth secrets to AWS SecretProviderClass - Generate CRD manifests and client code The implementation follows the exact same pattern as the existing Databricks integration: - Map keys become INI section headers - Existing reflection-based GenerateConfigMap() handles INI generation automatically - Secrets fetched using same pattern as Databricks - File mounted via existing secret-config volume at /mnt/secure-config/rstudio/oauth-clients.conf Fixes #46 Co-Authored-By: Claude --- api/core/v1beta1/workbench_config.go | 17 +++- api/core/v1beta1/zz_generated.deepcopy.go | 36 ++++++++ .../v1beta1/workbenchoauthclientconfig.go | 82 +++++++++++++++++++ .../core/v1beta1/workbenchsecretconfig.go | 15 ++++ .../core/v1beta1/workbenchsecretiniconfig.go | 21 ++++- client-go/applyconfiguration/utils.go | 2 + .../bases/core.posit.team_workbenches.yaml | 21 +++++ internal/controller/core/workbench.go | 43 ++++++++++ 8 files changed, 231 insertions(+), 6 deletions(-) create mode 100644 client-go/applyconfiguration/core/v1beta1/workbenchoauthclientconfig.go diff --git a/api/core/v1beta1/workbench_config.go b/api/core/v1beta1/workbench_config.go index 9c794d0..785fd12 100644 --- a/api/core/v1beta1/workbench_config.go +++ b/api/core/v1beta1/workbench_config.go @@ -29,9 +29,10 @@ type WorkbenchSecretConfig struct { } type WorkbenchSecretIniConfig struct { - Database *WorkbenchDatabaseConfig `json:"database.conf,omitempty"` - OpenidClientSecret *WorkbenchOpenidClientSecret `json:"openid-client-secret,omitempty"` - Databricks map[string]*WorkbenchDatabricksConfig `json:"databricks.conf,omitempty"` + Database *WorkbenchDatabaseConfig `json:"database.conf,omitempty"` + OpenidClientSecret *WorkbenchOpenidClientSecret `json:"openid-client-secret,omitempty"` + Databricks map[string]*WorkbenchDatabricksConfig `json:"databricks.conf,omitempty"` + OAuthClients map[string]*WorkbenchOAuthClientConfig `json:"oauth-clients.conf,omitempty"` } func (w *WorkbenchSecretIniConfig) GenerateConfigMap() map[string]string { @@ -852,6 +853,16 @@ type WorkbenchDatabaseConfig struct { Password string `json:"password,omitempty"` } +type WorkbenchOAuthClientConfig struct { + Name string `json:"name,omitempty"` + ClientId string `json:"client-id,omitempty"` + ClientSecret string `json:"client-secret,omitempty"` + AuthorizationUrl string `json:"authorization-url,omitempty"` + TokenUrl string `json:"token-url,omitempty"` + TokenEndpointAuthMethod string `json:"token-endpoint-auth-method,omitempty"` + Scopes []string `json:"scopes,omitempty"` +} + type WorkbenchVsCodeConfig struct { Enabled int `json:"enabled,omitempty"` Exe string `json:"exe,omitempty"` diff --git a/api/core/v1beta1/zz_generated.deepcopy.go b/api/core/v1beta1/zz_generated.deepcopy.go index 9471a10..7e57bf0 100644 --- a/api/core/v1beta1/zz_generated.deepcopy.go +++ b/api/core/v1beta1/zz_generated.deepcopy.go @@ -2700,6 +2700,26 @@ func (in *WorkbenchNssConfig) DeepCopy() *WorkbenchNssConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkbenchOAuthClientConfig) DeepCopyInto(out *WorkbenchOAuthClientConfig) { + *out = *in + if in.Scopes != nil { + in, out := &in.Scopes, &out.Scopes + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkbenchOAuthClientConfig. +func (in *WorkbenchOAuthClientConfig) DeepCopy() *WorkbenchOAuthClientConfig { + if in == nil { + return nil + } + out := new(WorkbenchOAuthClientConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *WorkbenchOpenidClientSecret) DeepCopyInto(out *WorkbenchOpenidClientSecret) { *out = *in @@ -2852,6 +2872,22 @@ func (in *WorkbenchSecretIniConfig) DeepCopyInto(out *WorkbenchSecretIniConfig) (*out)[key] = outVal } } + if in.OAuthClients != nil { + in, out := &in.OAuthClients, &out.OAuthClients + *out = make(map[string]*WorkbenchOAuthClientConfig, len(*in)) + for key, val := range *in { + var outVal *WorkbenchOAuthClientConfig + if val == nil { + (*out)[key] = nil + } else { + inVal := (*in)[key] + in, out := &inVal, &outVal + *out = new(WorkbenchOAuthClientConfig) + (*in).DeepCopyInto(*out) + } + (*out)[key] = outVal + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkbenchSecretIniConfig. diff --git a/client-go/applyconfiguration/core/v1beta1/workbenchoauthclientconfig.go b/client-go/applyconfiguration/core/v1beta1/workbenchoauthclientconfig.go new file mode 100644 index 0000000..207f5c3 --- /dev/null +++ b/client-go/applyconfiguration/core/v1beta1/workbenchoauthclientconfig.go @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2023-2026 Posit Software, PBC + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1beta1 + +// WorkbenchOAuthClientConfigApplyConfiguration represents a declarative configuration of the WorkbenchOAuthClientConfig type for use +// with apply. +type WorkbenchOAuthClientConfigApplyConfiguration struct { + Name *string `json:"name,omitempty"` + ClientId *string `json:"client-id,omitempty"` + ClientSecret *string `json:"client-secret,omitempty"` + AuthorizationUrl *string `json:"authorization-url,omitempty"` + TokenUrl *string `json:"token-url,omitempty"` + TokenEndpointAuthMethod *string `json:"token-endpoint-auth-method,omitempty"` + Scopes []string `json:"scopes,omitempty"` +} + +// WorkbenchOAuthClientConfigApplyConfiguration constructs a declarative configuration of the WorkbenchOAuthClientConfig type for use with +// apply. +func WorkbenchOAuthClientConfig() *WorkbenchOAuthClientConfigApplyConfiguration { + return &WorkbenchOAuthClientConfigApplyConfiguration{} +} + +// 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 *WorkbenchOAuthClientConfigApplyConfiguration) WithName(value string) *WorkbenchOAuthClientConfigApplyConfiguration { + b.Name = &value + return b +} + +// 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 *WorkbenchOAuthClientConfigApplyConfiguration) WithClientId(value string) *WorkbenchOAuthClientConfigApplyConfiguration { + 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 *WorkbenchOAuthClientConfigApplyConfiguration) WithClientSecret(value string) *WorkbenchOAuthClientConfigApplyConfiguration { + b.ClientSecret = &value + return b +} + +// WithAuthorizationUrl sets the AuthorizationUrl 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 AuthorizationUrl field is set to the value of the last call. +func (b *WorkbenchOAuthClientConfigApplyConfiguration) WithAuthorizationUrl(value string) *WorkbenchOAuthClientConfigApplyConfiguration { + b.AuthorizationUrl = &value + return b +} + +// WithTokenUrl sets the TokenUrl 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 TokenUrl field is set to the value of the last call. +func (b *WorkbenchOAuthClientConfigApplyConfiguration) WithTokenUrl(value string) *WorkbenchOAuthClientConfigApplyConfiguration { + b.TokenUrl = &value + return b +} + +// WithTokenEndpointAuthMethod sets the TokenEndpointAuthMethod 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 TokenEndpointAuthMethod field is set to the value of the last call. +func (b *WorkbenchOAuthClientConfigApplyConfiguration) WithTokenEndpointAuthMethod(value string) *WorkbenchOAuthClientConfigApplyConfiguration { + b.TokenEndpointAuthMethod = &value + return b +} + +// WithScopes adds the given value to the Scopes 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 Scopes field. +func (b *WorkbenchOAuthClientConfigApplyConfiguration) WithScopes(values ...string) *WorkbenchOAuthClientConfigApplyConfiguration { + for i := range values { + b.Scopes = append(b.Scopes, values[i]) + } + return b +} diff --git a/client-go/applyconfiguration/core/v1beta1/workbenchsecretconfig.go b/client-go/applyconfiguration/core/v1beta1/workbenchsecretconfig.go index 589e881..79ef798 100644 --- a/client-go/applyconfiguration/core/v1beta1/workbenchsecretconfig.go +++ b/client-go/applyconfiguration/core/v1beta1/workbenchsecretconfig.go @@ -54,6 +54,21 @@ func (b *WorkbenchSecretConfigApplyConfiguration) WithDatabricks(entries map[str return b } +// WithOAuthClients puts the entries into the OAuthClients field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the OAuthClients field, +// overwriting an existing map entries in OAuthClients field with the same key. +func (b *WorkbenchSecretConfigApplyConfiguration) WithOAuthClients(entries map[string]*corev1beta1.WorkbenchOAuthClientConfig) *WorkbenchSecretConfigApplyConfiguration { + b.ensureWorkbenchSecretIniConfigApplyConfigurationExists() + if b.WorkbenchSecretIniConfigApplyConfiguration.OAuthClients == nil && len(entries) > 0 { + b.WorkbenchSecretIniConfigApplyConfiguration.OAuthClients = make(map[string]*corev1beta1.WorkbenchOAuthClientConfig, len(entries)) + } + for k, v := range entries { + b.WorkbenchSecretIniConfigApplyConfiguration.OAuthClients[k] = v + } + return b +} + func (b *WorkbenchSecretConfigApplyConfiguration) ensureWorkbenchSecretIniConfigApplyConfigurationExists() { if b.WorkbenchSecretIniConfigApplyConfiguration == nil { b.WorkbenchSecretIniConfigApplyConfiguration = &WorkbenchSecretIniConfigApplyConfiguration{} diff --git a/client-go/applyconfiguration/core/v1beta1/workbenchsecretiniconfig.go b/client-go/applyconfiguration/core/v1beta1/workbenchsecretiniconfig.go index 3de039c..4f67beb 100644 --- a/client-go/applyconfiguration/core/v1beta1/workbenchsecretiniconfig.go +++ b/client-go/applyconfiguration/core/v1beta1/workbenchsecretiniconfig.go @@ -12,9 +12,10 @@ import ( // WorkbenchSecretIniConfigApplyConfiguration represents a declarative configuration of the WorkbenchSecretIniConfig type for use // with apply. type WorkbenchSecretIniConfigApplyConfiguration struct { - Database *WorkbenchDatabaseConfigApplyConfiguration `json:"database.conf,omitempty"` - OpenidClientSecret *WorkbenchOpenidClientSecretApplyConfiguration `json:"openid-client-secret,omitempty"` - Databricks map[string]*corev1beta1.WorkbenchDatabricksConfig `json:"databricks.conf,omitempty"` + Database *WorkbenchDatabaseConfigApplyConfiguration `json:"database.conf,omitempty"` + OpenidClientSecret *WorkbenchOpenidClientSecretApplyConfiguration `json:"openid-client-secret,omitempty"` + Databricks map[string]*corev1beta1.WorkbenchDatabricksConfig `json:"databricks.conf,omitempty"` + OAuthClients map[string]*corev1beta1.WorkbenchOAuthClientConfig `json:"oauth-clients.conf,omitempty"` } // WorkbenchSecretIniConfigApplyConfiguration constructs a declarative configuration of the WorkbenchSecretIniConfig type for use with @@ -52,3 +53,17 @@ func (b *WorkbenchSecretIniConfigApplyConfiguration) WithDatabricks(entries map[ } return b } + +// WithOAuthClients puts the entries into the OAuthClients field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the OAuthClients field, +// overwriting an existing map entries in OAuthClients field with the same key. +func (b *WorkbenchSecretIniConfigApplyConfiguration) WithOAuthClients(entries map[string]*corev1beta1.WorkbenchOAuthClientConfig) *WorkbenchSecretIniConfigApplyConfiguration { + if b.OAuthClients == nil && len(entries) > 0 { + b.OAuthClients = make(map[string]*corev1beta1.WorkbenchOAuthClientConfig, len(entries)) + } + for k, v := range entries { + b.OAuthClients[k] = v + } + return b +} diff --git a/client-go/applyconfiguration/utils.go b/client-go/applyconfiguration/utils.go index 44cfcaa..817363b 100644 --- a/client-go/applyconfiguration/utils.go +++ b/client-go/applyconfiguration/utils.go @@ -219,6 +219,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &corev1beta1.WorkbenchLoggingSectionApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("WorkbenchNssConfig"): return &corev1beta1.WorkbenchNssConfigApplyConfiguration{} + case v1beta1.SchemeGroupVersion.WithKind("WorkbenchOAuthClientConfig"): + return &corev1beta1.WorkbenchOAuthClientConfigApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("WorkbenchOpenidClientSecret"): return &corev1beta1.WorkbenchOpenidClientSecretApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("WorkbenchPositronConfig"): diff --git a/config/crd/bases/core.posit.team_workbenches.yaml b/config/crd/bases/core.posit.team_workbenches.yaml index 2d66295..d1ef169 100644 --- a/config/crd/bases/core.posit.team_workbenches.yaml +++ b/config/crd/bases/core.posit.team_workbenches.yaml @@ -700,6 +700,27 @@ spec: type: string type: object type: object + oauth-clients.conf: + additionalProperties: + properties: + authorization-url: + type: string + client-id: + type: string + client-secret: + type: string + name: + type: string + scopes: + items: + type: string + type: array + token-endpoint-auth-method: + type: string + token-url: + type: string + type: object + type: object openid-client-secret: properties: client-id: diff --git a/internal/controller/core/workbench.go b/internal/controller/core/workbench.go index 79f75d4..54dc81c 100644 --- a/internal/controller/core/workbench.go +++ b/internal/controller/core/workbench.go @@ -70,6 +70,33 @@ func (r *WorkbenchReconciler) FetchAndSetClientSecretForAzureDatabricks(ctx cont return nil } +func (r *WorkbenchReconciler) FetchAndSetOAuthClientSecrets(ctx context.Context, req ctrl.Request, w *positcov1beta1.Workbench) error { + l := r.GetLogger(ctx) + + if w.Spec.SecretConfig.WorkbenchSecretIniConfig.OAuthClients == nil { + return nil + } + + for integrationName, config := range w.Spec.SecretConfig.WorkbenchSecretIniConfig.OAuthClients { + if config.ClientId == "" { + l.Info("skipping OAuth integration with no client ID", "integration", integrationName) + continue + } + + clientSecretName := fmt.Sprintf("dev-oauth-client-secret-%s", integrationName) + + if cs, err := product.FetchSecret(ctx, r, req, w.Spec.Secret.Type, w.Spec.Secret.VaultName, clientSecretName); err != nil { + l.Error(err, "error fetching client secret for OAuth integration", "integration", integrationName) + return err + } else { + config.ClientSecret = cs + l.Info("successfully fetched OAuth client secret", "integration", integrationName) + } + } + + return nil +} + func (r *WorkbenchReconciler) ReconcileWorkbench(ctx context.Context, req ctrl.Request, w *positcov1beta1.Workbench) (ctrl.Result, error) { l := r.GetLogger(ctx).WithValues( "event", "reconcile-workbench", @@ -134,6 +161,12 @@ func (r *WorkbenchReconciler) ReconcileWorkbench(ctx context.Context, req ctrl.R l.Error(err, "error fetching client secret for databricks azure. Not fatal") } + // fetch OAuth client secrets + if err := r.FetchAndSetOAuthClientSecrets(ctx, req, w); err != nil { + l.Error(err, "error fetching OAuth client secrets") + return ctrl.Result{}, err + } + // now create the service itself res, err := r.ensureDeployedService(ctx, req, w) if err != nil { @@ -285,6 +318,16 @@ func (r *WorkbenchReconciler) ensureDeployedService(ctx context.Context, req ctr kubernetesSecrets[secretName]["dev-chronicle-api-key"] = "dev-chronicle-api-key" } + // OAuth client secrets + if w.Spec.SecretConfig.WorkbenchSecretIniConfig.OAuthClients != nil { + for integrationName, config := range w.Spec.SecretConfig.WorkbenchSecretIniConfig.OAuthClients { + if config.ClientId != "" { + secretKey := fmt.Sprintf("dev-oauth-client-secret-%s", integrationName) + allSecrets[secretKey] = secretKey + } + } + } + if targetSpc, err := product.GetSecretProviderClassForAllSecrets( w, w.SecretProviderClassName(), req.Namespace, w.Spec.Secret.VaultName, From 68345cfef6d6b716ae9595ee13f18a9f0d01de24 Mon Sep 17 00:00:00 2001 From: Elliot Murphy Date: Thu, 22 Jan 2026 23:08:48 -0500 Subject: [PATCH 2/8] test: add unit tests for OAuth client configuration Adds comprehensive unit tests for the OAuth integration feature: - Test INI generation with multiple OAuth clients - Test kebab-case conversion of field names - Test comma-separated scopes array formatting - Test empty scopes handling - Test interaction with other config types Also fixes bug in WorkbenchSecretIniConfig.GenerateConfigMap() where slices in map-based configs were not converted to comma-separated strings. The fix ensures array fields (like scopes) are properly formatted as "scopes=read,write" instead of "scopes=[read write]". Co-Authored-By: Claude --- api/core/v1beta1/workbench_config.go | 7 +- api/core/v1beta1/workbench_config_test.go | 122 ++++++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/api/core/v1beta1/workbench_config.go b/api/core/v1beta1/workbench_config.go index 785fd12..a3cc837 100644 --- a/api/core/v1beta1/workbench_config.go +++ b/api/core/v1beta1/workbench_config.go @@ -70,7 +70,12 @@ func (w *WorkbenchSecretIniConfig) GenerateConfigMap() map[string]string { valueKey := value.Type().Field(j).Name valueVal := value.Field(j) - if fmt.Sprintf("%v", valueVal) != "" { + if valueVal.Kind() == reflect.Slice { + arrayString := sliceToString(valueVal, ",") + if arrayString != "" { + builder.WriteString(fmt.Sprintf("%v", toKebabCase(valueKey)) + "=" + arrayString + "\n") + } + } else if fmt.Sprintf("%v", valueVal) != "" { builder.WriteString(fmt.Sprintf("%v", toKebabCase(valueKey)) + "=" + fmt.Sprintf("%v", valueVal) + "\n") } } diff --git a/api/core/v1beta1/workbench_config_test.go b/api/core/v1beta1/workbench_config_test.go index 3d3b801..52cd216 100644 --- a/api/core/v1beta1/workbench_config_test.go +++ b/api/core/v1beta1/workbench_config_test.go @@ -45,6 +45,128 @@ func TestWorkbenchSecretConfig_GenerateSecretData(t *testing.T) { require.Len(t, res, 3) } +func TestWorkbenchSecretConfig_GenerateSecretData_WithOAuthClients(t *testing.T) { + wb := WorkbenchSecretConfig{ + WorkbenchSecretIniConfig{ + OAuthClients: map[string]*WorkbenchOAuthClientConfig{ + "box-integration": { + Name: "Box API Access", + ClientId: "box-client-id-123", + ClientSecret: "box-secret-xyz", + AuthorizationUrl: "https://account.box.com/api/oauth2/authorize", + TokenUrl: "https://api.box.com/oauth2/token", + TokenEndpointAuthMethod: "client_secret_post", + Scopes: []string{"read", "write"}, + }, + "google-drive": { + Name: "Google Drive", + ClientId: "google-client-id-456", + ClientSecret: "google-secret-abc", + AuthorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", + TokenUrl: "https://oauth2.googleapis.com/token", + Scopes: []string{"https://www.googleapis.com/auth/drive.readonly"}, + }, + }, + }, + } + + res, err := wb.GenerateSecretData() + require.Nil(t, err) + require.Contains(t, res, "oauth-clients.conf") + + oauthConf := res["oauth-clients.conf"] + + // Verify box-integration section + require.Contains(t, oauthConf, "[box-integration]") + require.Contains(t, oauthConf, "name=Box API Access") + require.Contains(t, oauthConf, "client-id=box-client-id-123") + require.Contains(t, oauthConf, "client-secret=box-secret-xyz") + require.Contains(t, oauthConf, "authorization-url=https://account.box.com/api/oauth2/authorize") + require.Contains(t, oauthConf, "token-url=https://api.box.com/oauth2/token") + require.Contains(t, oauthConf, "token-endpoint-auth-method=client_secret_post") + require.Contains(t, oauthConf, "scopes=read,write") + + // Verify google-drive section + require.Contains(t, oauthConf, "[google-drive]") + require.Contains(t, oauthConf, "name=Google Drive") + require.Contains(t, oauthConf, "client-id=google-client-id-456") + require.Contains(t, oauthConf, "client-secret=google-secret-abc") + require.Contains(t, oauthConf, "authorization-url=https://accounts.google.com/o/oauth2/v2/auth") + require.Contains(t, oauthConf, "token-url=https://oauth2.googleapis.com/token") + require.Contains(t, oauthConf, "scopes=https://www.googleapis.com/auth/drive.readonly") + + // Verify field names are converted to kebab-case + require.NotContains(t, oauthConf, "ClientId") + require.NotContains(t, oauthConf, "AuthorizationUrl") + + // Verify trailing newline + require.True(t, strings.HasSuffix(oauthConf, "\n")) +} + +func TestWorkbenchSecretConfig_GenerateSecretData_WithOAuthClients_EmptyScopes(t *testing.T) { + wb := WorkbenchSecretConfig{ + WorkbenchSecretIniConfig{ + OAuthClients: map[string]*WorkbenchOAuthClientConfig{ + "dropbox": { + Name: "Dropbox", + ClientId: "dropbox-client-id", + ClientSecret: "dropbox-secret", + AuthorizationUrl: "https://www.dropbox.com/oauth2/authorize", + TokenUrl: "https://api.dropbox.com/oauth2/token", + // No scopes specified + }, + }, + }, + } + + res, err := wb.GenerateSecretData() + require.Nil(t, err) + require.Contains(t, res, "oauth-clients.conf") + + oauthConf := res["oauth-clients.conf"] + require.Contains(t, oauthConf, "[dropbox]") + require.Contains(t, oauthConf, "name=Dropbox") + require.Contains(t, oauthConf, "client-id=dropbox-client-id") + + // Scopes should not appear in output if empty + require.NotContains(t, oauthConf, "scopes=") +} + +func TestWorkbenchSecretConfig_GenerateSecretData_WithMultipleConfigs(t *testing.T) { + wb := WorkbenchSecretConfig{ + WorkbenchSecretIniConfig{ + Database: &WorkbenchDatabaseConfig{ + Provider: WorkbenchDatabaseProviderPostgres, + Database: "testdb", + Port: "5432", + Host: "localhost", + Username: "user", + }, + OAuthClients: map[string]*WorkbenchOAuthClientConfig{ + "box": { + Name: "Box", + ClientId: "box-id", + ClientSecret: "box-secret", + AuthorizationUrl: "https://account.box.com/api/oauth2/authorize", + TokenUrl: "https://api.box.com/oauth2/token", + }, + }, + }, + } + + res, err := wb.GenerateSecretData() + require.Nil(t, err) + + // Should have both database.conf and oauth-clients.conf + require.Contains(t, res, "database.conf") + require.Contains(t, res, "oauth-clients.conf") + require.Len(t, res, 2) + + // Verify both configs are properly formatted + require.Contains(t, res["database.conf"], "provider=postgresql") + require.Contains(t, res["oauth-clients.conf"], "[box]") +} + func TestWorkbenchConfig_GenerateConfigmap(t *testing.T) { wb := WorkbenchConfig{ WorkbenchIniConfig: WorkbenchIniConfig{ From 025b74b360dfffa5e98d319dc748c690a66d69d1 Mon Sep 17 00:00:00 2001 From: Elliot Murphy Date: Thu, 22 Jan 2026 23:16:38 -0500 Subject: [PATCH 3/8] fix: align struct field spacing to match formatter --- api/core/v1beta1/workbench_config.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/core/v1beta1/workbench_config.go b/api/core/v1beta1/workbench_config.go index a3cc837..d0d6119 100644 --- a/api/core/v1beta1/workbench_config.go +++ b/api/core/v1beta1/workbench_config.go @@ -29,10 +29,10 @@ type WorkbenchSecretConfig struct { } type WorkbenchSecretIniConfig struct { - Database *WorkbenchDatabaseConfig `json:"database.conf,omitempty"` - OpenidClientSecret *WorkbenchOpenidClientSecret `json:"openid-client-secret,omitempty"` - Databricks map[string]*WorkbenchDatabricksConfig `json:"databricks.conf,omitempty"` - OAuthClients map[string]*WorkbenchOAuthClientConfig `json:"oauth-clients.conf,omitempty"` + Database *WorkbenchDatabaseConfig `json:"database.conf,omitempty"` + OpenidClientSecret *WorkbenchOpenidClientSecret `json:"openid-client-secret,omitempty"` + Databricks map[string]*WorkbenchDatabricksConfig `json:"databricks.conf,omitempty"` + OAuthClients map[string]*WorkbenchOAuthClientConfig `json:"oauth-clients.conf,omitempty"` } func (w *WorkbenchSecretIniConfig) GenerateConfigMap() map[string]string { From b432ed650bafd9df440ee4fa03d4686d832f4a3c Mon Sep 17 00:00:00 2001 From: Elliot Murphy Date: Thu, 22 Jan 2026 23:45:09 -0500 Subject: [PATCH 4/8] feat: improve OAuth integration implementation Improvements to the custom OAuth integrations feature: 1. Error handling consistency: - OAuth secret fetching errors are now non-fatal (like Databricks) - Continues processing other integrations even if one fails - A missing secret will not block entire Workbench deployment 2. API documentation: - Added comprehensive kubebuilder comments to WorkbenchOAuthClientConfig - Documented required vs optional fields - Added validation markers (MinLength, Enum) - Links to official Workbench documentation 3. Validation for required fields: - Validates client-id, authorization-url, and token-url are present - Logs informative message listing missing fields - Skips integration gracefully if validation fails 4. Example CR: - Added config/samples/workbench_oauth_integrations.yaml - Shows Box, Google Drive, Dropbox, and custom API examples - Includes corresponding Kubernetes Secret example - Documents prerequisites and redirect URI configuration Co-Authored-By: Claude --- api/core/v1beta1/workbench_config.go | 46 ++++++++-- .../bases/core.posit.team_workbenches.yaml | 32 +++++++ .../samples/workbench_oauth_integrations.yaml | 85 +++++++++++++++++++ internal/controller/core/workbench.go | 27 ++++-- 4 files changed, 177 insertions(+), 13 deletions(-) create mode 100644 config/samples/workbench_oauth_integrations.yaml diff --git a/api/core/v1beta1/workbench_config.go b/api/core/v1beta1/workbench_config.go index d0d6119..f1ac10a 100644 --- a/api/core/v1beta1/workbench_config.go +++ b/api/core/v1beta1/workbench_config.go @@ -858,14 +858,46 @@ type WorkbenchDatabaseConfig struct { Password string `json:"password,omitempty"` } +// WorkbenchOAuthClientConfig defines configuration for a custom OAuth integration. +// See https://docs.posit.co/ide/server-pro/integration/custom-oauth.html for details. type WorkbenchOAuthClientConfig struct { - Name string `json:"name,omitempty"` - ClientId string `json:"client-id,omitempty"` - ClientSecret string `json:"client-secret,omitempty"` - AuthorizationUrl string `json:"authorization-url,omitempty"` - TokenUrl string `json:"token-url,omitempty"` - TokenEndpointAuthMethod string `json:"token-endpoint-auth-method,omitempty"` - Scopes []string `json:"scopes,omitempty"` + // Name is the display name for this OAuth integration shown in the Workbench UI. + // If not provided, the section header (map key) is used as the display name. + // +optional + Name string `json:"name,omitempty"` + + // ClientId is the OAuth client ID obtained from the external service provider. + // This field is required for the integration to function. + // +kubebuilder:validation:MinLength=1 + ClientId string `json:"client-id,omitempty"` + + // ClientSecret is populated automatically from the secret store. + // Do not set this field directly - instead, create a secret with the name + // "dev-oauth-client-secret-{integration-name}" in your secret store. + // +optional + ClientSecret string `json:"client-secret,omitempty"` + + // AuthorizationUrl is the OAuth authorization endpoint URL. + // This field is required for the integration to function. + // +kubebuilder:validation:MinLength=1 + AuthorizationUrl string `json:"authorization-url,omitempty"` + + // TokenUrl is the OAuth token endpoint URL. + // This field is required for the integration to function. + // +kubebuilder:validation:MinLength=1 + TokenUrl string `json:"token-url,omitempty"` + + // TokenEndpointAuthMethod specifies how the client authenticates with the token endpoint. + // Common values: "client_secret_post", "client_secret_basic". + // Defaults to "client_secret_post" if not specified. + // +optional + // +kubebuilder:validation:Enum=client_secret_post;client_secret_basic + TokenEndpointAuthMethod string `json:"token-endpoint-auth-method,omitempty"` + + // Scopes is a list of OAuth scopes to request from the authorization server. + // These are rendered as a comma-separated list in the configuration file. + // +optional + Scopes []string `json:"scopes,omitempty"` } type WorkbenchVsCodeConfig struct { diff --git a/config/crd/bases/core.posit.team_workbenches.yaml b/config/crd/bases/core.posit.team_workbenches.yaml index d1ef169..1bdac67 100644 --- a/config/crd/bases/core.posit.team_workbenches.yaml +++ b/config/crd/bases/core.posit.team_workbenches.yaml @@ -702,22 +702,54 @@ spec: type: object oauth-clients.conf: additionalProperties: + description: |- + WorkbenchOAuthClientConfig defines configuration for a custom OAuth integration. + See https://docs.posit.co/ide/server-pro/integration/custom-oauth.html for details. properties: authorization-url: + description: |- + AuthorizationUrl is the OAuth authorization endpoint URL. + This field is required for the integration to function. + minLength: 1 type: string client-id: + description: |- + ClientId is the OAuth client ID obtained from the external service provider. + This field is required for the integration to function. + minLength: 1 type: string client-secret: + description: |- + ClientSecret is populated automatically from the secret store. + Do not set this field directly - instead, create a secret with the name + "dev-oauth-client-secret-{integration-name}" in your secret store. type: string name: + description: |- + Name is the display name for this OAuth integration shown in the Workbench UI. + If not provided, the section header (map key) is used as the display name. type: string scopes: + description: |- + Scopes is a list of OAuth scopes to request from the authorization server. + These are rendered as a comma-separated list in the configuration file. items: type: string type: array token-endpoint-auth-method: + description: |- + TokenEndpointAuthMethod specifies how the client authenticates with the token endpoint. + Common values: "client_secret_post", "client_secret_basic". + Defaults to "client_secret_post" if not specified. + enum: + - client_secret_post + - client_secret_basic type: string token-url: + description: |- + TokenUrl is the OAuth token endpoint URL. + This field is required for the integration to function. + minLength: 1 type: string type: object type: object diff --git a/config/samples/workbench_oauth_integrations.yaml b/config/samples/workbench_oauth_integrations.yaml new file mode 100644 index 0000000..33ce680 --- /dev/null +++ b/config/samples/workbench_oauth_integrations.yaml @@ -0,0 +1,85 @@ +# Example: Workbench with Custom OAuth Integrations +# +# This example shows how to configure custom OAuth integrations for Workbench, +# enabling users to access external APIs like Box, Dropbox, Google Drive, etc. +# +# Documentation: https://docs.posit.co/ide/server-pro/integration/custom-oauth.html +# +# Prerequisites: +# 1. Register your application with each OAuth provider to obtain client credentials +# 2. Configure the redirect URI in each provider: https:///oauth_redirect_callback +# 3. Create secrets for each integration's client secret (see below) +# +--- +apiVersion: core.posit.team/v1beta1 +kind: Workbench +metadata: + name: workbench-with-oauth + namespace: posit-team +spec: + # Secret configuration - adjust based on your secret store type + secret: + type: kubernetes # or "aws" for AWS Secrets Manager + vaultName: workbench-oauth-secrets + + # OAuth integrations are configured under secretConfig + secretConfig: + workbench-secret-ini-config: + oauth-clients.conf: + # Box Integration + # Register at: https://developer.box.com/ + box-integration: + name: "Box Cloud Storage" + client-id: "your-box-client-id" + authorization-url: "https://account.box.com/api/oauth2/authorize" + token-url: "https://api.box.com/oauth2/token" + scopes: + - "root_readwrite" + + # Google Drive Integration + # Register at: https://console.cloud.google.com/ + google-drive: + name: "Google Drive" + client-id: "your-google-client-id.apps.googleusercontent.com" + authorization-url: "https://accounts.google.com/o/oauth2/v2/auth" + token-url: "https://oauth2.googleapis.com/token" + scopes: + - "https://www.googleapis.com/auth/drive.readonly" + - "https://www.googleapis.com/auth/drive.file" + + # Dropbox Integration + # Register at: https://www.dropbox.com/developers/apps + dropbox: + name: "Dropbox" + client-id: "your-dropbox-app-key" + authorization-url: "https://www.dropbox.com/oauth2/authorize" + token-url: "https://api.dropbox.com/oauth2/token" + token-endpoint-auth-method: "client_secret_post" + + # Custom Internal API Example + internal-api: + name: "Company Data API" + client-id: "internal-api-client" + authorization-url: "https://auth.company.com/oauth/authorize" + token-url: "https://auth.company.com/oauth/token" + token-endpoint-auth-method: "client_secret_basic" + scopes: + - "read" + - "write" + +--- +# Kubernetes Secret containing OAuth client secrets +# Each key follows the pattern: dev-oauth-client-secret-{integration-name} +# +# For AWS Secrets Manager, create secrets with the same names in your vault. +apiVersion: v1 +kind: Secret +metadata: + name: workbench-oauth-secrets + namespace: posit-team +type: Opaque +stringData: + dev-oauth-client-secret-box-integration: "your-box-client-secret" + dev-oauth-client-secret-google-drive: "your-google-client-secret" + dev-oauth-client-secret-dropbox: "your-dropbox-app-secret" + dev-oauth-client-secret-internal-api: "your-internal-api-secret" diff --git a/internal/controller/core/workbench.go b/internal/controller/core/workbench.go index 54dc81c..001b404 100644 --- a/internal/controller/core/workbench.go +++ b/internal/controller/core/workbench.go @@ -77,9 +77,24 @@ func (r *WorkbenchReconciler) FetchAndSetOAuthClientSecrets(ctx context.Context, return nil } + var lastErr error for integrationName, config := range w.Spec.SecretConfig.WorkbenchSecretIniConfig.OAuthClients { + // Validate required fields per Workbench documentation + var missingFields []string if config.ClientId == "" { - l.Info("skipping OAuth integration with no client ID", "integration", integrationName) + missingFields = append(missingFields, "client-id") + } + if config.AuthorizationUrl == "" { + missingFields = append(missingFields, "authorization-url") + } + if config.TokenUrl == "" { + missingFields = append(missingFields, "token-url") + } + + if len(missingFields) > 0 { + l.Info("skipping OAuth integration with missing required fields", + "integration", integrationName, + "missingFields", missingFields) continue } @@ -87,14 +102,15 @@ func (r *WorkbenchReconciler) FetchAndSetOAuthClientSecrets(ctx context.Context, if cs, err := product.FetchSecret(ctx, r, req, w.Spec.Secret.Type, w.Spec.Secret.VaultName, clientSecretName); err != nil { l.Error(err, "error fetching client secret for OAuth integration", "integration", integrationName) - return err + lastErr = err + // Continue processing other integrations } else { config.ClientSecret = cs l.Info("successfully fetched OAuth client secret", "integration", integrationName) } } - return nil + return lastErr } func (r *WorkbenchReconciler) ReconcileWorkbench(ctx context.Context, req ctrl.Request, w *positcov1beta1.Workbench) (ctrl.Result, error) { @@ -161,10 +177,9 @@ func (r *WorkbenchReconciler) ReconcileWorkbench(ctx context.Context, req ctrl.R l.Error(err, "error fetching client secret for databricks azure. Not fatal") } - // fetch OAuth client secrets + // fetch OAuth client secrets (non-fatal, like Databricks) if err := r.FetchAndSetOAuthClientSecrets(ctx, req, w); err != nil { - l.Error(err, "error fetching OAuth client secrets") - return ctrl.Result{}, err + l.Error(err, "error fetching OAuth client secrets. Not fatal") } // now create the service itself From 378823af76a76a69171b25dff347dc806e4ade48 Mon Sep 17 00:00:00 2001 From: Elliot Murphy Date: Fri, 23 Jan 2026 00:13:52 -0500 Subject: [PATCH 5/8] test: add controller tests for OAuth client secret fetching Adds comprehensive controller-level tests for FetchAndSetOAuthClientSecrets: - TestOAuthClientSecrets: Happy path with multiple OAuth integrations - TestOAuthClientSecrets_NilOAuthClients: Verifies nil handling (no-op) - TestOAuthClientSecrets_MissingRequiredFields: Validates required field checking - TestOAuthClientSecrets_BackwardsCompatibility: Ensures existing configs unaffected These tests provide confidence that: - OAuth feature works correctly end-to-end - Existing Workbench deployments without OAuth are unaffected - Invalid integrations are skipped gracefully - Valid integrations are processed even when others fail Co-Authored-By: Claude --- internal/controller/core/workbench_test.go | 169 +++++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/internal/controller/core/workbench_test.go b/internal/controller/core/workbench_test.go index c7f66b5..397d6c7 100644 --- a/internal/controller/core/workbench_test.go +++ b/internal/controller/core/workbench_test.go @@ -262,3 +262,172 @@ func TestWorkbenchAuthSamlMissingMetadata(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "SAML authentication requires a metadata URL") } + +// OAuth Client Tests + +func TestOAuthClientSecrets(t *testing.T) { + r := &WorkbenchReconciler{} + ctx := context.TODO() + req := ctrl.Request{} + w := &positcov1beta1.Workbench{ + Spec: positcov1beta1.WorkbenchSpec{ + Secret: positcov1beta1.SecretConfig{ + VaultName: "test-vault", + Type: product.SiteSecretTest, + }, + SecretConfig: positcov1beta1.WorkbenchSecretConfig{ + WorkbenchSecretIniConfig: positcov1beta1.WorkbenchSecretIniConfig{ + OAuthClients: map[string]*positcov1beta1.WorkbenchOAuthClientConfig{ + "box-integration": { + Name: "Box", + ClientId: "box-client-id", + AuthorizationUrl: "https://account.box.com/api/oauth2/authorize", + TokenUrl: "https://api.box.com/oauth2/token", + }, + "google-drive": { + Name: "Google Drive", + ClientId: "google-client-id", + AuthorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", + TokenUrl: "https://oauth2.googleapis.com/token", + Scopes: []string{"drive.readonly"}, + }, + }, + }, + }, + }, + } + + // Verify secrets are empty before fetching + require.Equal(t, "", w.Spec.SecretConfig.OAuthClients["box-integration"].ClientSecret) + require.Equal(t, "", w.Spec.SecretConfig.OAuthClients["google-drive"].ClientSecret) + + // Fetch secrets + err := r.FetchAndSetOAuthClientSecrets(ctx, req, w) + require.NoError(t, err) + + // Verify secrets were populated (test provider returns the secret name as the value) + require.Equal(t, "dev-oauth-client-secret-box-integration", w.Spec.SecretConfig.OAuthClients["box-integration"].ClientSecret) + require.Equal(t, "dev-oauth-client-secret-google-drive", w.Spec.SecretConfig.OAuthClients["google-drive"].ClientSecret) +} + +func TestOAuthClientSecrets_NilOAuthClients(t *testing.T) { + r := &WorkbenchReconciler{} + ctx := context.TODO() + req := ctrl.Request{} + w := &positcov1beta1.Workbench{ + Spec: positcov1beta1.WorkbenchSpec{ + Secret: positcov1beta1.SecretConfig{ + VaultName: "test-vault", + Type: product.SiteSecretTest, + }, + SecretConfig: positcov1beta1.WorkbenchSecretConfig{ + WorkbenchSecretIniConfig: positcov1beta1.WorkbenchSecretIniConfig{ + // OAuthClients is nil - should be a no-op + }, + }, + }, + } + + // Should return nil error for nil OAuthClients (no-op) + err := r.FetchAndSetOAuthClientSecrets(ctx, req, w) + require.NoError(t, err) +} + +func TestOAuthClientSecrets_MissingRequiredFields(t *testing.T) { + r := &WorkbenchReconciler{} + ctx := context.TODO() + req := ctrl.Request{} + w := &positcov1beta1.Workbench{ + Spec: positcov1beta1.WorkbenchSpec{ + Secret: positcov1beta1.SecretConfig{ + VaultName: "test-vault", + Type: product.SiteSecretTest, + }, + SecretConfig: positcov1beta1.WorkbenchSecretConfig{ + WorkbenchSecretIniConfig: positcov1beta1.WorkbenchSecretIniConfig{ + OAuthClients: map[string]*positcov1beta1.WorkbenchOAuthClientConfig{ + // Missing client-id + "missing-client-id": { + Name: "Missing Client ID", + AuthorizationUrl: "https://example.com/auth", + TokenUrl: "https://example.com/token", + }, + // Missing authorization-url + "missing-auth-url": { + Name: "Missing Auth URL", + ClientId: "some-client-id", + TokenUrl: "https://example.com/token", + }, + // Missing token-url + "missing-token-url": { + Name: "Missing Token URL", + ClientId: "some-client-id", + AuthorizationUrl: "https://example.com/auth", + }, + // Valid integration - should still be processed + "valid-integration": { + Name: "Valid", + ClientId: "valid-client-id", + AuthorizationUrl: "https://example.com/auth", + TokenUrl: "https://example.com/token", + }, + }, + }, + }, + }, + } + + // Should not return error - invalid integrations are skipped, valid ones processed + err := r.FetchAndSetOAuthClientSecrets(ctx, req, w) + require.NoError(t, err) + + // Invalid integrations should have empty secrets (they were skipped) + require.Equal(t, "", w.Spec.SecretConfig.OAuthClients["missing-client-id"].ClientSecret) + require.Equal(t, "", w.Spec.SecretConfig.OAuthClients["missing-auth-url"].ClientSecret) + require.Equal(t, "", w.Spec.SecretConfig.OAuthClients["missing-token-url"].ClientSecret) + + // Valid integration should have its secret populated + require.Equal(t, "dev-oauth-client-secret-valid-integration", w.Spec.SecretConfig.OAuthClients["valid-integration"].ClientSecret) +} + +func TestOAuthClientSecrets_BackwardsCompatibility(t *testing.T) { + // This test verifies that existing Workbench configurations without OAuth + // continue to work correctly (backwards compatibility) + r := &WorkbenchReconciler{} + ctx := context.TODO() + req := ctrl.Request{} + + // Simulate an existing Workbench with Databricks but no OAuth + w := &positcov1beta1.Workbench{ + Spec: positcov1beta1.WorkbenchSpec{ + Secret: positcov1beta1.SecretConfig{ + VaultName: "test-vault", + Type: product.SiteSecretTest, + }, + SecretConfig: positcov1beta1.WorkbenchSecretConfig{ + WorkbenchSecretIniConfig: positcov1beta1.WorkbenchSecretIniConfig{ + // Existing Databricks config + Databricks: map[string]*positcov1beta1.WorkbenchDatabricksConfig{ + "existing-workspace": { + Name: "Existing Workspace", + Url: "https://existing.cloud.databricks.com", + ClientId: "existing-client-id", + }, + }, + // No OAuthClients configured (nil) + }, + }, + }, + } + + // OAuth fetching should be a no-op and not affect Databricks + err := r.FetchAndSetOAuthClientSecrets(ctx, req, w) + require.NoError(t, err) + + // Databricks config should be unchanged + require.NotNil(t, w.Spec.SecretConfig.Databricks) + require.Equal(t, "Existing Workspace", w.Spec.SecretConfig.Databricks["existing-workspace"].Name) + + // OAuthClients should still be nil + require.Nil(t, w.Spec.SecretConfig.OAuthClients) +} From d676d766da1053739f6106e2ecad7d4c84dc679e Mon Sep 17 00:00:00 2001 From: Elliot Murphy Date: Thu, 12 Feb 2026 20:51:57 -0500 Subject: [PATCH 6/8] docs: document just mtest for integration tests --- CLAUDE.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index c561ded..b66db54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,8 @@ Kubernetes operator for managing Posit Team deployments. ```bash just build # Build operator binary to ./bin/team-operator -just test # Run go tests +just test # Run go tests (unit tests only, no envtest/kubebuilder) +just mtest # Run all tests including integration tests (requires envtest) just run # Run operator locally from source just deps # Install dependencies just mgenerate # Regenerate manifests after API changes @@ -40,6 +41,13 @@ helm install team-operator ./dist/chart \ --create-namespace ``` +## Testing + +- **`just test`** runs `go test` directly — fast, but skips integration tests that need a control plane (etcd, kube-apiserver). +- **`just mtest`** runs `make test`, which uses `setup-envtest` to download kubebuilder binaries and sets `KUBEBUILDER_ASSETS` before running tests. Use this for controller/reconciler tests in `internal/controller/`. + +Use `just mtest` when changing reconciler logic or controller tests. Use `just test` for quick feedback on unit-only packages (`api/`, `internal/` non-controller code). + ## Contributing - **PR titles must follow conventional commit format** (`feat:`, `fix:`, `docs:`, etc.) - this is enforced by CI From dbd54f42d227b898c57bc10fcb4c55af51e97b30 Mon Sep 17 00:00:00 2001 From: Elliot Murphy Date: Thu, 12 Feb 2026 20:57:31 -0500 Subject: [PATCH 7/8] fix: sync Helm chart CRDs with kustomize after OAuth client changes --- .../crd/core.posit.team_workbenches.yaml | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/dist/chart/templates/crd/core.posit.team_workbenches.yaml b/dist/chart/templates/crd/core.posit.team_workbenches.yaml index b6151e3..ef8be11 100755 --- a/dist/chart/templates/crd/core.posit.team_workbenches.yaml +++ b/dist/chart/templates/crd/core.posit.team_workbenches.yaml @@ -742,6 +742,59 @@ spec: type: string type: object type: object + oauth-clients.conf: + additionalProperties: + description: |- + WorkbenchOAuthClientConfig defines configuration for a custom OAuth integration. + See https://docs.posit.co/ide/server-pro/integration/custom-oauth.html for details. + properties: + authorization-url: + description: |- + AuthorizationUrl is the OAuth authorization endpoint URL. + This field is required for the integration to function. + minLength: 1 + type: string + client-id: + description: |- + ClientId is the OAuth client ID obtained from the external service provider. + This field is required for the integration to function. + minLength: 1 + type: string + client-secret: + description: |- + ClientSecret is populated automatically from the secret store. + Do not set this field directly - instead, create a secret with the name + "dev-oauth-client-secret-{integration-name}" in your secret store. + type: string + name: + description: |- + Name is the display name for this OAuth integration shown in the Workbench UI. + If not provided, the section header (map key) is used as the display name. + type: string + scopes: + description: |- + Scopes is a list of OAuth scopes to request from the authorization server. + These are rendered as a comma-separated list in the configuration file. + items: + type: string + type: array + token-endpoint-auth-method: + description: |- + TokenEndpointAuthMethod specifies how the client authenticates with the token endpoint. + Common values: "client_secret_post", "client_secret_basic". + Defaults to "client_secret_post" if not specified. + enum: + - client_secret_post + - client_secret_basic + type: string + token-url: + description: |- + TokenUrl is the OAuth token endpoint URL. + This field is required for the integration to function. + minLength: 1 + type: string + type: object + type: object openid-client-secret: properties: client-id: From bd0bbaa306f44ca6dcd7007c6f9d31004f13df47 Mon Sep 17 00:00:00 2001 From: Elliot Murphy Date: Thu, 12 Feb 2026 20:59:04 -0500 Subject: [PATCH 8/8] docs: document code generation workflow and helm-generate --- CLAUDE.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index b66db54..5834eaf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,8 @@ just test # Run go tests (unit tests only, no envtest/kubebuilder) just mtest # Run all tests including integration tests (requires envtest) just run # Run operator locally from source just deps # Install dependencies -just mgenerate # Regenerate manifests after API changes +just mgenerate # Regenerate manifests and client-go after API changes +just helm-generate # Sync Helm chart with kustomize CRDs/RBAC (run after mgenerate) just helm-lint # Lint Helm chart just helm-template # Render Helm templates locally just helm-install # Install operator via Helm @@ -48,6 +49,13 @@ helm install team-operator ./dist/chart \ Use `just mtest` when changing reconciler logic or controller tests. Use `just test` for quick feedback on unit-only packages (`api/`, `internal/` non-controller code). +## Code Generation + +After changing CRD types in `api/`, run these in order: + +1. **`just mgenerate`** — regenerates deepcopy, client-go, CRD manifests in `config/crd/`, and OpenAPI specs. +2. **`make helm-generate`** — copies CRDs and RBAC from `config/` into `dist/chart/templates/`. The Helm chart is not updated automatically by `mgenerate`, so this step is required or the chart will drift. + ## Contributing - **PR titles must follow conventional commit format** (`feat:`, `fix:`, `docs:`, etc.) - this is enforced by CI