Search

IAM and Resource Management to Check First After Adopting Google Cloud Organization — Preventing Project Sprawl and Personal-Account Dependencies

Tadashi Shigeoka · Mon, March 16, 2026

There is an IAM configuration you should verify the moment you finish provisioning a Google Cloud Organization linked to Google Workspace or Cloud Identity. By default, every user in the domain can create both Google Cloud projects and Billing Accounts. Left in that state for a few months, “My First Project” pile up directly under the Organization, external services authenticate as people who have long since left, and billing becomes something nobody can fully explain.

This post walks through what to check right after Organization creation, the risks of leaving the default in place, a safe procedure that narrows the roles without cutting the admin off, folder and naming conventions, precautions when moving existing projects, angles for auditing external-service integrations, and a checklist you can run down on day one. Placeholder values used throughout: Organization ID 123456789012, domain example.com, admin admin@example.com.

IAM to Verify Right After Organization Creation

When you create a new Google Cloud Organization, the default access control assigns the following two roles to the entire domain (domain:example.com):

  • roles/resourcemanager.projectCreator (Project Creator)
  • roles/billing.creator (Billing Account Creator)

Project Creator lets a principal create new projects. Billing Account Creator lets a principal create new Billing Accounts. Granted to the whole domain, this means every user in example.com can create Google Cloud projects and Billing Accounts freely from the console.

To confirm this with the gcloud CLI, fetch the Organization IAM policy and filter to the two roles:

gcloud organizations get-iam-policy 123456789012 \
  --flatten="bindings[].members" \
  --filter="bindings.role=roles/resourcemanager.projectCreator OR bindings.role=roles/billing.creator" \
  --format="table(bindings.role, bindings.members)"

If domain:example.com appears in the output, the default domain-wide grants are still in place. From the console, select the Organization, open “IAM & Admin” → “IAM,” and look for rows whose principal is the example.com domain (same conclusion, different surface).

The Risk of Domain-Wide Creator Roles

The default exists so the first admin does not get locked out on day one, which is a reasonable initial state. Whether it is a reasonable steady state is a different question. Left as-is, the risks stack up along several axes:

  • Security: any user can create projects under the Organization, so resources tend to outlive the offboarding checklists. Personal sandboxes with hard-coded credentials survive indefinitely
  • Cost: if someone creates a Billing Account with a personal credit card, no one on the admin side will notice that spending is now on personal invoicing. On the other side, resources tied to the org Billing Account keep ticking with no one able to explain the recurring $100/month lines
  • Operations: project naming and placement drift, so any later inventory becomes a set of incomparable one-offs. Dozens of “My First Project” accumulate in the domain
  • Offboarding: when a project is owned only by a personal account and that account is deleted, the IAM policy is left with a deleted-principal handle of the form deleted:user:.... If a BI tool connected via that person’s OAuth, service breaks the day they leave

To repeat: the default is a bootstrap convenience for the first admin, not a recommended production configuration. Once you have a stable admin path in place, narrowing the domain-wide grants early keeps this from turning into a governance problem later.

A Safe Procedure to Narrow the Roles

If you drop the domain-wide bindings first, you may find that the admin executing the change has just lost Project Creator and Billing Account Creator themselves. This is especially likely when the admin has been relying on the domain-wide grant during initial setup. Move the grants in an order that always leaves at least one working path:

  1. Grant roles/resourcemanager.projectCreator explicitly to the admin or an admin group
  2. Grant roles/billing.creator explicitly to the admin or an admin group
  3. Verify with testIamPermissions that the expected permissions are in effect
  4. Create a throwaway test project to confirm creation still works
  5. Verify that the new project landed under the Organization
  6. Delete the test project
  7. Remove roles/resourcemanager.projectCreator from the domain
  8. Remove roles/billing.creator from the domain
  9. Re-fetch the IAM policy and confirm only the intended principals hold the two roles

Corresponding gcloud examples follow. First, snapshot the current Organization IAM policy so you can roll back with set-iam-policy if anything goes wrong:

gcloud organizations get-iam-policy 123456789012 \
  --format=json > org-iam-before.json

Grant both roles to the admin:

gcloud organizations add-iam-policy-binding 123456789012 \
  --member="user:admin@example.com" \
  --role="roles/resourcemanager.projectCreator"
 
gcloud organizations add-iam-policy-binding 123456789012 \
  --member="user:admin@example.com" \
  --role="roles/billing.creator"

Confirm the effective permissions with testIamPermissions. There is no direct gcloud subcommand for testIamPermissions at the organization level, so the reliable path is to hit the REST API with an access token from gcloud auth print-access-token:

ACCESS_TOKEN=$(gcloud auth print-access-token)
curl -s -X POST \
  -H "Authorization: Bearer ${ACCESS_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{"permissions":["resourcemanager.projects.create","billing.accounts.create"]}' \
  "https://cloudresourcemanager.googleapis.com/v3/organizations/123456789012:testIamPermissions"

If both permissions come back in the response, the admin holds them through the direct grant. Next, create a throwaway project under the Organization:

gcloud projects create example-org-iam-test-01 \
  --name="Org IAM Test 01" \
  --organization=123456789012

Verify the parent resource is the Organization:

gcloud projects describe example-org-iam-test-01 \
  --format="value(parent.type, parent.id)"

Output like organization 123456789012 confirms creation landed under the Organization. Delete the test project once the smoke test passes. Deletion is soft for 30 days, and gcloud projects undelete can bring it back within that window:

gcloud projects delete example-org-iam-test-01

Only now remove the domain-wide grants:

gcloud organizations remove-iam-policy-binding 123456789012 \
  --member="domain:example.com" \
  --role="roles/resourcemanager.projectCreator"
 
gcloud organizations remove-iam-policy-binding 123456789012 \
  --member="domain:example.com" \
  --role="roles/billing.creator"

Finally, re-fetch the policy and confirm only the intended principals hold the two roles:

gcloud organizations get-iam-policy 123456789012 \
  --format=json > org-iam-after.json
 
diff org-iam-before.json org-iam-after.json

If anything goes sideways mid-procedure, gcloud organizations set-iam-policy 123456789012 org-iam-before.json restores the snapshot. Always keep at least one Organization Administrator (roles/resourcemanager.organizationAdmin) separately, on a different account, as a break-glass.

Grant to a Group, Not to a Person

The procedure above is fine as an emergency response, but leaving user:admin@example.com bound to sensitive roles long-term is not what you want. When that admin leaves or changes teams, someone has to hand-migrate IAM, and hand-migrations are where over-grants and orphaned roles are born.

For steady-state operation, the standard pattern is to create a security group in Cloud Identity or Google Workspace (built on top of Google Groups) and bind roles to it. For example, create an admin group:

  • google-cloud-admins@example.com

Grant the group is identical to granting a user, just swap user: for group::

gcloud organizations add-iam-policy-binding 123456789012 \
  --member="group:google-cloud-admins@example.com" \
  --role="roles/resourcemanager.projectCreator"
 
gcloud organizations add-iam-policy-binding 123456789012 \
  --member="group:google-cloud-admins@example.com" \
  --role="roles/billing.creator"

Confirm through testIamPermissions that the admin, logged in as themselves, now has both permissions via group membership. Once confirmed, remove the direct grants on the individual account:

gcloud organizations remove-iam-policy-binding 123456789012 \
  --member="user:admin@example.com" \
  --role="roles/resourcemanager.projectCreator"
 
gcloud organizations remove-iam-policy-binding 123456789012 \
  --member="user:admin@example.com" \
  --role="roles/billing.creator"

Google Cloud IAM by itself does not offer a native way to group human users. Groups are managed in Cloud Identity or Google Workspace, and Google Cloud references them by email as group: principals. Cleaning up Google Cloud governance therefore doubles as a cleanup pass on your Cloud Identity or Google Workspace group inventory.

Set Up Folders and a Naming Convention

Once Project Creator is narrowed, the next thing to shape is where new projects live and how they are named. If you keep piling projects directly under the Organization, IAM inheritance and Organization Policy inheritance only apply at the Organization granularity, and you lose the ability to segment cleanly later.

The baseline is to carve out Resource Manager folders per service or per team, and place projects under those folders. The conceptual model of splitting by environment comes from Google Cloud’s Enterprise Foundations Blueprint, and the concrete folder and Project ID naming conventions come from the Terraform Example Foundation implementation. A two-tier layout with environments at the top and services underneath looks like this:

Organization (example.com)
├── folders/production
│   └── folders/example-service
│       ├── example-service-data-prod
│       └── example-service-app-prod
├── folders/nonproduction
│   └── folders/example-service
│       └── example-service-app-nonprod
└── folders/development
    └── folders/example-service
        └── example-service-app-dev

Recommended naming elements. Folder names spell environments out in full (production, nonproduction, development), matching the Terraform Example Foundation convention:

  • Service name (e.g. example-service)
  • Purpose (e.g. data, app, log)
  • Environment (folders use full spelling: production, nonproduction, development)
  • Region when relevant (e.g. a short form like an1 for asia-northeast1)

Project IDs are capped at 6 to 30 characters, so environment names get shortened on the Project ID side. Terraform Example Foundation uses single-letter prefixes p, n, d (e.g. prj-p-bu1-sample-floating). If readability matters more than compactness, prod, nonprod, and dev are common alternatives. example-service-data-nonprod fits at 28 characters, well under the 30-character cap. Either way, the split of “full spelling for folders, short forms for Project IDs” mirrors the Terraform Example Foundation convention.

For display names like “My First Project” that the console assigns automatically, update the display name to something that describes the purpose. With gcloud:

gcloud projects update example-service-app-prod \
  --name="Example Service / App / production"

The important caveat: only the display name (name) is mutable. The Project ID (the example-service-app-prod part) is fixed the moment the project is created and can never change afterwards. Because Project IDs end up embedded in logs, audit records, and external integrations, the naming convention must be pinned down at Project ID granularity first, not at display-name granularity.

Cautions When Moving Existing Projects

To relocate a project that already exists under the Organization or another folder into its correct folder, use project migration. The destination folder’s IAM and Organization Policy are inherited, so the move alone can change effective permissions and applicable constraints.

Before moving, confirm:

  • resourcemanager.projects.move at both source and destination (the executing user needs it on each side)
  • resourcemanager.projects.update on the project itself
  • Any Liens on the project (they block deletion, which can affect operational planning around the move)
  • The destination folder’s IAM (how effective permissions shift after inheritance)
  • The destination folder’s Organization Policy (are permitted locations or APIs about to change)
  • Service account IAM used by external integrations (service accounts themselves do not move, but the permissions they resolve to may effectively shift)
  • Billing Account association (moves do not break it, but revisit if the destination folder carries billing rules)
  • Direct project-level IAM that must be preserved (does it get overridden or lost by inheritance)

Example commands to snapshot parent, billing, and IAM around the move. Target project example-service-app-prod, destination folder folders/123456789012345:

gcloud projects describe example-service-app-prod \
  --format="value(parent.type, parent.id)" > parent-before.txt
 
gcloud billing projects describe example-service-app-prod \
  --format="value(billingAccountName)" > billing-before.txt
 
gcloud projects get-iam-policy example-service-app-prod \
  --format=json > iam-before.json
 
gcloud beta projects move example-service-app-prod \
  --folder=123456789012345
 
gcloud projects describe example-service-app-prod \
  --format="value(parent.type, parent.id)" > parent-after.txt
 
gcloud billing projects describe example-service-app-prod \
  --format="value(billingAccountName)" > billing-after.txt
 
gcloud projects get-iam-policy example-service-app-prod \
  --format=json > iam-after.json
 
diff parent-before.txt parent-after.txt
diff billing-before.txt billing-after.txt
diff iam-before.json iam-after.json

Inherited IAM does not appear in get-iam-policy output. To see it, use effective-permission tooling such as Policy Analyzer or Cloud Asset Inventory. For mission-critical projects, simulate the post-move effective permissions with Policy Analyzer before pulling the trigger.

Angles for Auditing External-Service Integrations

Whenever you clean up an Organization, one question always comes up: who exactly is this BI tool authenticating as when it hits BigQuery? Trusting only the Google Cloud console can mislead you about the real connecting principal.

When a BI tool or analytics service connects to BigQuery, the user shown on-screen is not necessarily the project Owner. It can be the individual who linked the account via OAuth. Break the audit into distinct pieces:

  • The project’s owning and administrating principals (holders of roles/owner and roles/editor)
  • Dataset-level access control on BigQuery datasets
  • The user actually running queries (the user_email field in BigQuery INFORMATION_SCHEMA.JOBS)
  • The OAuth user recorded on the external service’s own admin surface
  • Service accounts used for data movement (BigQuery Data Transfer Service, Dataflow, etc.)
  • Billing Account association
  • Cloud Audit Logs covering Admin Activity and Data Access
  • deleted:user: principals from deleted accounts

Searching IAM only by a former employee’s email as an exact match will miss handles that took the form deleted:user:takashi@example.com?uid=1234567890 after the account was deleted. Pull the full IAM policy as JSON, or run a query that also matches the deleted:user: prefix.

gcloud asset search-all-iam-policies \
  --scope="organizations/123456789012" \
  --query="policy:deleted" \
  --format="table(resource, policy.bindings.members)"

This uses Cloud Asset Inventory IAM policy search to sweep across every resource under the Organization. You need read permission such as roles/cloudasset.viewer to run it.

To find out who is actually running BigQuery queries, aggregate INFORMATION_SCHEMA.JOBS:

SELECT
  user_email,
  COUNT(*) AS job_count,
  SUM(total_bytes_processed) / POW(10, 12) AS tb_processed
FROM
  `region-us`.INFORMATION_SCHEMA.JOBS
WHERE
  creation_time > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
GROUP BY
  user_email
ORDER BY
  tb_processed DESC;

If user_email is dominated by individual accounts (ex-employee@example.com-style), your analytics pipeline is riding on personal OAuth. Plan the migration to a service account before the connection breaks on the person’s last day.

The Day-One Checklist

Finally, a checklist you can work through immediately after standing up the Organization. It maps directly onto the sections above.

  • Confirmed the roles/resourcemanager.projectCreator binding to domain:example.com at the Organization IAM level
  • Confirmed the roles/billing.creator binding to domain:example.com at the Organization IAM level
  • Narrowed Project Creator to the admin or an admin group
  • Narrowed Billing Account Creator to the admin or an admin group
  • Created an admin group such as google-cloud-admins@example.com in Cloud Identity or Google Workspace and bound the roles to it
  • Defined the project-creation request flow (who requests, who approves, who executes)
  • Defined a per-service or per-team folder structure
  • Defined a Project ID naming convention (service, purpose, environment, region)
  • Inventoried existing projects sitting directly under the Organization
  • Flagged projects with opaque display names such as “My First Project”
  • Verified the Billing Account association for each project
  • Searched across the Organization for lingering deleted:user: principals in IAM
  • Measured OAuth dependencies of BI or analytics tools with INFORMATION_SCHEMA.JOBS and similar
  • Assigned read access on Cloud Audit Logs and Cloud Asset Inventory to a responsible owner
  • Saved before/after Organization IAM policies as JSON (org-iam-before.json and org-iam-after.json)
  • Verified with a throwaway test project that Project Creator restriction behaves as intended

That’s all from tightening the day-one IAM and resource management on a fresh Google Cloud Organization, complete with gcloud commands, ordering, and a checklist to hand to the next admin, from the Gemba.

References