From f4240bd16e5d44da03f37f6cb0dbe77156185b68 Mon Sep 17 00:00:00 2001 From: ivanauth Date: Mon, 30 Mar 2026 15:21:46 -0400 Subject: [PATCH 1/2] feat: support separate datastore URIs for migrations Add MigrationDatastoreURI credential ref to ClusterCredentials, enabling least-privilege credentials for the application while using elevated credentials only for migration jobs. When MigrationDatastoreURI is configured, migration jobs use that URI instead of the application's DatastoreURI. When not set, migrations continue using the same DatastoreURI as the application (existing behavior). Fixes #338 --- pkg/apis/authzed/v1alpha1/types.go | 7 +++++++ pkg/config/config.go | 28 ++++++++++++++++++++++++++-- pkg/config/config_test.go | 22 ++++++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/pkg/apis/authzed/v1alpha1/types.go b/pkg/apis/authzed/v1alpha1/types.go index 39618e70..0b02ef3e 100644 --- a/pkg/apis/authzed/v1alpha1/types.go +++ b/pkg/apis/authzed/v1alpha1/types.go @@ -124,6 +124,13 @@ type ClusterCredentials struct { // MigrationSecrets configures the source for the migration secrets. // +optional MigrationSecrets *CredentialRef `json:"migrationSecrets,omitempty"` + + // MigrationDatastoreURI configures a separate connection string for migrations. + // When set, migration jobs use this URI instead of DatastoreURI, enabling + // least-privilege credentials for the application while using elevated + // credentials only for migrations. + // +optional + MigrationDatastoreURI *CredentialRef `json:"migrationDatastoreURI,omitempty"` } // CredentialRef describes where to read a single credential value. diff --git a/pkg/config/config.go b/pkg/config/config.go index dcc80844..40c936c6 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -176,6 +176,7 @@ type SpiceConfig struct { DatastoreURIRef ResolvedCredentialRef PresharedKeyRef ResolvedCredentialRef MigrationSecretsRef ResolvedCredentialRef + MigrationDatastoreURIRef ResolvedCredentialRef ExtraPodLabels map[string]string ExtraPodAnnotations map[string]string ExtraServiceAccountAnnotations map[string]string @@ -367,6 +368,24 @@ func NewConfig(cluster *v1alpha1.SpiceDBCluster, globalConfig *OperatorConfig, s Key: key, } } + + // Resolve MigrationDatastoreURI credential. + // If not specified, migrations use the same DatastoreURI. + if credentials.MigrationDatastoreURI == nil { + // Default: use the same datastore URI for migrations + spiceConfig.MigrationDatastoreURIRef = spiceConfig.DatastoreURIRef + } else if credentials.MigrationDatastoreURI.Skip { + spiceConfig.MigrationDatastoreURIRef = ResolvedCredentialRef{Skip: true} + } else { + key := credentials.MigrationDatastoreURI.Key + if key == "" { + key = "migration_datastore_uri" + } + spiceConfig.MigrationDatastoreURIRef = ResolvedCredentialRef{ + SecretName: credentials.MigrationDatastoreURI.SecretName, + Key: key, + } + } } if len(migrationConfig.SpannerCredsSecretRef) > 0 { @@ -752,9 +771,14 @@ func (c *Config) unpatchedMigrationJob(migrationHash string) *applybatchv1.JobAp envVars := []*applycorev1.EnvVarApplyConfiguration{ applycorev1.EnvVar().WithName(envPrefix + "_LOG_LEVEL").WithValue(c.MigrationLogLevel), } - if !c.DatastoreURIRef.Skip { + // Use MigrationDatastoreURIRef for migrations if set, otherwise fall back to DatastoreURIRef. + migrationURIRef := c.MigrationDatastoreURIRef + if migrationURIRef.SecretName == "" && !migrationURIRef.Skip { + migrationURIRef = c.DatastoreURIRef + } + if !migrationURIRef.Skip { envVars = append(envVars, - applycorev1.EnvVar().WithName(envPrefix+"_DATASTORE_CONN_URI").WithValueFrom(applycorev1.EnvVarSource().WithSecretKeyRef(applycorev1.SecretKeySelector().WithName(c.DatastoreURIRef.SecretName).WithKey(c.DatastoreURIRef.Key))), + applycorev1.EnvVar().WithName(envPrefix+"_DATASTORE_CONN_URI").WithValueFrom(applycorev1.EnvVarSource().WithSecretKeyRef(applycorev1.SecretKeySelector().WithName(migrationURIRef.SecretName).WithKey(migrationURIRef.Key))), ) } if !c.MigrationSecretsRef.Skip { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 1bd7cd2c..efa1a67d 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -170,6 +170,7 @@ func TestNewConfig(t *testing.T) { DatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, PresharedKeyRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "preshared_key"}, MigrationSecretsRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "migration_secrets"}, + MigrationDatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, ProjectLabels: true, ProjectAnnotations: true, Passthrough: map[string]string{ @@ -257,6 +258,7 @@ func TestNewConfig(t *testing.T) { DatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, PresharedKeyRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "preshared_key"}, MigrationSecretsRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "migration_secrets"}, + MigrationDatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, ProjectLabels: true, ProjectAnnotations: true, Passthrough: map[string]string{ @@ -342,6 +344,7 @@ func TestNewConfig(t *testing.T) { DatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, PresharedKeyRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "preshared_key"}, MigrationSecretsRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "migration_secrets"}, + MigrationDatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, ProjectLabels: true, ProjectAnnotations: true, Passthrough: map[string]string{ @@ -408,6 +411,7 @@ func TestNewConfig(t *testing.T) { DatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, PresharedKeyRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "preshared_key"}, MigrationSecretsRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "migration_secrets"}, + MigrationDatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, ProjectLabels: true, ProjectAnnotations: true, Passthrough: map[string]string{ @@ -476,6 +480,7 @@ func TestNewConfig(t *testing.T) { DatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, PresharedKeyRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "preshared_key"}, MigrationSecretsRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "migration_secrets"}, + MigrationDatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, ProjectLabels: true, ProjectAnnotations: true, Passthrough: map[string]string{ @@ -563,6 +568,7 @@ func TestNewConfig(t *testing.T) { DatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, PresharedKeyRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "preshared_key"}, MigrationSecretsRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "migration_secrets"}, + MigrationDatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, ProjectLabels: true, ProjectAnnotations: true, Passthrough: map[string]string{ @@ -650,6 +656,7 @@ func TestNewConfig(t *testing.T) { DatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, PresharedKeyRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "preshared_key"}, MigrationSecretsRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "migration_secrets"}, + MigrationDatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, ProjectLabels: true, ProjectAnnotations: true, Passthrough: map[string]string{ @@ -741,6 +748,7 @@ func TestNewConfig(t *testing.T) { DatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, PresharedKeyRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "preshared_key"}, MigrationSecretsRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "migration_secrets"}, + MigrationDatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, ProjectLabels: true, ProjectAnnotations: true, Passthrough: map[string]string{ @@ -835,6 +843,7 @@ func TestNewConfig(t *testing.T) { DatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, PresharedKeyRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "preshared_key"}, MigrationSecretsRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "migration_secrets"}, + MigrationDatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, ProjectLabels: true, ProjectAnnotations: true, Passthrough: map[string]string{ @@ -924,6 +933,7 @@ func TestNewConfig(t *testing.T) { DatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, PresharedKeyRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "preshared_key"}, MigrationSecretsRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "migration_secrets"}, + MigrationDatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, ProjectLabels: true, ProjectAnnotations: true, Passthrough: map[string]string{ @@ -1013,6 +1023,7 @@ func TestNewConfig(t *testing.T) { DatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, PresharedKeyRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "preshared_key"}, MigrationSecretsRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "migration_secrets"}, + MigrationDatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, ProjectLabels: true, ProjectAnnotations: true, Passthrough: map[string]string{ @@ -1104,6 +1115,7 @@ func TestNewConfig(t *testing.T) { DatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, PresharedKeyRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "preshared_key"}, MigrationSecretsRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "migration_secrets"}, + MigrationDatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, ProjectLabels: true, ProjectAnnotations: true, Passthrough: map[string]string{ @@ -1198,6 +1210,7 @@ func TestNewConfig(t *testing.T) { DatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, PresharedKeyRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "preshared_key"}, MigrationSecretsRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "migration_secrets"}, + MigrationDatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, ProjectLabels: true, ProjectAnnotations: true, Passthrough: map[string]string{ @@ -1289,6 +1302,7 @@ func TestNewConfig(t *testing.T) { DatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, PresharedKeyRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "preshared_key"}, MigrationSecretsRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "migration_secrets"}, + MigrationDatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, ProjectLabels: true, ProjectAnnotations: true, Passthrough: map[string]string{ @@ -1382,6 +1396,7 @@ func TestNewConfig(t *testing.T) { DatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, PresharedKeyRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "preshared_key"}, MigrationSecretsRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "migration_secrets"}, + MigrationDatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, ProjectLabels: true, ProjectAnnotations: true, Passthrough: map[string]string{ @@ -1473,6 +1488,7 @@ func TestNewConfig(t *testing.T) { DatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, PresharedKeyRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "preshared_key"}, MigrationSecretsRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "migration_secrets"}, + MigrationDatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, ProjectLabels: true, ProjectAnnotations: true, Passthrough: map[string]string{ @@ -1563,6 +1579,7 @@ func TestNewConfig(t *testing.T) { DatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, PresharedKeyRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "preshared_key"}, MigrationSecretsRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "migration_secrets"}, + MigrationDatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, ProjectLabels: true, ProjectAnnotations: true, Passthrough: map[string]string{ @@ -1654,6 +1671,7 @@ func TestNewConfig(t *testing.T) { DatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, PresharedKeyRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "preshared_key"}, MigrationSecretsRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "migration_secrets"}, + MigrationDatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, ProjectLabels: true, ProjectAnnotations: true, Passthrough: map[string]string{ @@ -1750,6 +1768,7 @@ func TestNewConfig(t *testing.T) { DatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, PresharedKeyRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "preshared_key"}, MigrationSecretsRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "migration_secrets"}, + MigrationDatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, ProjectLabels: true, ProjectAnnotations: true, Passthrough: map[string]string{ @@ -1850,6 +1869,7 @@ func TestNewConfig(t *testing.T) { DatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, PresharedKeyRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "preshared_key"}, MigrationSecretsRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "migration_secrets"}, + MigrationDatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, ProjectLabels: true, ProjectAnnotations: true, Passthrough: map[string]string{ @@ -1940,6 +1960,7 @@ func TestNewConfig(t *testing.T) { DatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, PresharedKeyRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "preshared_key"}, MigrationSecretsRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "migration_secrets"}, + MigrationDatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, ProjectLabels: true, ProjectAnnotations: true, Passthrough: map[string]string{ //nolint:gosec // this is a test @@ -2031,6 +2052,7 @@ func TestNewConfig(t *testing.T) { DatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, PresharedKeyRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "preshared_key"}, MigrationSecretsRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "migration_secrets"}, + MigrationDatastoreURIRef: ResolvedCredentialRef{SecretName: "test-secret", Key: "datastore_uri"}, ProjectLabels: true, ProjectAnnotations: true, Passthrough: map[string]string{ From 7ed4984ccd48894d6dbb8ae996b1047363ec01ca Mon Sep 17 00:00:00 2001 From: ivanauth Date: Mon, 30 Mar 2026 16:06:12 -0400 Subject: [PATCH 2/2] Regenerate CRD manifests and deepcopy for MigrationDatastoreURI field --- config/crds/authzed.com_spicedbclusters.yaml | 23 +++++++++++++++++++ .../authzed/v1alpha1/zz_generated.deepcopy.go | 5 ++++ pkg/crds/authzed.com_spicedbclusters.yaml | 23 +++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/config/crds/authzed.com_spicedbclusters.yaml b/config/crds/authzed.com_spicedbclusters.yaml index ea2306ec..4f8dcf53 100644 --- a/config/crds/authzed.com_spicedbclusters.yaml +++ b/config/crds/authzed.com_spicedbclusters.yaml @@ -116,6 +116,29 @@ spec: identity, sidecar proxy). When true, SecretName and Key are ignored. type: boolean type: object + migrationDatastoreURI: + description: |- + MigrationDatastoreURI configures a separate connection string for migrations. + When set, migration jobs use this URI instead of DatastoreURI, enabling + least-privilege credentials for the application while using elevated + credentials only for migrations. + properties: + key: + description: |- + Key is the key within the Secret. Defaults to the standard SpiceDB key + name for this credential (datastore_uri or preshared_key) if omitted. + type: string + secretName: + description: SecretName is the name of the Kubernetes Secret + in the same namespace. + type: string + skip: + description: |- + Skip instructs the operator not to validate or inject this credential. + Use when the credential is provided externally (CSI driver, workload + identity, sidecar proxy). When true, SecretName and Key are ignored. + type: boolean + type: object migrationSecrets: description: MigrationSecrets configures the source for the migration secrets. diff --git a/pkg/apis/authzed/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/authzed/v1alpha1/zz_generated.deepcopy.go index 7e2804fe..649e72aa 100644 --- a/pkg/apis/authzed/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/authzed/v1alpha1/zz_generated.deepcopy.go @@ -28,6 +28,11 @@ func (in *ClusterCredentials) DeepCopyInto(out *ClusterCredentials) { *out = new(CredentialRef) **out = **in } + if in.MigrationDatastoreURI != nil { + in, out := &in.MigrationDatastoreURI, &out.MigrationDatastoreURI + *out = new(CredentialRef) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterCredentials. diff --git a/pkg/crds/authzed.com_spicedbclusters.yaml b/pkg/crds/authzed.com_spicedbclusters.yaml index ea2306ec..4f8dcf53 100644 --- a/pkg/crds/authzed.com_spicedbclusters.yaml +++ b/pkg/crds/authzed.com_spicedbclusters.yaml @@ -116,6 +116,29 @@ spec: identity, sidecar proxy). When true, SecretName and Key are ignored. type: boolean type: object + migrationDatastoreURI: + description: |- + MigrationDatastoreURI configures a separate connection string for migrations. + When set, migration jobs use this URI instead of DatastoreURI, enabling + least-privilege credentials for the application while using elevated + credentials only for migrations. + properties: + key: + description: |- + Key is the key within the Secret. Defaults to the standard SpiceDB key + name for this credential (datastore_uri or preshared_key) if omitted. + type: string + secretName: + description: SecretName is the name of the Kubernetes Secret + in the same namespace. + type: string + skip: + description: |- + Skip instructs the operator not to validate or inject this credential. + Use when the credential is provided externally (CSI driver, workload + identity, sidecar proxy). When true, SecretName and Key are ignored. + type: boolean + type: object migrationSecrets: description: MigrationSecrets configures the source for the migration secrets.