Skip to content

gapp setup should enforce public_access_prevention at project policy level #41

@krisrowe

Description

@krisrowe

Problem

gapp setup creates a per-solution GCS bucket
(gapp-<solution>-<project>) and enables uniform bucket-level
access at create time, but does not configure
public access prevention. The bucket is created with
public_access_prevention: inherited, which means a future IAM
binding to allUsers or allAuthenticatedUsers would succeed
silently (assuming no overriding org policy).

Today the buckets are not bound to public principals — gapp only
grants the per-solution service account storage.objectUser
but there is no defense-in-depth against accidental
misconfiguration. A typo'd gcloud command, a misapplied
terraform module, or a future gapp regression that adds a public
binding would not be blocked.

For solutions that persist sensitive material in the bucket
(OAuth refresh tokens, API credentials, customer data), this is
a real defense-in-depth gap. The bucket is the data plane; the
runtime SA is one factor — public-access prevention is a separate,
stronger factor that should be set up at the same time the bucket
is created.

Proposed scope

Have gapp setup apply public-access prevention. Two distinct
mechanisms apply, depending on the project's parent.
Standalone
GCP projects (no organization parent) cannot have org policies
set on them at all — the constraint exists as a placeholder but
its booleanPolicy stays empty, and gcloud resource-manager org-policies set-policy --project <P> fails
with permission denied regardless of the caller's roles. So
gapp must handle both shapes:

Project has an organization or folder parent

Apply constraints/storage.publicAccessPrevention at the
project level, enforced. This is strictly stronger than the
bucket-level flag because it:

  • Covers the bucket gapp creates today
  • Covers every other bucket on the project (Cloud Build staging,
    any hand-created buckets, future gapp-created buckets)
  • Blocks any future bucket from being created without PAP, even
    if a different tool or a manual gcloud command tries

Concretely:

gcloud resource-manager org-policies set-policy --project <PROJECT> - <<EOF
constraint: constraints/storage.publicAccessPrevention
booleanPolicy:
  enforced: true
EOF

If the project already has a stricter or differently-shaped org
policy (e.g., applied at the folder/org level), gapp should not
override it — it should detect the inherited policy and skip the
project-level set with a setup log message noting the
inheritance.

Project is standalone (no organization parent)

Project-level org policies are not available. Fall back to
per-bucket enforcement at bucket-create time via
gcloud storage buckets update gs://<bucket> --public-access-prevention. Apply to every gapp-managed bucket
the solution touches: the per-solution data bucket and any
project-level shared buckets gapp creates or uses.

The per-bucket fallback is weaker than project-level (does not
cover non-gapp buckets, future hand-created buckets, etc.) but
is what's available without an org. Document this clearly in the
deploy skill so operators know what protection they're getting.

Detection logic

Run before deciding which path:

gcloud projects describe <PROJECT> --format='value(parent)'

If parent is empty → standalone, use per-bucket fallback.
If parent is set → attempt project-level set; on failure, fall
back to per-bucket with a clear log message.

If roles/orgpolicy.policyAdmin is missing on a non-standalone
project, gapp should log a clear warning and fall back to
bucket-level enforcement.

Why project-level over bucket-level (when available)

Aspect Bucket-level Project-level (org policy)
Protects today's bucket
Protects future buckets on the same project
Protects buckets created outside gapp on the project
Available on standalone projects (no org parent)
Requires roles/orgpolicy.policyAdmin (or org-inherited)
Reversible by accident ✓ (anyone with storage.admin on the bucket) ✗ (requires org policy admin)

Project-level wins on every protection dimension when the
project has an org or folder parent
. Standalone projects fall
back to bucket-level by necessity, not preference.

Non-goals

  • Not changing bucket creation parameters beyond PAP.
  • Not adding versioning, logging, or CMEK at setup time. Those
    are separate decisions with separate tradeoffs (cost, log
    volume, key management) and belong in their own issues if
    requested. This issue is specifically about closing the
    accidental-exposure gap.
  • Not changing how gapp grants the per-solution SA bucket
    access. That part of the design is fine; PAP is orthogonal to
    IAM-binding correctness.

Work breakdown

  • Detect project parent shape via gcloud projects describe <P> --format='value(parent)'. Empty → standalone, use
    per-bucket. Set → org/folder, try project-level.
  • Detect whether constraints/storage.publicAccessPrevention
    is already enforced at project, folder, or org level.
    Skip the set if inherited from a stricter scope.
  • On org/folder-parented projects: apply the constraint at
    project level during gapp setup, idempotent on re-run.
    If roles/orgpolicy.policyAdmin is missing, log a clear
    warning and fall back to bucket-level.
  • On standalone projects: apply per-bucket
    --public-access-prevention=enforced at create time on
    every gapp-managed bucket. Log clearly that project-level
    isn't available on standalone projects.
  • Document the behavior (and the standalone-vs-org-parented
    distinction) in the deploy skill and CONTRIBUTING so
    operators know what protection level they're getting on
    their specific project shape.
  • Audit existing gapp-deployed projects in the wild — if any
    have inherited PAP without an enforced policy, surface that
    in gapp_status output so operators can decide whether to
    retro-apply.

Acceptance criteria

  • After gapp setup on a fresh project, gcloud resource-manager org-policies describe constraints/storage.publicAccessPrevention --project <P>
    returns an enforced policy (or the operator sees a clear
    message that a stricter inherited policy is already in place).
  • Attempting to bind allUsers or allAuthenticatedUsers to
    any bucket on the project fails with the expected
    PAP error.
  • gapp_status surfaces the PAP state for the project as part of
    its security/hygiene readout (out of scope for this issue if
    too large; can be a follow-up).

Notes

This issue is filed from a deploy-prep session for an mcp-app
solution that persists Google OAuth refresh tokens in the gapp
bucket. The session inspected three already-deployed solutions
on a shared project and found PAP inherited (not enforced) on
every one of them. Project IAM was clean (no public bindings
present), but the defense-in-depth was missing.

The session also attempted to apply project-level PAP manually
to the target deploy project and discovered that standalone
GCP projects (no organization parent) cannot have org policies
set on them at all
— the gcloud resource-manager org-policies set-policy --project <P> call fails with
permission denied no matter what roles the caller holds, and
roles/orgpolicy.policyAdmin itself is not assignable to a
standalone project resource. This is what motivated the
two-mechanism split in this issue. The manual workaround for
standalone projects is per-bucket
gcloud storage buckets update gs://<bucket> --public-access-prevention, which any project owner can run.

Applying PAP at setup time — at whichever scope is available —
would close this gap globally for every gapp consumer without
requiring downstream operators to remember.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions