Sitemap
Ayoub Reda

Ayoub Reda

Engineering & AI Leader driving AI rollouts, digital transformation, and cloud platforms that serve millions.

Rebuilding Access Management with Crossplane and Backstage

14 min readOct 1, 2026

--

350+ people, 15 tools, squads that never sit still. How we turned access from a service DevX performed into a self-service platform with guardrails

Press enter or click to view image in full size

At Yassir, we managed access to our engineering tools as code, in a Terraform repository. One pull request in that repo shows why we had to rebuild it. It was three lines long and added an engineer to a GitHub team in another domain. CI was green, the Terraform plan was clean, and someone on DevX approved it.

Nobody on DevX knew whether that engineer belonged in that team. The squad lead who did know was never asked. Two weeks later another PR went further: someone created a GitHub team, added most of engineering to it, and gave it write access to a long list of repositories. It was a shortcut, so people would stop waiting for access. It also meant that anyone in the team could push to repositories they had never worked on.

Neither change was malicious. People were blocked and wanted to get their work done. The process just made the shortcut easier than doing it properly.

I manage several squads at Yassir. One of them is DevX, the team that builds our internal developer platform. This post covers how we replaced that access process: what went wrong with Terraform, the model we switched to, and how we gave 350+ people self-service access without giving up control over who gets what.

The short version: we stopped treating access as a service DevX performed, and started treating it as a product DevX owns. People use it on their own, through a pull request or a form. The rules are built into the platform, so DevX doesn’t have to check them by hand. Squad leads make the decisions, because they have the context. It’s platform engineering applied to a problem most companies still handle with tickets.

The problem: the org chart isn’t the access chart

Yassir is a super app: ride-hailing, food and grocery delivery, and financial services, across several countries. More than 350 people build it, in domains (Mobility, Food, Fintech, Platform) split into squads.

Our HR reporting structure doesn’t tell you who needs access to what. An engineer reports to one manager, works in a squad in another domain, helps on a cross-domain project for a quarter, and belongs to the mobile chapter. Engineers move between squads. Projects start and end. New people join every week, and some people leave.

Each of those changes touches a long list of tools: Google Workspace, GCP, GitHub, Jira, Confluence, Slack, Postman, Datadog, Sentry, PagerDuty, LaunchDarkly, MongoDB Atlas, Miro, Notion and Bitrise. Every request went to the same place: DevX.

“Can you add Sara to the checkout repo, the Jira board, the dev GCP project and the squad channels? She starts Monday.”

DevX became the access desk, and access requests left less and less time for building the platform.

Attempt one: Terraform

Access-as-code was the obvious move, and it was a real improvement on DMs. Teams, memberships and permissions lived in Terraform, and a request became a pull request.

It failed in two ways.

Approval went to the wrong people. The Terraform repo belonged to DevX, so DevX reviewed every PR. DevX could check whether a change was syntactically valid. It couldn’t check whether it was the right change: does this person belong in this squad? Should this squad have write access to that repo? So reviews became rubber stamps. If you could open a PR, you could add yourself to any team you liked, and the “everyone” team got through the same way. The review step was there, but it didn’t check anything.

It didn’t scale. One state file means one lock. Access changes are small, frequent and unrelated to each other: Sara joins Checkout, Omar leaves Food, a new squad needs a repo. In Terraform they all queued behind the same apply. One PR’s plan went stale as soon as another one merged. We had replaced a ticket queue with a lock queue.

Press enter or click to view image in full size

Under both of those was a modelling problem. Terraform pushed us to mirror each tool’s API: a GitHub team here, an Atlassian group there, a list of IAM bindings somewhere else. The same squad had a separate member list in every tool, and they drifted apart.

So we had three things to fix:

  1. Governance: the person who approves a change must be the person who has the context to judge it.
  2. Throughput: independent changes must not wait for each other.
  3. The model: describe the organisation once, not once per tool.

The model: people and teams, nothing else

We started from how people already describe the org: “Jane leads Food Checkout. John is in it.” The platform has two concepts, and each one is a file in Git.

A Member is a person:

# org/members/jane.doe.yaml
apiVersion: access.yassir.com/v1alpha1
kind: Member
metadata: { name: jane.doe, namespace: access }
spec:
email: jane.doe@yassir.com # the Google SSO identity, everywhere
displayName: Jane Doe
github: janedoe # migration only: the personal account, until GitHub moved to SSO

A Team is a group of people and the access they share:

# org/teams/food/food-checkout.yaml
apiVersion: access.yassir.com/v1alpha1
kind: Team
metadata: { name: food-checkout, namespace: access }
spec:
displayName: Food Checkout
domain: food
leads: [jane.doe]
members: [john.smith, ines.bouzid]
access:
gcp:
projects:
- id: ysr-food-checkout-dev
roles: [roles/editor]
github:
repositories:
- name: food-checkout-web
permission: push
jira:
projects:
- key: FCHK
roles: [Contributor]
slack:
channels:
- id: C04SQUADFCHK
name: "#squad-food-checkout"
sentry:
projects: [food-checkout-api]
pagerduty:
escalationPolicies: [PFCHK01]

Two rules hold the model together.

Access belongs to teams, never to people. Nobody has a personal grant anywhere. Your access is the sum of your teams’ access. To get access to something, you join the team that has it, or that team’s leads agree to share it.

Each team gets one identity per tool, and grants point at that identity. The squad becomes a Google group food-checkout@yassir.com, a GitHub team, an Atlassian group, a Datadog team and so on. GCP IAM is bound to group:food-checkout@yassir.com, never to a person. When someone joins or leaves the squad, only their group membership changes. The IAM policy stays the same.

Press enter or click to view image in full size

Authentication was already solved: everyone signs in with Google SSO everywhere, and SSO/SCIM creates the account in each tool. So this system never creates accounts. It only decides which groups those accounts belong to.

GitHub was the exception at first. Engineers joined the organization with their personal GitHub accounts, which have nothing to do with their Google identity. So the Member file carried a github handle, the one identity we couldn't derive from the email. That was a migration step, not the target. Once GitHub moved behind SSO, personal accounts were no longer allowed: GitHub accounts are provisioned from Google like every other tool's, and the handle no longer has to be maintained by hand. It also closed a real gap. A personal account doesn't care that its owner's Google account was suspended, so before the migration, removal from the GitHub team was the only thing that cut a leaver's access to the code.

Squads, domains and the legitimate “everyone” team

The “add everyone to one team” PR wasn’t wrong about the need. Some access really should go to every engineer: the engineering Slack channels, the handbook, shared CI workflows. The problem was the shortcut: write access, owned by nobody, granted to whoever happened to be added.

So a Team has a type, and teams can include other teams:

org/teams/
common/
all-engineering.yaml type: common includeTeams: [mobility, food, fintech, platform]
chapter-mobile.yaml type: common people listed directly (a skill, not a position)
food/
food.yaml type: domain includeTeams: [food-checkout, ...]
food-checkout.yaml type: squad people + squad-specific access
  • A squad lists its people and its own access.
  • A domain includes its squads. Everyone in Food gets #food-engineering, the Food Confluence space and read access to food-shared-libs, without being listed twice.
  • A common team cuts across the org. all-engineering includes every domain and gets the basics, mostly read-only, and DevX owns it. Chapters and guilds list their people directly, because they follow skills, not reporting lines.
Press enter or click to view image in full size

A team that includes others reads their resolved members. When John leaves Food Checkout, he also leaves the Food domain and all-engineering, with no extra file to edit.

With this model, a change in the org is a small diff. Moving John from Food Checkout to DevX looks like this:

# org/teams/food/food-checkout.yaml
- members: [john.smith, ines.bouzid]
+ members: [ines.bouzid]
 # org/teams/platform/pi-devx.yaml
- members: [udokama.chinenye]
+ members: [udokama.chinenye, john.smith]

He loses Checkout’s repos, Jira project, GCP project and channels on every tool, and gets DevX’s, from one PR.

Governance: the reviewer is whoever has the context

This was the decision that mattered most, and it isn’t a technical one. DevX owns the platform. Squads own their squads.

CODEOWNERS is not written by hand. A script generates it from the team files, so ownership follows the data:

# GENERATED by scripts/gen-codeowners.sh — do not edit by hand.
* @yassir/pi-devx
/org/members/ @yassir/pi-devx
/org/teams/ @yassir/pi-devx
# Domains: domain leads approve new squads and domain-wide access
/org/teams/food/ @aminab
# Squads: squad leads approve their own squad
/org/teams/food/food-checkout.yaml @janedoe
  • A squad’s members or access are approved by that squad’s leads.
  • A new squad, or domain-wide access, is approved by the domain’s leads.
  • People records, common teams and the platform itself are approved by DevX.
Press enter or click to view image in full size

Branch protection requires a code owner’s approval. A self-add PR to Food Checkout now waits for Jane, who knows whether you belong there. Adding yourself to a squad in another domain needs that squad’s lead. Creating a team means a new file under a domain folder, so the domain’s leads have to approve it. DevX is no longer in the path of everyday changes, and DevX approval no longer counts for decisions DevX can’t judge.

Good reviewers still need to see what a change really does. A one-line change to a domain file can affect forty people. So CI posts an access diff on every PR. It renders the whole org before and after the change, and lists the people and grants that will be added or removed, per team and per tool. That includes teams affected indirectly:

### food
+ slack member john.smith (member)
+ github member john.smith (member)
+ jira member john.smith (member)
### food-checkout
+ github member john.smith (member)
+ github grant repositories {"name":"food-checkout-web","permission":"push"}

The reviewer approves what will happen on each tool, not just the YAML change.

Get Ayoub Reda’s stories in your inbox

Join Medium for free to get updates from this writer.

Some changes shouldn’t depend on a reviewer at all, so CI rejects them before anyone looks. The validation step fails a PR on:

  • a person or included team that doesn’t exist,
  • a file in the wrong domain folder,
  • privileged grants outside the platform domain: GCP roles/owner, GitHub admin, Jira Administrators, Postman Admin, Atlas GROUP_OWNER, Bitrise admin. No squad lead can approve those for their own squad,
  • resource names that break the naming convention,
  • generated files (CODEOWNERS, the Backstage catalog) that are out of sync with the data.

So the “everyone gets write on everything” PR now fails CI if it asks for admin rights. If it only asks for write, it needs approval from DevX and from the lead of every squad it touches.

Throughput: Crossplane instead of a state file

Governance decides who approves a change. Something still has to apply it, and Terraform’s lock was the bottleneck there.

Crossplane lets you define your own Kubernetes APIs (here, Member and Team) and back each one with a composition: a pipeline that turns one object into many resources on external systems. Controllers then reconcile those resources continuously. Argo CD syncs the org/ folder into the cluster, so merging a PR is all it takes to apply it.

Press enter or click to view image in full size

This removes the bottleneck for structural reasons:

  • No shared state. Every team is its own object, reconciled on its own. Two PRs for two squads never wait for each other, and there’s no plan to go stale.
  • One resource per membership and per grant. Removing John deletes exactly one object per tool. Adding him can’t affect the other forty people in the domain.
  • It converges instead of applying once. If someone adds a person by hand in an admin console, the next reconcile removes them again. The admin console stops being a way around the process.

One module per tool

Our first version was one composition that rendered every tool. By the sixth tool it was 600 lines, and a mistake in the Slack section stopped GitHub from reconciling too. So each tool became its own module:

Press enter or click to view image in full size

The Team works out who is in it, once. Each module receives already-resolved people (their role and their ID on that tool) and only knows its own API. It fails on its own, has its own tests and can have its own owner. Adding a tool means adding a module.

GCP, GitHub, PagerDuty and MongoDB Atlas use Crossplane providers. The others have no provider that covers what we need, so we call their REST APIs through provider-http. For each call, what matters is how it decides whether the thing already exists. Here is a Slack channel membership:

mappings:
- action: CREATE
url: (.payload.baseUrl + "/conversations.invite")
body: "{ channel: .payload.body.channel, users: .payload.body.user }"
- action: OBSERVE
url: (.payload.baseUrl + "/users.conversations?types=public_channel,private_channel&limit=999&user=" + .payload.body.user)
- action: REMOVE
url: (.payload.baseUrl + "/conversations.kick")
body: "{ channel: .payload.body.channel, user: .payload.body.user }"
isRemovedCheck:
type: CUSTOM
logic: .payload.body.channel as $c | any(.response.body.channels[]?; .id == $c) | not

The same check detects drift (they left the channel, so invite them again) and confirms removal (they left the squad, and the kick actually worked). Tokens never appear in these resources. Headers reference a Kubernetes Secret, which External Secrets fills from GCP Secret Manager.

Finding each person’s ID on each tool is configuration, not code:

pagerduty:
lookup:
url: https://api.pagerduty.com/users?query={email}&limit=100
list: users
match: email
id: id

A lookup only accepts an exact email match. Granting access to the wrong account is worse than waiting.

Leavers: Google is the kill switch

We didn’t build an offboarding process. We reused the one IT already had.

IT suspends the account in Google Workspace, and SSO blocks login to every tool at once. That’s the security boundary, and it doesn’t wait for a PR.

SSO doesn’t clean up, though. The person is still in the Google group, the GitHub team, the Jira group, the Slack channels and the Postman workspace. At best that’s noise in audits. At worst it’s a real gap wherever an API token outlives the login.

So every Member watches its own Google account, read-only, through the Directory API. A suspended or deleted account counts as inactive, and every team drops that person on every tool. No file needs to be edited:

# kubectl get team food-checkout -o yaml
status:
resolvedMembers: [jane.doe, john.smith, ines.bouzid]
inactiveMembers: [sam.leaver]
Press enter or click to view image in full size

The name stays listed as inactive until someone tidies up the files, which a Backstage template does. Access is removed immediately; the files can be cleaned up later.

Self-service for people who don’t write YAML

Product managers, designers and QA are in squads too. For them, every operation is a Backstage form:

  • Onboard a joiner: creates the Member file and adds them to a squad.
  • Join, leave or move squad: pick a person, a squad and the change.
  • Create a squad: name, domain, leads, members, and what it should have access to.
  • Create a repository: from a golden-path template, owned by a team.
  • Archive a leaver: tidies the record after the Google suspension.

None of these forms call a tool directly. Each one edits the same YAML and opens the same pull request an engineer would, with the same CI, the same access diff and the same code owners. The form is a convenience, not a way around review. The Backstage catalog is generated from the same files, so its org chart and people pickers match what the platform enforces.

Creating resources, not just granting access

Access to a repo someone created by hand, with any name and no clear owner, is only half governed. So repositories, Jira projects, Confluence spaces, Slack channels, Sentry projects, PagerDuty services, LaunchDarkly projects, Atlas projects and GCP projects are created here too, each as its own file with an owning team:

# org/resources/food/repositories/food-checkout-api.yaml
apiVersion: access.yassir.com/v1alpha1
kind: Repository
metadata: { name: food-checkout-api, namespace: access }
spec:
owner: food-checkout
description: Cart and checkout API
template: golden-path-go-service

A resource has its own file, separate from the team, because it outlives teams. Squads split and merge, but the repos stay. Handing a repo to another team is a one-line owner: change, approved by the current owner's leads.

Every new repo comes from a golden-path template, with a protected default branch, a CODEOWNERS file and a Backstage catalog entry. Names follow a convention per kind (<domain>-<service> for repos, ysr-<domain>-<service>-<env> for GCP projects), and CI rejects a name that doesn't fit before anything is created. Removing a resource from Git archives it. Nothing is ever hard-deleted.

We had several hundred existing repos, channels and projects. An import script adopts them read-only (management: observe), so nothing changes on any tool until an owner is assigned and the file is switched to manage. Resources nobody claims stay in an _unassigned/ folder, which became our list of orphaned resources to sort out.

What the tools wouldn’t let us do

Granting access is the easy half. For every module, the hard question was: when this disappears from Git, what removes it on the tool, and how do we know the removal worked? Not every API had an answer:

  • Postman has no documented way to revoke collection-level roles. We don’t manage collections, and govern at workspace level instead.
  • Notion has no documented API to bind a group to a teamspace. Group membership is automated, and the binding is a one-time manual step that the Team’s status lists.
  • Bitrise can add people to groups but not remove them. Each person gets their own group per team, and access is revoked from the app side.
  • Datadog restriction policies and Bitrise app roles replace the whole list on every write, so two teams granting the same dashboard would overwrite each other. A SharedGrants resource combines every team's grants into one list per target resource and is the only thing that writes it.

We documented each of these gaps instead of hiding them.

What I’d tell you before you build this

Fix the reviewer before the tooling. Our Terraform setup had a review step, and it didn’t stop anything. Making the people with the context the required approvers mattered more than anything Crossplane gave us.

Model the org, not the tools. People and teams is a model everyone already understands. One object per tool API gives you one member list per tool, and they drift.

Give the “everyone” team an owner and a ceiling. The need behind the shortcut was real. Give it a proper place, owned by DevX, read-mostly, with privileged grants blocked in CI.

Design for removal first. For every grant, know what deletes it and how you check it worked. Our first proof of concept could grant Jira access but never revoke it.

Don’t rebuild what you already have. Google suspension plus SSO was already a working kill switch. We added the cleanup, not a second offboarding process.

Test compositions like code. crossplane render runs the real composition functions in Docker, and crossplane beta validate checks the output against the real provider schemas. Both run in CI without a cluster, on every PR.

Where it ended up

A squad’s access across fifteen tools is one file, owned by the squad’s leads. A domain is one file, owned by its head. A person is one file, owned by DevX. Each repo, project and channel is one file, owned by a team. Leaving the company needs no file change at all.

Engineers get access by asking the person who actually knows the answer, and that person approves it in a PR that shows exactly what will change. DevX went back to building the platform.

The goal was never a faster access desk. It was to stop needing one.

--

--

Ayoub Reda

Written by Ayoub Reda

Engineering & AI Leader driving AI rollouts, digital transformation, and cloud platforms that serve millions.