diff --git a/CLAUDE.md b/CLAUDE.md index 410547b..ce645a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,10 +17,12 @@ 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 +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 @@ -40,6 +42,20 @@ 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). + +## 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. + ## Git Worktrees **Always use git worktrees instead of plain branches.** This enables concurrent Claude sessions in the same repo. diff --git a/api/core/v1beta1/workbench_config.go b/api/core/v1beta1/workbench_config.go index 96b5a51..7a52694 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 { @@ -69,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") } } @@ -909,6 +915,48 @@ 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 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 { Enabled int `json:"enabled,omitempty"` Exe string `json:"exe,omitempty"` diff --git a/api/core/v1beta1/workbench_config_test.go b/api/core/v1beta1/workbench_config_test.go index 029305c..ecfb962 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{ diff --git a/api/core/v1beta1/zz_generated.deepcopy.go b/api/core/v1beta1/zz_generated.deepcopy.go index b1443fb..e61dd71 100644 --- a/api/core/v1beta1/zz_generated.deepcopy.go +++ b/api/core/v1beta1/zz_generated.deepcopy.go @@ -2811,6 +2811,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 @@ -2993,6 +3013,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 bba2e5a..b07e215 100644 --- a/client-go/applyconfiguration/utils.go +++ b/client-go/applyconfiguration/utils.go @@ -223,6 +223,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 d047931..e76f5e2 100644 --- a/config/crd/bases/core.posit.team_workbenches.yaml +++ b/config/crd/bases/core.posit.team_workbenches.yaml @@ -737,6 +737,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: 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/dist/chart/templates/crd/core.posit.team_workbenches.yaml b/dist/chart/templates/crd/core.posit.team_workbenches.yaml index ff0ed92..503fe9a 100755 --- a/dist/chart/templates/crd/core.posit.team_workbenches.yaml +++ b/dist/chart/templates/crd/core.posit.team_workbenches.yaml @@ -758,6 +758,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: diff --git a/internal/controller/core/workbench.go b/internal/controller/core/workbench.go index 721c9d5..d559a7d 100644 --- a/internal/controller/core/workbench.go +++ b/internal/controller/core/workbench.go @@ -70,6 +70,49 @@ 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 + } + + var lastErr error + for integrationName, config := range w.Spec.SecretConfig.WorkbenchSecretIniConfig.OAuthClients { + // Validate required fields per Workbench documentation + var missingFields []string + if config.ClientId == "" { + 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 + } + + 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) + lastErr = err + // Continue processing other integrations + } else { + config.ClientSecret = cs + l.Info("successfully fetched OAuth client secret", "integration", integrationName) + } + } + + return lastErr +} + 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 +177,11 @@ 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 (non-fatal, like Databricks) + if err := r.FetchAndSetOAuthClientSecrets(ctx, req, w); err != nil { + l.Error(err, "error fetching OAuth client secrets. Not fatal") + } + // now create the service itself res, err := r.ensureDeployedService(ctx, req, w) if err != nil { @@ -285,6 +333,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, diff --git a/internal/controller/core/workbench_test.go b/internal/controller/core/workbench_test.go index ea49444..b3a7798 100644 --- a/internal/controller/core/workbench_test.go +++ b/internal/controller/core/workbench_test.go @@ -263,6 +263,175 @@ func TestWorkbenchAuthSamlMissingMetadata(t *testing.T) { 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) +} + func TestWorkbenchLoadBalancingInitContainer(t *testing.T) { ctx := context.Background() ns := "posit-team"