How-to: onboard a new Team (a playbook)
A playbook for putting a new Team on the platform. A Team is the top-level governance object — it carries a team’s identity (the SSO group it maps to) and its envelope (the bound on everything the team’s Products and Environments may ever ask for). By the end, one small file in git has become a GitHub org team, a Keycloak group, and an admission-enforced boundary the whole platform reads.
This is the procedure. For why a Team is an envelope and how admission checks it, read the domain model first — that walks the concept and the four-request admission story, and this doc doesn’t repeat them. For how the SSO group becomes sign-in and roles, see identity. Here you just get it built.
Working with an AI agent? Authoring a Team file is a good agent task — the risk is the envelope, not the YAML. See Doing this with an agent for a prompt, guardrails, and a review checklist. Come back here to review its PR.
Before you start
Section titled “Before you start”- This is admin-only work. A Team grants an envelope, so onboarding one is a platform-admin decision, not team self-service (developers author Products and Environments inside an existing Team, not the Team itself). Both paths below are gated so only an admin can land one.
- A name.
metadata.nameis a bare lowercase slug,^[a-z][a-z0-9]{1,15}$— letters and digits, no hyphens. The team name is parsed back out of<team>-<product>-<stage>namespace names, so a hyphen would make that split ambiguous.charlie, notc-h-a-r-l-i-e. (The Backstage form enforces exactly this pattern; the Teams Gate’s own regex is looser — it permits hyphens and longer names — so on a hand-authored Path B PR the reviewer, not the gate, is what catches a stray hyphen.) - An SSO group. The upstream group this Team maps to (
spec.ssoGroup). The convention isDev-<name>(soDev-charlie); a tenant Team may not map to a privileged/platform group. The Teams Gate rejects the configured denied set (today justplatform-admins, matched case-insensitively) — notePlatformitself is not in that set (the platform-owned Team uses it), so CODEOWNERS review, not the gate, is the backstop for every other privileged group. - An envelope. The tiers, stages, quota ceiling, budget, and self-service limits every Environment under this Team is bounded by. Copy an existing Team and adjust; the fields are in the table below.
What you’re actually doing
Section titled “What you’re actually doing”You’re making a GitOps change: git is the source of truth, and the platform
reconciles reality to match it. The Team registry (gitops/teams/**) is the single list of every team,
and adding one file onboards a team. Several platform units and a Kyverno policy derive from that file —
you provision none of it by hand. Your whole job is to write one good file and open a PR.
Path A — the Backstage scaffolder
Section titled “Path A — the Backstage scaffolder”The paved path is the New Team software template:
- In Backstage, Create → New Team.
- Fill the form: name, ssoGroup (optional; defaults to
Dev-<name>), and the envelope fields (allowedTiers, stages,cpu,memory,pods). - The template runs a server-side admin check (
requireAdmin) — since team members can create scaffolder tasks for self-service templates, this one restricts running it to platform admins. It then writesgitops/teams/<name>.yamland opens a PR (team/onboard-<name>) againstmain. It opens a PR — it never automerges. A Team grants an envelope, so it always gets human review. - Review and merge that PR → What happens on merge.
⚠️ The scaffolder skeleton is currently stale against the live Team schema. It emits an older shape —
apiVersion: …/v1alpha2,envelope.allowedEnvironments,maxDedicatedZones— while live Team CRs use…/v1beta1,envelope.allowedStages, andmaxDedicatedIsolation, and never emitslack,budget, orresources. Until the template is refreshed, treat its output as a starting point you must reconcile against a real Team file before merging — or just use Path B, which is the canonical shape. This is a known drift, flagged in Gotchas.
Path B — a direct registry PR (the canonical shape)
Section titled “Path B — a direct registry PR (the canonical shape)”Identical outcome, and the shape you can trust: hand-author the Team file and open a PR. Copy an existing Team — an existing tenant Team is the model — and adjust.
Step 1 — write the registry file
Section titled “Step 1 — write the registry file”Add gitops/teams/<name>.yaml. Here’s a complete, annotated example for a new team charlie:
apiVersion: platform.refplat.org/v1beta1kind: Teammetadata: name: charlie # bare slug, no hyphens; ^[a-z][a-z0-9]{1,15}$ labels: app.kubernetes.io/managed-by: argocd app.kubernetes.io/part-of: environment-apispec: ssoGroup: Dev-charlie # the Keycloak/IdP group this team maps to (identity) slack: channel: "C0XXXXXXXXX" # incident channel id — the triage agent posts here envelope: # the bound on every Product/Environment this team may author allowedTiers: ["standard"] # compliance tiers an Environment may request allowedStages: ["dev", "test", "staging", "prod"] # stages it may deploy to allowedLocations: ["*"] # data-residency regions ("*" = any) quotaCap: # per-Environment ResourceQuota ceiling cpu: "8" memory: 16Gi pods: 40 budget: monthlyUSD: 2000 # cost guardrail (surfaced/alerted, not hard-enforced) maxDedicatedIsolation: { cluster: 0, account: 0 } # dedicated-isolation rungs allowed (0 = pooled only) resources: # self-service cloud resources (default-deny) allowedEngines: ["s3", "sqs"] # engines the team may declare; [] = none maxPerEnvironment: 10 # cap on self-service resources per Environment isolationFloor: shared # lowest data-isolation rung requestablespec.ssoGroup and spec.envelope are the only required members of spec; within the envelope,
allowedTiers, allowedStages, and quotaCap are required. Everything else has a safe default (the
resources block defaults to deny-all — no engines, maxPerEnvironment: 0 — so a team that never
opts in gets no self-service resources).
The envelope, field by field
Section titled “The envelope, field by field”Each envelope field is a ceiling on what an XEnvironment claim under this Team may ask for. This is the
bound; the domain model is the concept.
| Field | What it bounds downstream | Notes |
|---|---|---|
allowedTiers |
Environment’s spec.tier must be in this set |
enum standard|elevated|pci|hipaa; ≥1 |
allowedStages |
Environment’s spec.stage must be in this set |
enum dev|test|uat|staging|prod; ≥1 |
allowedLocations |
Environment’s residency must be a subset of this | default ["*"] (any region) |
quotaCap.{cpu,memory,pods} |
Each Environment’s spec.quota dimension ≤ this |
an absent dimension is effectively unlimited |
budget.monthlyUSD |
Cost guardrail — surfaced and alerted on | not hard-enforced by the provisioner; absent ⇒ unbounded |
maxDedicatedIsolation.{cluster,account} |
How many Environments may dial the expensive isolation rungs | 0 = pooled namespace/nodes only |
maxCrossTeamGrantsPerProduct |
Cap on active inbound cross-team access grants per Product | default 10 |
resources.allowedEngines |
Self-service engines a Service may declare | enum s3|sqs|sns|dynamodb; default [] (deny) |
resources.maxPerEnvironment |
Total self-service resources per Environment | default 0 |
resources.isolationFloor |
Lowest data-isolation rung requestable | shared|dedicated; default shared |
The non-envelope fields are identity and routing, not bounds: spec.slack.channel is the incident channel
the triage agent posts to, and spec.pagerduty / spec.oncall point at on-call. A platform-owned Team
may also carry spec.platformTrust (cluster-read ClusterRoles + IAM actions past the deny-set) — a
capability tenants can’t request. It’s defined in the Team CRD but unused by any live Team: platform
agents get their cluster-read/model access directly from the XAgent runtime, not from a Team’s
platformTrust. Leave it unset for a tenant Team.
The Team CRD does not bound
quotaCap,maxDedicatedIsolation, orssoGroup— nothing in the schema stops you writingcpu: "9999". The Teams Gate (below) is what enforces platform maximums and the privileged-group denial. That’s deliberate: the envelope is the ceiling for tenants, and the gate is the ceiling for the envelope.
Step 2 — open the PR
Section titled “Step 2 — open the PR”Open a pull request with just that one file. The Teams Gate (the required “Teams Approval” check)
validates it before merge: required fields present, enums valid, ssoGroup not a denied platform group,
and each cap under the platform maximum (quotaCap, maxDedicatedIsolation, maxPerEnvironment). It also
runs a deletion guard — you can’t remove a Team while any Environment still references it. Fix
anything it flags; it runs before merge, so mistakes never reach the cluster.
What happens on merge
Section titled “What happens on merge”You don’t run terragrunt apply. Merging to main under gitops/teams/** fires teams-apply.yml, which
converges the derived units on the in-VPC runner, in order: keycloak-config → argocd → argocd-apps →
github-teams. Out of that one file come:
- A Keycloak group — one group per Team (
spec.ssoGroup, e.g.Dev-charlie), the team’s SSO identity that apps read from thegroupsclaim. - A GitHub org team —
github-teamscreates the org team (named for the slug) and later grants itpushon each of the team’s<team>-<product>repos. This is human ownership only — org-team membership is not in the Actions OIDC token, so it never carries supply-chain identity (that anchors on repo name). - A projected cluster
TeamCR — ArgoCD’steamsApplication syncs the file intocrossplane-system(at an early sync-wave, so Teams land before the environments that reference them). This is the copy Kyverno reads atXEnvironmentadmission to enforce the envelope.
The precise boundary worth holding: a Team materializes identity, ownership, and the governance
envelope — a Keycloak group, a GitHub org team, and the admission-enforced bound. The per-Product
machinery (ECR repos, the CI push role, the cosign verify policies, per-Service IAM) is derived from the
Product registry, keyed on <team>-<product> — the <team> there is a coordinate, not a per-Team
fan-out. Onboarding a Team builds none of that; onboarding a Product does.
Verify it worked
Section titled “Verify it worked”- The file is on
mainandteams-apply.ymlsucceeded (check the Actions run). - The projected Team CR exists:
kubectl --context preprod get teamslists your team with its SSO group and stages. - The Keycloak group exists: the
Dev-<name>group is present in the realm. - The GitHub org team exists: a team named for your slug appears in the org (it gains repo
pushgrants as the team’s Products are onboarded). - Admission accepts a claim: an
XEnvironmentclaim under the new Team, within its envelope, passesrestrict-environment-envelopeat the gitops gate.
Doing this with an AI agent
Section titled “Doing this with an AI agent”Authoring a Team file is mechanical and well-specified — a good agent task. The risk isn’t the YAML; it’s
the envelope. An over-wide envelope (a tenant granted pci, or a quotaCap above the platform max) is a
governance hole, and a privileged ssoGroup is a privilege-escalation. Those are exactly what the agent
must not get wrong — and what a human must check.
Context to attach: this playbook, gitops/teams/ (an existing tenant Team as the example), and the
envelope table.
A starting prompt:
Onboard a new Team
charliemapping to SSO groupDev-charlie, followingdocs/learn/teams/how-to-onboard-a-team.md. Creategitops/teams/charlie.yamlin the v1beta1 shape, copying an existing tenant Team’s conventions. Envelope:allowedTiers: ["standard"], stages["dev","test","staging","prod"],quotaCapcpu"8"/ memory16Gi/ pods40,budget.monthlyUSD: 2000,maxDedicatedIsolation: { cluster: 0, account: 0 },resources.allowedEngines: ["s3"],maxPerEnvironment: 10. Open a PR with only that one file. Do not touch any derived infra (Keycloak, GitHub teams, ArgoCD, IAM) — those reconcile automatically on merge.
Guardrails to give it:
- Never widen the envelope beyond what I specified. No tier, stage, engine, or quota I didn’t ask for. When unsure, ask — don’t guess generously.
ssoGroupis the given value, neverplatform/platform-adminsor any privileged group. A tenant Team mapping to a platform group is a privilege escalation. The gate rejects only its configured denied set (todayplatform-admins) — don’t rely on it to catch every privileged group; that’s what review is for.metadata.nameis a bare slug matching^[a-z][a-z0-9]{1,15}$— no hyphens.- Use the v1beta1 shape (
allowedStages,maxDedicatedIsolation) — not the scaffolder’s stalev1alpha2/allowedEnvironments/maxDedicatedZones. - One file, no derived infra. The agent authors only the registry entry; it must not hand-write the Keycloak group, GitHub team, or any IAM. That defeats the single-source-of-truth model and will drift.
Review checklist:
-
metadata.nameis a bare slug (no hyphens),apiVersionis…/v1beta1. -
ssoGroupis the intended group and is not privileged/platform. - Every envelope value matches what you authorized — tiers, stages, quota, engines — nothing wider.
- Each cap is under the platform maximum (the Teams Gate will confirm, but check before you ask).
- PR touches only
gitops/teams/<name>.yaml— no derived infra edited. - The Teams Gate (“Teams Approval”) passes.
Gotchas
Section titled “Gotchas”- Admin-gated, always human-reviewed. There is no automerge arm for Teams — an envelope grant gets a human, by design. Expect to (or need an admin to) approve the head SHA.
- The scaffolder skeleton is stale. Path A emits
v1alpha2/allowedEnvironments/maxDedicatedZones; live Teams arev1beta1/allowedStages/maxDedicatedIsolationand carryslack/budget/resourcesthe skeleton never writes. Prefer Path B, or reconcile the scaffolder’s output against a real Team before merging. allowedStagesvsallowedEnvironments— a live wiring gap. The keycloak-config module still derives its stage-based developer-access roles from the oldenvelope.allowedEnvironmentskey (with atry(…, [])fallback). Since live Teams name that fieldallowedStages, that derivation silently degrades to empty — the team gets its Keycloak group, but not the stage-derived roles. Treat the group as the reliable output today; flag this if you need the roles.- Per-team kubectl access is designed, not built. The Keycloak group→role mapping exists, but the
per-team
DeveloperAccess-<team>IAM role + EKS access entry that would turn it into namespace-scoped kubectl is not provisioned — the Composition emits only the in-cluster RoleBinding. Useplatctl kubeconfig/ PlatformAdmin until it’s built. See identity. - You can’t delete a Team that’s still in use. The Teams Gate’s deletion guard rejects removing a Team while any Environment references it. Decommission the Environments first.
- The
resourcesblock is deny-by-default. Omit it and the team can request no self-service cloud resources. Opt in explicitly (allowedEngines,maxPerEnvironment) if the team needs S3/SQS/etc.
Go deeper
Section titled “Go deeper”- The concept: the domain model — Team as envelope · identity and the SSO group: identity.
- Onboarding what lives under a Team: onboard a Product.
- Substrate: GitOps principles · Backstage software templates.