From 69d586bfcc883dcb7db8d6a465362ca5eeabc887 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sat, 10 Oct 2026 15:37:12 +0700 Subject: [PATCH] Let the VM list DNS zones so Caddy can present DNS-01 challenges With DNS resolution fixed, issuance failed at the challenge: presenting for challenge: adding temporary record for zone "gitea.jasonmross.dev.": googleapi: Error 403: Forbidden Testing with the VM service account's own token: managedZones/main and its rrsets return 200, but managedZones (list) returns 403. The googleclouddns plugin resolves the domain to a zone by listing the project's managed zones, and listing is a project-level permission that the zone-scoped dns.admin binding cannot grant. Grant roles/dns.reader on the project. It adds read access only, in a project that holds this single zone; every write stays zone-scoped. A custom role with just dns.managedZones.list was the alternative, but managing it would need iam.roleAdmin on the cb-infra Pulumi runner, which widens a far more powerful identity to narrow a read-only one. Co-Authored-By: Claude Opus 5.5 --- infra/pkg/dns/dns.go | 15 +++++++++++++++ vm/config/Caddyfile.tmpl | 3 ++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/infra/pkg/dns/dns.go b/infra/pkg/dns/dns.go index e3e0c67..bc7911f 100644 --- a/infra/pkg/dns/dns.go +++ b/infra/pkg/dns/dns.go @@ -8,6 +8,7 @@ package dns import ( "github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/compute" "github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/dns" + "github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/projects" "github.com/pulumi/pulumi/sdk/v3/go/pulumi" "gitea-infra/pkg/config" @@ -60,5 +61,19 @@ func New( return nil, err } + // The zone-scoped grant is not enough on its own: the googleclouddns plugin + // maps the domain to a zone by LISTING the project's managed zones, and a + // list is a project-level permission that no zone binding can confer. Without + // this, presenting the challenge fails with a bare 403. dns.reader adds + // read-only access and nothing else, and every write stays scoped to the zone + // above. + if _, err := projects.NewIAMMember(ctx, "gitea-vm-dns-reader", &projects.IAMMemberArgs{ + Project: pulumi.String(cfg.Project), + Role: pulumi.String("roles/dns.reader"), + Member: pulumi.Sprintf("serviceAccount:%s", vmServiceAccountEmail), + }, pulumi.DependsOn(deps)); err != nil { + return nil, err + } + return &DNS{Zone: zone, Record: rec}, nil } diff --git a/vm/config/Caddyfile.tmpl b/vm/config/Caddyfile.tmpl index 855218e..8b160fa 100644 --- a/vm/config/Caddyfile.tmpl +++ b/vm/config/Caddyfile.tmpl @@ -17,7 +17,8 @@ ${DOMAIN} { tls { dns googleclouddns { # Application Default Credentials come from the GCE metadata server. - # The VM service account holds roles/dns.admin scoped to this zone only. + # The VM service account holds roles/dns.admin scoped to this zone, plus + # project-level dns.reader so the plugin can list zones to find it. gcp_project {env.GCP_PROJECT} } # Only used for propagation checks. If issuance stalls waiting for