The first `make build` failed before any step ran: INVALID_ARGUMENT: could not resolve source: cb-image@... does not have storage.objects.get access to ... gitea-496920_cloudbuild/source/... `gcloud builds submit` uploads the source tarball to <project>_cloudbuild and the build, running as the user-specified cb-image@, must read it back. Nothing grants that. Binding on that bucket is not an option: gcloud creates it on the first submit, after `pulumi up` has already run. Project-wide objectViewer would also open the backup, config and state buckets. Pulumi now owns <project>-gitea-build-source, readable by cb-image@ and nothing else, with a 7-day delete rule since each tarball is read once. `make build` stages there via --gcs-source-staging-dir. Triggered builds fetch source through the GitHub connection and are unaffected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
184 lines
7.1 KiB
Go
184 lines
7.1 KiB
Go
// Package iam creates the workload identities and grants them the narrowest set
|
|
// of roles that still works.
|
|
//
|
|
// Note what is NOT here: the Cloud Build service account that runs Pulumi
|
|
// itself. That one is created by scripts/bootstrap.sh, because it is the
|
|
// identity performing the very `pulumi up` that would create it.
|
|
package iam
|
|
|
|
import (
|
|
"github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/artifactregistry"
|
|
"github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/projects"
|
|
"github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/secretmanager"
|
|
"github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/serviceaccount"
|
|
"github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/storage"
|
|
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
|
|
|
|
"gitea-infra/pkg/config"
|
|
)
|
|
|
|
type Accounts struct {
|
|
// VM runs the containers; it reads secrets, pulls images, writes backups,
|
|
// and answers ACME DNS-01 challenges.
|
|
VM *serviceaccount.Account
|
|
// Image builds and pushes container images, then triggers the rollout over
|
|
// an IAP tunnel.
|
|
Image *serviceaccount.Account
|
|
}
|
|
|
|
func New(ctx *pulumi.Context, cfg *config.Config, deps []pulumi.Resource) (*Accounts, error) {
|
|
opts := pulumi.DependsOn(deps)
|
|
|
|
vm, err := serviceaccount.NewAccount(ctx, "gitea-vm-sa", &serviceaccount.AccountArgs{
|
|
AccountId: pulumi.String("gitea-vm"),
|
|
DisplayName: pulumi.String("Gitea VM"),
|
|
Description: pulumi.String("Workload identity for the Gitea instance"),
|
|
}, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
img, err := serviceaccount.NewAccount(ctx, "cb-image-sa", &serviceaccount.AccountArgs{
|
|
AccountId: pulumi.String("cb-image"),
|
|
DisplayName: pulumi.String("Cloud Build: images"),
|
|
Description: pulumi.String("Builds and pushes container images, then rolls them out"),
|
|
}, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Observability only. Everything with real blast radius is granted per
|
|
// resource further down.
|
|
vmProjectRoles := []string{
|
|
"roles/logging.logWriter",
|
|
"roles/monitoring.metricWriter",
|
|
}
|
|
for _, role := range vmProjectRoles {
|
|
if _, err := projects.NewIAMMember(ctx, "vm-"+role, &projects.IAMMemberArgs{
|
|
Project: pulumi.String(cfg.Project),
|
|
Role: pulumi.String(role),
|
|
Member: pulumi.Sprintf("serviceAccount:%s", vm.Email),
|
|
}, opts); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
imageProjectRoles := []string{
|
|
"roles/logging.logWriter",
|
|
// Reaching the VM without a public SSH port.
|
|
"roles/iap.tunnelResourceAccessor",
|
|
// Needed to push an ephemeral SSH key via OS Login; osLogin (non-admin)
|
|
// is not enough because the rollout runs `sudo systemctl`.
|
|
"roles/compute.osAdminLogin",
|
|
}
|
|
for _, role := range imageProjectRoles {
|
|
if _, err := projects.NewIAMMember(ctx, "img-"+role, &projects.IAMMemberArgs{
|
|
Project: pulumi.String(cfg.Project),
|
|
Role: pulumi.String(role),
|
|
Member: pulumi.Sprintf("serviceAccount:%s", img.Email),
|
|
}, opts); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// OS Login on an instance that runs as gitea-vm@ requires the caller to be
|
|
// able to act as that account.
|
|
if _, err := serviceaccount.NewIAMMember(ctx, "img-act-as-vm", &serviceaccount.IAMMemberArgs{
|
|
ServiceAccountId: vm.Name,
|
|
Role: pulumi.String("roles/iam.serviceAccountUser"),
|
|
Member: pulumi.Sprintf("serviceAccount:%s", img.Email),
|
|
}, opts); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &Accounts{VM: vm, Image: img}, nil
|
|
}
|
|
|
|
// GrantRegistry scopes image pull/push to the one repository rather than
|
|
// granting project-wide Artifact Registry roles.
|
|
func GrantRegistry(ctx *pulumi.Context, cfg *config.Config, a *Accounts, repo *artifactregistry.Repository) error {
|
|
if _, err := artifactregistry.NewRepositoryIamMember(ctx, "vm-ar-reader", &artifactregistry.RepositoryIamMemberArgs{
|
|
Project: pulumi.String(cfg.Project),
|
|
Location: repo.Location,
|
|
Repository: repo.Name,
|
|
Role: pulumi.String("roles/artifactregistry.reader"),
|
|
Member: pulumi.Sprintf("serviceAccount:%s", a.VM.Email),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
_, err := artifactregistry.NewRepositoryIamMember(ctx, "img-ar-writer", &artifactregistry.RepositoryIamMemberArgs{
|
|
Project: pulumi.String(cfg.Project),
|
|
Location: repo.Location,
|
|
Repository: repo.Name,
|
|
Role: pulumi.String("roles/artifactregistry.writer"),
|
|
Member: pulumi.Sprintf("serviceAccount:%s", a.Image.Email),
|
|
})
|
|
return err
|
|
}
|
|
|
|
// GrantSecrets gives the VM read access to each Gitea secret individually --
|
|
// not roles/secretmanager.secretAccessor across the project.
|
|
//
|
|
// Takes ids rather than resources because the secrets are created by
|
|
// scripts/bootstrap.sh, not by Pulumi; see package secrets for why.
|
|
func GrantSecrets(ctx *pulumi.Context, cfg *config.Config, a *Accounts, names []string) error {
|
|
for _, name := range names {
|
|
if err := GrantSecretRead(ctx, cfg, a, name); err != nil {
|
|
return err
|
|
}
|
|
// vm/bootstrap.sh's safety net adds a version if one is somehow missing.
|
|
if _, err := secretmanager.NewSecretIamMember(ctx, "vm-add-"+name, &secretmanager.SecretIamMemberArgs{
|
|
Project: pulumi.String(cfg.Project),
|
|
SecretId: pulumi.String(name),
|
|
Role: pulumi.String("roles/secretmanager.secretVersionAdder"),
|
|
Member: pulumi.Sprintf("serviceAccount:%s", a.VM.Email),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GrantSecretRead gives the VM read-only access to one secret. On its own it is
|
|
// for operator-supplied values the VM must never write; GrantSecrets adds
|
|
// version-adder on top for the ones it may generate.
|
|
func GrantSecretRead(ctx *pulumi.Context, cfg *config.Config, a *Accounts, name string) error {
|
|
_, err := secretmanager.NewSecretIamMember(ctx, "vm-read-"+name, &secretmanager.SecretIamMemberArgs{
|
|
Project: pulumi.String(cfg.Project),
|
|
SecretId: pulumi.String(name),
|
|
Role: pulumi.String("roles/secretmanager.secretAccessor"),
|
|
Member: pulumi.Sprintf("serviceAccount:%s", a.VM.Email),
|
|
})
|
|
return err
|
|
}
|
|
|
|
// GrantBuildSource lets cb-image@ read the tarballs `make build` stages, and
|
|
// nothing else in storage. See storage.New for why the bucket exists.
|
|
func GrantBuildSource(ctx *pulumi.Context, a *Accounts, sourceBucket *storage.Bucket) error {
|
|
_, err := storage.NewBucketIAMMember(ctx, "img-build-source-reader", &storage.BucketIAMMemberArgs{
|
|
Bucket: sourceBucket.Name,
|
|
Role: pulumi.String("roles/storage.objectViewer"),
|
|
Member: pulumi.Sprintf("serviceAccount:%s", a.Image.Email),
|
|
})
|
|
return err
|
|
}
|
|
|
|
// GrantBuckets: read-only on config, write-only on backups. The VM can create a
|
|
// backup but cannot read or delete existing ones, which limits what ransomware
|
|
// on the box could do to the backup history.
|
|
func GrantBuckets(ctx *pulumi.Context, a *Accounts, configBucket, backupBucket *storage.Bucket) error {
|
|
if _, err := storage.NewBucketIAMMember(ctx, "vm-config-reader", &storage.BucketIAMMemberArgs{
|
|
Bucket: configBucket.Name,
|
|
Role: pulumi.String("roles/storage.objectViewer"),
|
|
Member: pulumi.Sprintf("serviceAccount:%s", a.VM.Email),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
_, err := storage.NewBucketIAMMember(ctx, "vm-backup-creator", &storage.BucketIAMMemberArgs{
|
|
Bucket: backupBucket.Name,
|
|
Role: pulumi.String("roles/storage.objectCreator"),
|
|
Member: pulumi.Sprintf("serviceAccount:%s", a.VM.Email),
|
|
})
|
|
return err
|
|
}
|