From b84daa0a7541a9a0a3221b2b4333fcf985351cef Mon Sep 17 00:00:00 2001 From: Jona Neef Date: Wed, 22 Jul 2026 18:14:37 +0200 Subject: [PATCH 1/3] feat(fga): add openfga backup to bootstrap Signed-off-by: Jona Neef --- internal/bootstrap/gcp/gcp.go | 12 ++ internal/bootstrap/gcp/gcp_client.go | 49 +++++++ internal/bootstrap/gcp/gcp_test.go | 6 + internal/bootstrap/gcp/iam_admin.go | 1 + internal/bootstrap/gcp/iam_admin_test.go | 1 + internal/bootstrap/gcp/install_config.go | 67 +++++++++ internal/bootstrap/gcp/install_config_test.go | 75 ++++++++++ internal/bootstrap/gcp/mocks.go | 135 ++++++++++++++++++ 8 files changed, 346 insertions(+) diff --git a/internal/bootstrap/gcp/gcp.go b/internal/bootstrap/gcp/gcp.go index 8e8550d9..16b8a603 100644 --- a/internal/bootstrap/gcp/gcp.go +++ b/internal/bootstrap/gcp/gcp.go @@ -194,6 +194,13 @@ type CodesphereEnvironment struct { RootDiskSize int64 `json:"root_disk_size"` // Local OMS binary copied to the jumpbox instead of installing a release. RemoteOmsBinaryPath string `json:"-"` + + // OpenFGA database backups. The bucket lives in the project and is removed + // together with the project on cleanup. Access key/secret are populated only + // when a new HMAC key is created. + OpenfgaBackupBucket string `json:"openfga_backup_bucket"` + OpenfgaBackupAccessKeyID string `json:"-"` + OpenfgaBackupSecret string `json:"-"` } func NewGCPBootstrapper( @@ -276,6 +283,11 @@ func (b *GCPBootstrapper) Bootstrap() error { return fmt.Errorf("failed to ensure IAM roles: %w", err) } + err = b.stlog.Step("Ensure openfga backup bucket", b.EnsureOpenfgaBackupBucket) + if err != nil { + return fmt.Errorf("failed to ensure openfga backup bucket: %w", err) + } + err = b.stlog.Step("Ensure VPC", b.EnsureVPC) if err != nil { return fmt.Errorf("failed to ensure VPC: %w", err) diff --git a/internal/bootstrap/gcp/gcp_client.go b/internal/bootstrap/gcp/gcp_client.go index 7b764de3..06cce1d8 100644 --- a/internal/bootstrap/gcp/gcp_client.go +++ b/internal/bootstrap/gcp/gcp_client.go @@ -25,9 +25,11 @@ import ( "github.com/lithammer/shortuuid" "google.golang.org/api/cloudbilling/v1" "google.golang.org/api/dns/v1" + "google.golang.org/api/googleapi" "google.golang.org/api/iam/v1" "google.golang.org/api/iterator" publicca "google.golang.org/api/publicca/v1" + storage "google.golang.org/api/storage/v1" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/fieldmaskpb" @@ -63,6 +65,8 @@ type GCPClientManager interface { EnsureDNSRecordSets(projectID, zoneName string, records []*dns.ResourceRecordSet) error DeleteDNSRecordSets(projectID, zoneName, baseDomain string) error CreatePublicCAExternalAccountKey(projectID string) (keyID, b64MacKey string, err error) + EnsureStorageBucket(projectID, bucketName, location string) error + CreateHMACKey(projectID, serviceAccountEmail string) (accessID, secret string, err error) } // Concrete implementation @@ -874,6 +878,51 @@ func (c *GCPClient) CreatePublicCAExternalAccountKey(projectID string) (string, return key.KeyId, key.B64MacKey, nil } +// EnsureStorageBucket creates a Cloud Storage bucket in the given project and +// location. It is idempotent: an already-existing bucket owned by the project is +// treated as success. The bucket lives in the project so it is removed together +// with the project on cleanup. +func (c *GCPClient) EnsureStorageBucket(projectID, bucketName, location string) error { + svc, err := storage.NewService(c.ctx) + if err != nil { + return fmt.Errorf("failed to create storage client: %w", err) + } + + bucket := &storage.Bucket{ + Name: bucketName, + Location: location, + } + _, err = svc.Buckets.Insert(projectID, bucket).Context(c.ctx).Do() + if err != nil { + if apiErr, ok := err.(*googleapi.Error); ok && apiErr.Code == 409 { + // Bucket already exists (owned by this project on re-runs). + return nil + } + return fmt.Errorf("failed to create storage bucket %s: %w", bucketName, err) + } + return nil +} + +// CreateHMACKey creates an HMAC key for the given service account, used for +// S3-compatible access to Cloud Storage. The secret is only returned at creation +// time, so callers must persist it. HMAC keys are removed together with the +// project on cleanup. +func (c *GCPClient) CreateHMACKey(projectID, serviceAccountEmail string) (string, string, error) { + svc, err := storage.NewService(c.ctx) + if err != nil { + return "", "", fmt.Errorf("failed to create storage client: %w", err) + } + + key, err := svc.Projects.HmacKeys.Create(projectID, serviceAccountEmail).Context(c.ctx).Do() + if err != nil { + return "", "", fmt.Errorf("failed to create HMAC key: %w", err) + } + if key.Metadata == nil { + return "", "", fmt.Errorf("HMAC key response missing metadata") + } + return key.Metadata.AccessId, key.Secret, nil +} + // Helper functions func protoString(s string) *string { return &s } func protoBool(b bool) *bool { return &b } diff --git a/internal/bootstrap/gcp/gcp_test.go b/internal/bootstrap/gcp/gcp_test.go index 62ba6c8c..4ce02ec2 100644 --- a/internal/bootstrap/gcp/gcp_test.go +++ b/internal/bootstrap/gcp/gcp_test.go @@ -207,6 +207,12 @@ var _ = Describe("GCP Bootstrapper", func() { gc.EXPECT().AssignIAMRole(projectId, "cloud-controller", projectId, []string{"roles/compute.admin"}).Return(nil) gc.EXPECT().AssignIAMRole(csEnv.DNSProjectID, "cloud-controller", projectId, []string{"roles/dns.admin"}).Return(nil) + // EnsureOpenfgaBackupBucket + gc.EXPECT().EnsureStorageBucket(projectId, projectId+"-openfga-backup", "us-central1").Return(nil) + gc.EXPECT().CreateServiceAccount(projectId, "openfga-backup", "openfga-backup").Return("openfga-backup@p.iam.gserviceaccount.com", true, nil) + gc.EXPECT().AssignIAMRole(projectId, "openfga-backup", projectId, []string{"roles/storage.objectAdmin"}).Return(nil) + gc.EXPECT().CreateHMACKey(projectId, "openfga-backup@p.iam.gserviceaccount.com").Return("fake-access-id", "fake-secret", nil) + // EnsureVPC gc.EXPECT().CreateVPC(projectId, "us-central1", projectId+"-vpc", projectId+"-us-central1-subnet", projectId+"-router", projectId+"-nat-gateway").Return(nil) diff --git a/internal/bootstrap/gcp/iam_admin.go b/internal/bootstrap/gcp/iam_admin.go index 4e16e51e..3cd93ade 100644 --- a/internal/bootstrap/gcp/iam_admin.go +++ b/internal/bootstrap/gcp/iam_admin.go @@ -165,6 +165,7 @@ func (b *GCPBootstrapper) EnsureAPIsEnabled() error { "serviceusage.googleapis.com", "artifactregistry.googleapis.com", "dns.googleapis.com", + "storage.googleapis.com", } if b.Env.GoogleACMEIssuer { apis = append(apis, "publicca.googleapis.com") diff --git a/internal/bootstrap/gcp/iam_admin_test.go b/internal/bootstrap/gcp/iam_admin_test.go index ee37a846..3fc08ccf 100644 --- a/internal/bootstrap/gcp/iam_admin_test.go +++ b/internal/bootstrap/gcp/iam_admin_test.go @@ -215,6 +215,7 @@ var _ = Describe("IAM & Admin", func() { "serviceusage.googleapis.com", "artifactregistry.googleapis.com", "dns.googleapis.com", + "storage.googleapis.com", }).Return(nil) err := bs.EnsureAPIsEnabled() diff --git a/internal/bootstrap/gcp/install_config.go b/internal/bootstrap/gcp/install_config.go index 774dbdc0..0e014b87 100644 --- a/internal/bootstrap/gcp/install_config.go +++ b/internal/bootstrap/gcp/install_config.go @@ -377,6 +377,7 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error { } b.applyExternalLokiConfig() b.applyPrometheusRemoteWriteConfig() + b.applyOpenfgaBackupConfig() // Secret generation is idempotent and also backfills secrets introduced // after an existing vault was created (for example the auth keys required by @@ -453,6 +454,72 @@ func (b *GCPBootstrapper) applySshProxyConfig() { }) } +// openfgaBackupSAName is the service account whose HMAC key authenticates OpenFGA +// database backups against the S3-compatible Cloud Storage endpoint. +const openfgaBackupSAName = "openfga-backup" + +// EnsureOpenfgaBackupBucket creates the Cloud Storage bucket, dedicated service +// account and HMAC key used for OpenFGA database backups. The bucket and HMAC key +// live in the project so they are removed together with the project on cleanup. +// +// The HMAC secret is only returned at creation time, so it is persisted to the +// vault by applyOpenfgaBackupConfig. Creation is skipped when a real secret is +// already present in the vault (e.g. on re-runs or recovered configs). +func (b *GCPBootstrapper) EnsureOpenfgaBackupBucket() error { + bucketName := fmt.Sprintf("%s-openfga-backup", b.Env.ProjectID) + + if err := b.GCPClient.EnsureStorageBucket(b.Env.ProjectID, bucketName, b.Env.Region); err != nil { + return fmt.Errorf("failed to ensure openfga backup bucket: %w", err) + } + b.Env.OpenfgaBackupBucket = bucketName + + saEmail, _, err := b.GCPClient.CreateServiceAccount(b.Env.ProjectID, openfgaBackupSAName, openfgaBackupSAName) + if err != nil { + return fmt.Errorf("failed to ensure openfga backup service account: %w", err) + } + if err := b.GCPClient.AssignIAMRole(b.Env.ProjectID, openfgaBackupSAName, b.Env.ProjectID, []string{"roles/storage.objectAdmin"}); err != nil { + return fmt.Errorf("failed to assign storage role to openfga backup service account: %w", err) + } + + // The HMAC secret cannot be retrieved after creation, so only create a new key + // when we don't already have a real one persisted in the vault. + if existing := b.icg.GetVault().GetSecret(files.SecretOpenfgaDbBackupSecretAccessKey); existing != nil && + existing.Fields != nil && existing.Fields.Password != "" && existing.Fields.Password != "dummy" { + return nil + } + + accessID, secret, err := b.GCPClient.CreateHMACKey(b.Env.ProjectID, saEmail) + if err != nil { + return fmt.Errorf("failed to create openfga backup HMAC key: %w", err) + } + b.Env.OpenfgaBackupAccessKeyID = accessID + b.Env.OpenfgaBackupSecret = secret + + return nil +} + +// applyOpenfgaBackupConfig wires the bucket created by EnsureOpenfgaBackupBucket +// into the install config and persists the HMAC credentials to the vault. It is a +// no-op when no bucket was provisioned. +func (b *GCPBootstrapper) applyOpenfgaBackupConfig() { + if b.Env.OpenfgaBackupBucket == "" { + return + } + + b.Env.InstallConfig.Codesphere.OpenfgaBackups = &files.OpenfgaBackupsConfig{ + Enabled: true, + DestinationPath: "s3://" + b.Env.OpenfgaBackupBucket, + EndpointURL: "https://storage.googleapis.com", + } + + // Only overwrite when a new HMAC key was created this run; otherwise the + // existing secret loaded from the vault is kept. + if b.Env.OpenfgaBackupAccessKeyID != "" { + b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretOpenfgaDbBackupAccessKeyId, Fields: &files.SecretFields{Password: b.Env.OpenfgaBackupAccessKeyID}}) + b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretOpenfgaDbBackupSecretAccessKey, Fields: &files.SecretFields{Password: b.Env.OpenfgaBackupSecret}}) + } +} + func (b *GCPBootstrapper) applyExternalLokiConfig() { if b.Env.ExternalLokiEndpoint == "" { return diff --git a/internal/bootstrap/gcp/install_config_test.go b/internal/bootstrap/gcp/install_config_test.go index 38398963..dbbf5d35 100644 --- a/internal/bootstrap/gcp/install_config_test.go +++ b/internal/bootstrap/gcp/install_config_test.go @@ -311,6 +311,51 @@ var _ = Describe("Installconfig & Secrets", func() { }) }) + Describe("EnsureOpenfgaBackupBucket", func() { + It("creates the bucket, service account and HMAC key", func() { + vault := &files.InstallVault{} + icg.EXPECT().GetVault().Return(vault) + + gc.EXPECT().EnsureStorageBucket("pid", "pid-openfga-backup", "us-central1").Return(nil) + gc.EXPECT().CreateServiceAccount("pid", "openfga-backup", "openfga-backup").Return("openfga-backup@pid.iam.gserviceaccount.com", true, nil) + gc.EXPECT().AssignIAMRole("pid", "openfga-backup", "pid", []string{"roles/storage.objectAdmin"}).Return(nil) + gc.EXPECT().CreateHMACKey("pid", "openfga-backup@pid.iam.gserviceaccount.com").Return("access-id", "secret-key", nil) + + err := bs.EnsureOpenfgaBackupBucket() + Expect(err).NotTo(HaveOccurred()) + Expect(bs.Env.OpenfgaBackupBucket).To(Equal("pid-openfga-backup")) + Expect(bs.Env.OpenfgaBackupAccessKeyID).To(Equal("access-id")) + Expect(bs.Env.OpenfgaBackupSecret).To(Equal("secret-key")) + }) + + It("does not create a new HMAC key when a real secret already exists", func() { + vault := &files.InstallVault{ + Secrets: []files.SecretEntry{ + {Name: files.SecretOpenfgaDbBackupSecretAccessKey, Fields: &files.SecretFields{Password: "existing-secret"}}, + }, + } + icg.EXPECT().GetVault().Return(vault) + + gc.EXPECT().EnsureStorageBucket("pid", "pid-openfga-backup", "us-central1").Return(nil) + gc.EXPECT().CreateServiceAccount("pid", "openfga-backup", "openfga-backup").Return("openfga-backup@pid.iam.gserviceaccount.com", false, nil) + gc.EXPECT().AssignIAMRole("pid", "openfga-backup", "pid", []string{"roles/storage.objectAdmin"}).Return(nil) + // CreateHMACKey must not be called. + + err := bs.EnsureOpenfgaBackupBucket() + Expect(err).NotTo(HaveOccurred()) + Expect(bs.Env.OpenfgaBackupBucket).To(Equal("pid-openfga-backup")) + Expect(bs.Env.OpenfgaBackupAccessKeyID).To(BeEmpty()) + }) + + It("returns an error when bucket creation fails", func() { + gc.EXPECT().EnsureStorageBucket("pid", "pid-openfga-backup", "us-central1").Return(fmt.Errorf("bucket error")) + + err := bs.EnsureOpenfgaBackupBucket() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to ensure openfga backup bucket")) + }) + }) + Describe("UpdateInstallConfig", func() { var vault *files.InstallVault BeforeEach(func() { @@ -331,6 +376,9 @@ var _ = Describe("Installconfig & Secrets", func() { err := bs.UpdateInstallConfig() Expect(err).NotTo(HaveOccurred()) + // No openfga backup bucket provisioned → config stays unset. + Expect(bs.Env.InstallConfig.Codesphere.OpenfgaBackups).To(BeNil()) + applications := bs.Env.InstallConfig.PcApps["applications"].(map[string]interface{}) sshProxy := applications["ssh-workspace-proxy"].(map[string]interface{}) Expect(sshProxy["enabled"]).To(Equal(true)) @@ -381,6 +429,33 @@ var _ = Describe("Installconfig & Secrets", func() { Expect(bs.Env.InstallConfig.Datacenter.Name).To(Equal("staging")) }) + It("wires the openfga backup config and secrets when a bucket was provisioned", func() { + csEnv.OpenfgaBackupBucket = "pid-openfga-backup" + csEnv.OpenfgaBackupAccessKeyID = "access-id" + csEnv.OpenfgaBackupSecret = "secret-key" + + icg.EXPECT().GenerateSecrets().Return(nil) + icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) + icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + + nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice() + + err := bs.UpdateInstallConfig() + Expect(err).NotTo(HaveOccurred()) + + ob := bs.Env.InstallConfig.Codesphere.OpenfgaBackups + Expect(ob).NotTo(BeNil()) + Expect(ob.Enabled).To(BeTrue()) + Expect(ob.DestinationPath).To(Equal("s3://pid-openfga-backup")) + Expect(ob.EndpointURL).To(Equal("https://storage.googleapis.com")) + + accessKey := vault.GetSecret(files.SecretOpenfgaDbBackupAccessKeyId) + Expect(accessKey).NotTo(BeNil()) + Expect(accessKey.Fields.Password).To(Equal("access-id")) + secretKey := vault.GetSecret(files.SecretOpenfgaDbBackupSecretAccessKey) + Expect(secretKey).NotTo(BeNil()) + Expect(secretKey.Fields.Password).To(Equal("secret-key")) + }) Context("When internal flags are set in CodesphereEnvironment", func() { BeforeEach(func() { csEnv.InternalFlags = []string{"fake-exp1", "fake-exp2"} diff --git a/internal/bootstrap/gcp/mocks.go b/internal/bootstrap/gcp/mocks.go index 5b371325..b977072b 100644 --- a/internal/bootstrap/gcp/mocks.go +++ b/internal/bootstrap/gcp/mocks.go @@ -1755,3 +1755,138 @@ func (_c *MockGCPClientManager_UpdateProject_Call) RunAndReturn(run func(project _c.Call.Return(run) return _c } + +// EnsureStorageBucket provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) EnsureStorageBucket(projectID string, bucketName string, location string) error { + ret := _mock.Called(projectID, bucketName, location) + + if len(ret) == 0 { + panic("no return value specified for EnsureStorageBucket") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, string, string) error); ok { + r0 = returnFunc(projectID, bucketName, location) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockGCPClientManager_EnsureStorageBucket_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnsureStorageBucket' +type MockGCPClientManager_EnsureStorageBucket_Call struct { + *mock.Call +} + +// EnsureStorageBucket is a helper method to define mock.On call +// - projectID string +// - bucketName string +// - location string +func (_e *MockGCPClientManager_Expecter) EnsureStorageBucket(projectID any, bucketName any, location any) *MockGCPClientManager_EnsureStorageBucket_Call { + return &MockGCPClientManager_EnsureStorageBucket_Call{Call: _e.mock.On("EnsureStorageBucket", projectID, bucketName, location)} +} + +func (_c *MockGCPClientManager_EnsureStorageBucket_Call) Run(run func(projectID string, bucketName string, location string)) *MockGCPClientManager_EnsureStorageBucket_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_EnsureStorageBucket_Call) Return(err error) *MockGCPClientManager_EnsureStorageBucket_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockGCPClientManager_EnsureStorageBucket_Call) RunAndReturn(run func(projectID string, bucketName string, location string) error) *MockGCPClientManager_EnsureStorageBucket_Call { + _c.Call.Return(run) + return _c +} + +// CreateHMACKey provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) CreateHMACKey(projectID string, serviceAccountEmail string) (string, string, error) { + ret := _mock.Called(projectID, serviceAccountEmail) + + if len(ret) == 0 { + panic("no return value specified for CreateHMACKey") + } + + var r0 string + var r1 string + var r2 error + if returnFunc, ok := ret.Get(0).(func(string, string) (string, string, error)); ok { + return returnFunc(projectID, serviceAccountEmail) + } + if returnFunc, ok := ret.Get(0).(func(string, string) string); ok { + r0 = returnFunc(projectID, serviceAccountEmail) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(string, string) string); ok { + r1 = returnFunc(projectID, serviceAccountEmail) + } else { + r1 = ret.Get(1).(string) + } + if returnFunc, ok := ret.Get(2).(func(string, string) error); ok { + r2 = returnFunc(projectID, serviceAccountEmail) + } else { + r2 = ret.Error(2) + } + return r0, r1, r2 +} + +// MockGCPClientManager_CreateHMACKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateHMACKey' +type MockGCPClientManager_CreateHMACKey_Call struct { + *mock.Call +} + +// CreateHMACKey is a helper method to define mock.On call +// - projectID string +// - serviceAccountEmail string +func (_e *MockGCPClientManager_Expecter) CreateHMACKey(projectID any, serviceAccountEmail any) *MockGCPClientManager_CreateHMACKey_Call { + return &MockGCPClientManager_CreateHMACKey_Call{Call: _e.mock.On("CreateHMACKey", projectID, serviceAccountEmail)} +} + +func (_c *MockGCPClientManager_CreateHMACKey_Call) Run(run func(projectID string, serviceAccountEmail string)) *MockGCPClientManager_CreateHMACKey_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_CreateHMACKey_Call) Return(accessID string, secret string, err error) *MockGCPClientManager_CreateHMACKey_Call { + _c.Call.Return(accessID, secret, err) + return _c +} + +func (_c *MockGCPClientManager_CreateHMACKey_Call) RunAndReturn(run func(projectID string, serviceAccountEmail string) (string, string, error)) *MockGCPClientManager_CreateHMACKey_Call { + _c.Call.Return(run) + return _c +} From 9df21d3ef5c0cc6702dadf91a98de0c3cd098ae3 Mon Sep 17 00:00:00 2001 From: NJona <25478046+NJona@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:16:33 +0000 Subject: [PATCH 2/3] chore(docs): Auto-update docs and licenses Signed-off-by: NJona <25478046+NJona@users.noreply.github.com> --- internal/bootstrap/gcp/mocks.go | 270 ++++++++++++++++---------------- 1 file changed, 135 insertions(+), 135 deletions(-) diff --git a/internal/bootstrap/gcp/mocks.go b/internal/bootstrap/gcp/mocks.go index b977072b..7fff6c97 100644 --- a/internal/bootstrap/gcp/mocks.go +++ b/internal/bootstrap/gcp/mocks.go @@ -312,6 +312,78 @@ func (_c *MockGCPClientManager_CreateFirewallRule_Call) RunAndReturn(run func(pr return _c } +// CreateHMACKey provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) CreateHMACKey(projectID string, serviceAccountEmail string) (string, string, error) { + ret := _mock.Called(projectID, serviceAccountEmail) + + if len(ret) == 0 { + panic("no return value specified for CreateHMACKey") + } + + var r0 string + var r1 string + var r2 error + if returnFunc, ok := ret.Get(0).(func(string, string) (string, string, error)); ok { + return returnFunc(projectID, serviceAccountEmail) + } + if returnFunc, ok := ret.Get(0).(func(string, string) string); ok { + r0 = returnFunc(projectID, serviceAccountEmail) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(string, string) string); ok { + r1 = returnFunc(projectID, serviceAccountEmail) + } else { + r1 = ret.Get(1).(string) + } + if returnFunc, ok := ret.Get(2).(func(string, string) error); ok { + r2 = returnFunc(projectID, serviceAccountEmail) + } else { + r2 = ret.Error(2) + } + return r0, r1, r2 +} + +// MockGCPClientManager_CreateHMACKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateHMACKey' +type MockGCPClientManager_CreateHMACKey_Call struct { + *mock.Call +} + +// CreateHMACKey is a helper method to define mock.On call +// - projectID string +// - serviceAccountEmail string +func (_e *MockGCPClientManager_Expecter) CreateHMACKey(projectID any, serviceAccountEmail any) *MockGCPClientManager_CreateHMACKey_Call { + return &MockGCPClientManager_CreateHMACKey_Call{Call: _e.mock.On("CreateHMACKey", projectID, serviceAccountEmail)} +} + +func (_c *MockGCPClientManager_CreateHMACKey_Call) Run(run func(projectID string, serviceAccountEmail string)) *MockGCPClientManager_CreateHMACKey_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_CreateHMACKey_Call) Return(accessID string, secret string, err error) *MockGCPClientManager_CreateHMACKey_Call { + _c.Call.Return(accessID, secret, err) + return _c +} + +func (_c *MockGCPClientManager_CreateHMACKey_Call) RunAndReturn(run func(projectID string, serviceAccountEmail string) (string, string, error)) *MockGCPClientManager_CreateHMACKey_Call { + _c.Call.Return(run) + return _c +} + // CreateInstance provides a mock function for the type MockGCPClientManager func (_mock *MockGCPClientManager) CreateInstance(projectID string, zone string, instance *computepb.Instance) error { ret := _mock.Called(projectID, zone, instance) @@ -1155,6 +1227,69 @@ func (_c *MockGCPClientManager_EnsureDNSRecordSets_Call) RunAndReturn(run func(p return _c } +// EnsureStorageBucket provides a mock function for the type MockGCPClientManager +func (_mock *MockGCPClientManager) EnsureStorageBucket(projectID string, bucketName string, location string) error { + ret := _mock.Called(projectID, bucketName, location) + + if len(ret) == 0 { + panic("no return value specified for EnsureStorageBucket") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, string, string) error); ok { + r0 = returnFunc(projectID, bucketName, location) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockGCPClientManager_EnsureStorageBucket_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnsureStorageBucket' +type MockGCPClientManager_EnsureStorageBucket_Call struct { + *mock.Call +} + +// EnsureStorageBucket is a helper method to define mock.On call +// - projectID string +// - bucketName string +// - location string +func (_e *MockGCPClientManager_Expecter) EnsureStorageBucket(projectID any, bucketName any, location any) *MockGCPClientManager_EnsureStorageBucket_Call { + return &MockGCPClientManager_EnsureStorageBucket_Call{Call: _e.mock.On("EnsureStorageBucket", projectID, bucketName, location)} +} + +func (_c *MockGCPClientManager_EnsureStorageBucket_Call) Run(run func(projectID string, bucketName string, location string)) *MockGCPClientManager_EnsureStorageBucket_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockGCPClientManager_EnsureStorageBucket_Call) Return(err error) *MockGCPClientManager_EnsureStorageBucket_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockGCPClientManager_EnsureStorageBucket_Call) RunAndReturn(run func(projectID string, bucketName string, location string) error) *MockGCPClientManager_EnsureStorageBucket_Call { + _c.Call.Return(run) + return _c +} + // GetAddress provides a mock function for the type MockGCPClientManager func (_mock *MockGCPClientManager) GetAddress(projectID string, region string, addressName string) (*computepb.Address, error) { ret := _mock.Called(projectID, region, addressName) @@ -1755,138 +1890,3 @@ func (_c *MockGCPClientManager_UpdateProject_Call) RunAndReturn(run func(project _c.Call.Return(run) return _c } - -// EnsureStorageBucket provides a mock function for the type MockGCPClientManager -func (_mock *MockGCPClientManager) EnsureStorageBucket(projectID string, bucketName string, location string) error { - ret := _mock.Called(projectID, bucketName, location) - - if len(ret) == 0 { - panic("no return value specified for EnsureStorageBucket") - } - - var r0 error - if returnFunc, ok := ret.Get(0).(func(string, string, string) error); ok { - r0 = returnFunc(projectID, bucketName, location) - } else { - r0 = ret.Error(0) - } - return r0 -} - -// MockGCPClientManager_EnsureStorageBucket_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnsureStorageBucket' -type MockGCPClientManager_EnsureStorageBucket_Call struct { - *mock.Call -} - -// EnsureStorageBucket is a helper method to define mock.On call -// - projectID string -// - bucketName string -// - location string -func (_e *MockGCPClientManager_Expecter) EnsureStorageBucket(projectID any, bucketName any, location any) *MockGCPClientManager_EnsureStorageBucket_Call { - return &MockGCPClientManager_EnsureStorageBucket_Call{Call: _e.mock.On("EnsureStorageBucket", projectID, bucketName, location)} -} - -func (_c *MockGCPClientManager_EnsureStorageBucket_Call) Run(run func(projectID string, bucketName string, location string)) *MockGCPClientManager_EnsureStorageBucket_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 string - if args[0] != nil { - arg0 = args[0].(string) - } - var arg1 string - if args[1] != nil { - arg1 = args[1].(string) - } - var arg2 string - if args[2] != nil { - arg2 = args[2].(string) - } - run( - arg0, - arg1, - arg2, - ) - }) - return _c -} - -func (_c *MockGCPClientManager_EnsureStorageBucket_Call) Return(err error) *MockGCPClientManager_EnsureStorageBucket_Call { - _c.Call.Return(err) - return _c -} - -func (_c *MockGCPClientManager_EnsureStorageBucket_Call) RunAndReturn(run func(projectID string, bucketName string, location string) error) *MockGCPClientManager_EnsureStorageBucket_Call { - _c.Call.Return(run) - return _c -} - -// CreateHMACKey provides a mock function for the type MockGCPClientManager -func (_mock *MockGCPClientManager) CreateHMACKey(projectID string, serviceAccountEmail string) (string, string, error) { - ret := _mock.Called(projectID, serviceAccountEmail) - - if len(ret) == 0 { - panic("no return value specified for CreateHMACKey") - } - - var r0 string - var r1 string - var r2 error - if returnFunc, ok := ret.Get(0).(func(string, string) (string, string, error)); ok { - return returnFunc(projectID, serviceAccountEmail) - } - if returnFunc, ok := ret.Get(0).(func(string, string) string); ok { - r0 = returnFunc(projectID, serviceAccountEmail) - } else { - r0 = ret.Get(0).(string) - } - if returnFunc, ok := ret.Get(1).(func(string, string) string); ok { - r1 = returnFunc(projectID, serviceAccountEmail) - } else { - r1 = ret.Get(1).(string) - } - if returnFunc, ok := ret.Get(2).(func(string, string) error); ok { - r2 = returnFunc(projectID, serviceAccountEmail) - } else { - r2 = ret.Error(2) - } - return r0, r1, r2 -} - -// MockGCPClientManager_CreateHMACKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateHMACKey' -type MockGCPClientManager_CreateHMACKey_Call struct { - *mock.Call -} - -// CreateHMACKey is a helper method to define mock.On call -// - projectID string -// - serviceAccountEmail string -func (_e *MockGCPClientManager_Expecter) CreateHMACKey(projectID any, serviceAccountEmail any) *MockGCPClientManager_CreateHMACKey_Call { - return &MockGCPClientManager_CreateHMACKey_Call{Call: _e.mock.On("CreateHMACKey", projectID, serviceAccountEmail)} -} - -func (_c *MockGCPClientManager_CreateHMACKey_Call) Run(run func(projectID string, serviceAccountEmail string)) *MockGCPClientManager_CreateHMACKey_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 string - if args[0] != nil { - arg0 = args[0].(string) - } - var arg1 string - if args[1] != nil { - arg1 = args[1].(string) - } - run( - arg0, - arg1, - ) - }) - return _c -} - -func (_c *MockGCPClientManager_CreateHMACKey_Call) Return(accessID string, secret string, err error) *MockGCPClientManager_CreateHMACKey_Call { - _c.Call.Return(accessID, secret, err) - return _c -} - -func (_c *MockGCPClientManager_CreateHMACKey_Call) RunAndReturn(run func(projectID string, serviceAccountEmail string) (string, string, error)) *MockGCPClientManager_CreateHMACKey_Call { - _c.Call.Return(run) - return _c -} From 1a9126fc6f44c7de27745cea96a42bcb7da86a0e Mon Sep 17 00:00:00 2001 From: Jona Neef Date: Wed, 12 Aug 2026 13:45:18 +0200 Subject: [PATCH 3/3] refactor(openfga): consolidate backup SA provisioning Move the openfga-backup service account creation into EnsureServiceAccounts and its storage.objectAdmin role assignment into EnsureIAMRoles (via the shared retry helper), so all service accounts and roles are provisioned consistently in one place. EnsureOpenfgaBackupBucket now only ensures the bucket and HMAC key, reconstructing the SA email deterministically. Also fix a latent WriteVault/WriteUnencryptedVault mismatch in the backup config test. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Jona Neef --- internal/bootstrap/gcp/gcp_test.go | 6 +++--- internal/bootstrap/gcp/iam_admin.go | 11 +++++++++++ internal/bootstrap/gcp/iam_admin_test.go | 4 ++++ internal/bootstrap/gcp/install_config.go | 16 +++++----------- internal/bootstrap/gcp/install_config_test.go | 8 ++------ 5 files changed, 25 insertions(+), 20 deletions(-) diff --git a/internal/bootstrap/gcp/gcp_test.go b/internal/bootstrap/gcp/gcp_test.go index 4ce02ec2..c2ad4063 100644 --- a/internal/bootstrap/gcp/gcp_test.go +++ b/internal/bootstrap/gcp/gcp_test.go @@ -199,6 +199,7 @@ var _ = Describe("GCP Bootstrapper", func() { // EnsureServiceAccounts gc.EXPECT().CreateServiceAccount(projectId, "cloud-controller", "cloud-controller").Return("cloud-controller@p.iam.gserviceaccount.com", false, nil) + gc.EXPECT().CreateServiceAccount(projectId, "openfga-backup", "openfga-backup").Return("openfga-backup@"+projectId+".iam.gserviceaccount.com", true, nil) gc.EXPECT().CreateServiceAccount(projectId, "artifact-registry-writer", "artifact-registry-writer").Return("writer@p.iam.gserviceaccount.com", true, nil) gc.EXPECT().CreateServiceAccountKey(projectId, "writer@p.iam.gserviceaccount.com").Return("fake-key", nil) @@ -206,12 +207,11 @@ var _ = Describe("GCP Bootstrapper", func() { gc.EXPECT().AssignIAMRole(projectId, "artifact-registry-writer", projectId, []string{"roles/artifactregistry.writer"}).Return(nil) gc.EXPECT().AssignIAMRole(projectId, "cloud-controller", projectId, []string{"roles/compute.admin"}).Return(nil) gc.EXPECT().AssignIAMRole(csEnv.DNSProjectID, "cloud-controller", projectId, []string{"roles/dns.admin"}).Return(nil) + gc.EXPECT().AssignIAMRole(projectId, "openfga-backup", projectId, []string{"roles/storage.objectAdmin"}).Return(nil) // EnsureOpenfgaBackupBucket gc.EXPECT().EnsureStorageBucket(projectId, projectId+"-openfga-backup", "us-central1").Return(nil) - gc.EXPECT().CreateServiceAccount(projectId, "openfga-backup", "openfga-backup").Return("openfga-backup@p.iam.gserviceaccount.com", true, nil) - gc.EXPECT().AssignIAMRole(projectId, "openfga-backup", projectId, []string{"roles/storage.objectAdmin"}).Return(nil) - gc.EXPECT().CreateHMACKey(projectId, "openfga-backup@p.iam.gserviceaccount.com").Return("fake-access-id", "fake-secret", nil) + gc.EXPECT().CreateHMACKey(projectId, "openfga-backup@"+projectId+".iam.gserviceaccount.com").Return("fake-access-id", "fake-secret", nil) // EnsureVPC gc.EXPECT().CreateVPC(projectId, "us-central1", projectId+"-vpc", projectId+"-us-central1-subnet", projectId+"-router", projectId+"-nat-gateway").Return(nil) diff --git a/internal/bootstrap/gcp/iam_admin.go b/internal/bootstrap/gcp/iam_admin.go index 3cd93ade..4391cc2d 100644 --- a/internal/bootstrap/gcp/iam_admin.go +++ b/internal/bootstrap/gcp/iam_admin.go @@ -186,6 +186,12 @@ func (b *GCPBootstrapper) EnsureServiceAccounts() error { return err } + // Dedicated service account for OpenFGA database backups. Its storage role is + // assigned in EnsureIAMRoles and its HMAC key created in EnsureOpenfgaBackupBucket. + if _, _, err := b.GCPClient.CreateServiceAccount(b.Env.ProjectID, openfgaBackupSAName, openfgaBackupSAName); err != nil { + return fmt.Errorf("failed to ensure openfga backup service account: %w", err) + } + if b.Env.RegistryType == RegistryTypeArtifactRegistry { sa, newSa, err := b.GCPClient.CreateServiceAccount(b.Env.ProjectID, "artifact-registry-writer", "artifact-registry-writer") if err != nil { @@ -234,6 +240,11 @@ func (b *GCPBootstrapper) EnsureIAMRoles() error { return fmt.Errorf("failed to ensure DNS permissions: %w", err) } + err = b.ensureIAMRoleWithRetry(b.Env.ProjectID, openfgaBackupSAName, b.Env.ProjectID, []string{"roles/storage.objectAdmin"}) + if err != nil { + return fmt.Errorf("failed to ensure openfga backup role bindings: %w", err) + } + if b.Env.RegistryType != RegistryTypeArtifactRegistry { return nil } diff --git a/internal/bootstrap/gcp/iam_admin_test.go b/internal/bootstrap/gcp/iam_admin_test.go index 3fc08ccf..97facad9 100644 --- a/internal/bootstrap/gcp/iam_admin_test.go +++ b/internal/bootstrap/gcp/iam_admin_test.go @@ -243,6 +243,7 @@ var _ = Describe("IAM & Admin", func() { }) It("creates cloud-controller and skips writer", func() { gc.EXPECT().CreateServiceAccount(csEnv.ProjectID, "cloud-controller", "cloud-controller").Return("email@sa", false, nil) + gc.EXPECT().CreateServiceAccount(csEnv.ProjectID, "openfga-backup", "openfga-backup").Return("openfga-backup@sa", true, nil) err := bs.EnsureServiceAccounts() Expect(err).NotTo(HaveOccurred()) @@ -259,6 +260,7 @@ var _ = Describe("IAM & Admin", func() { icg.EXPECT().GetVault().Return(vault) gc.EXPECT().CreateServiceAccount(csEnv.ProjectID, "cloud-controller", "cloud-controller").Return("email@sa", false, nil) + gc.EXPECT().CreateServiceAccount(csEnv.ProjectID, "openfga-backup", "openfga-backup").Return("openfga-backup@sa", true, nil) gc.EXPECT().CreateServiceAccount(csEnv.ProjectID, "artifact-registry-writer", "artifact-registry-writer").Return("writer@sa", true, nil) gc.EXPECT().CreateServiceAccountKey(csEnv.ProjectID, "writer@sa").Return("key-content", nil) err := bs.EnsureServiceAccounts() @@ -287,6 +289,7 @@ var _ = Describe("IAM & Admin", func() { It("assigns roles correctly", func() { gc.EXPECT().AssignIAMRole(csEnv.ProjectID, "cloud-controller", csEnv.ProjectID, []string{"roles/compute.admin"}).Return(nil) gc.EXPECT().AssignIAMRole(csEnv.DNSProjectID, "cloud-controller", csEnv.ProjectID, []string{"roles/dns.admin"}).Return(nil) + gc.EXPECT().AssignIAMRole(csEnv.ProjectID, "openfga-backup", csEnv.ProjectID, []string{"roles/storage.objectAdmin"}).Return(nil) gc.EXPECT().AssignIAMRole(csEnv.ProjectID, "artifact-registry-writer", csEnv.ProjectID, []string{"roles/artifactregistry.writer"}).Return(nil) err := bs.EnsureIAMRoles() @@ -300,6 +303,7 @@ var _ = Describe("IAM & Admin", func() { It("assigns DNS role to cloud-controller in main project", func() { gc.EXPECT().AssignIAMRole(csEnv.ProjectID, "cloud-controller", csEnv.ProjectID, []string{"roles/compute.admin"}).Return(nil) gc.EXPECT().AssignIAMRole(csEnv.ProjectID, "cloud-controller", csEnv.ProjectID, []string{"roles/dns.admin"}).Return(nil) + gc.EXPECT().AssignIAMRole(csEnv.ProjectID, "openfga-backup", csEnv.ProjectID, []string{"roles/storage.objectAdmin"}).Return(nil) gc.EXPECT().AssignIAMRole(csEnv.ProjectID, "artifact-registry-writer", csEnv.ProjectID, []string{"roles/artifactregistry.writer"}).Return(nil) err := bs.EnsureIAMRoles() diff --git a/internal/bootstrap/gcp/install_config.go b/internal/bootstrap/gcp/install_config.go index 0e014b87..98b200a6 100644 --- a/internal/bootstrap/gcp/install_config.go +++ b/internal/bootstrap/gcp/install_config.go @@ -458,9 +458,10 @@ func (b *GCPBootstrapper) applySshProxyConfig() { // database backups against the S3-compatible Cloud Storage endpoint. const openfgaBackupSAName = "openfga-backup" -// EnsureOpenfgaBackupBucket creates the Cloud Storage bucket, dedicated service -// account and HMAC key used for OpenFGA database backups. The bucket and HMAC key -// live in the project so they are removed together with the project on cleanup. +// EnsureOpenfgaBackupBucket creates the Cloud Storage bucket and HMAC key used for +// OpenFGA database backups. The bucket and HMAC key live in the project so they are +// removed together with the project on cleanup. The dedicated service account and +// its storage role are provisioned by EnsureServiceAccounts / EnsureIAMRoles. // // The HMAC secret is only returned at creation time, so it is persisted to the // vault by applyOpenfgaBackupConfig. Creation is skipped when a real secret is @@ -473,14 +474,6 @@ func (b *GCPBootstrapper) EnsureOpenfgaBackupBucket() error { } b.Env.OpenfgaBackupBucket = bucketName - saEmail, _, err := b.GCPClient.CreateServiceAccount(b.Env.ProjectID, openfgaBackupSAName, openfgaBackupSAName) - if err != nil { - return fmt.Errorf("failed to ensure openfga backup service account: %w", err) - } - if err := b.GCPClient.AssignIAMRole(b.Env.ProjectID, openfgaBackupSAName, b.Env.ProjectID, []string{"roles/storage.objectAdmin"}); err != nil { - return fmt.Errorf("failed to assign storage role to openfga backup service account: %w", err) - } - // The HMAC secret cannot be retrieved after creation, so only create a new key // when we don't already have a real one persisted in the vault. if existing := b.icg.GetVault().GetSecret(files.SecretOpenfgaDbBackupSecretAccessKey); existing != nil && @@ -488,6 +481,7 @@ func (b *GCPBootstrapper) EnsureOpenfgaBackupBucket() error { return nil } + saEmail := fmt.Sprintf("%s@%s.iam.gserviceaccount.com", openfgaBackupSAName, b.Env.ProjectID) accessID, secret, err := b.GCPClient.CreateHMACKey(b.Env.ProjectID, saEmail) if err != nil { return fmt.Errorf("failed to create openfga backup HMAC key: %w", err) diff --git a/internal/bootstrap/gcp/install_config_test.go b/internal/bootstrap/gcp/install_config_test.go index dbbf5d35..bdd27ef4 100644 --- a/internal/bootstrap/gcp/install_config_test.go +++ b/internal/bootstrap/gcp/install_config_test.go @@ -312,13 +312,11 @@ var _ = Describe("Installconfig & Secrets", func() { }) Describe("EnsureOpenfgaBackupBucket", func() { - It("creates the bucket, service account and HMAC key", func() { + It("creates the bucket and HMAC key", func() { vault := &files.InstallVault{} icg.EXPECT().GetVault().Return(vault) gc.EXPECT().EnsureStorageBucket("pid", "pid-openfga-backup", "us-central1").Return(nil) - gc.EXPECT().CreateServiceAccount("pid", "openfga-backup", "openfga-backup").Return("openfga-backup@pid.iam.gserviceaccount.com", true, nil) - gc.EXPECT().AssignIAMRole("pid", "openfga-backup", "pid", []string{"roles/storage.objectAdmin"}).Return(nil) gc.EXPECT().CreateHMACKey("pid", "openfga-backup@pid.iam.gserviceaccount.com").Return("access-id", "secret-key", nil) err := bs.EnsureOpenfgaBackupBucket() @@ -337,8 +335,6 @@ var _ = Describe("Installconfig & Secrets", func() { icg.EXPECT().GetVault().Return(vault) gc.EXPECT().EnsureStorageBucket("pid", "pid-openfga-backup", "us-central1").Return(nil) - gc.EXPECT().CreateServiceAccount("pid", "openfga-backup", "openfga-backup").Return("openfga-backup@pid.iam.gserviceaccount.com", false, nil) - gc.EXPECT().AssignIAMRole("pid", "openfga-backup", "pid", []string{"roles/storage.objectAdmin"}).Return(nil) // CreateHMACKey must not be called. err := bs.EnsureOpenfgaBackupBucket() @@ -436,7 +432,7 @@ var _ = Describe("Installconfig & Secrets", func() { icg.EXPECT().GenerateSecrets().Return(nil) icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil) - icg.EXPECT().WriteVault("fake-secret", true).Return(nil) + icg.EXPECT().WriteUnencryptedVault("fake-secret", true).Return(nil) nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice()