How Courier works
Courier connects four systems your organization already trusts: Git for intent and approval, the identity provider for clients and groups, a vault for secrets, and Kubernetes for the automation that keeps them in step. This page explains what each piece does, what it is allowed to do, and what happens when something fails.
Components
| Component | Role | Where it lives |
|---|---|---|
| Request repository | One YAML file per client at requests/<team>/<name>.yaml; the policy file sets organization rules | GitHub, requests/ |
| Backstage | Request form that opens the pull request; each client appears as a Resource owned by its team, with live delivery status | Template backstage/templates/oauth-client; entities requests/<team>/<name>.catalog.yml |
| Validation check | courier validate runs on every pull request, annotates problems and posts a reviewer summary | GitHub Actions, .github/workflows/requests.yml |
| Code owners | Decide who must approve each team's requests and any policy change | .github/CODEOWNERS plus branch protection |
| Sync | One ArgoCD application per team directory; a locked project allows only OAuthClient objects in team-* namespaces | ArgoCD, deploy/argocd/ |
| Controller | Turns each OAuthClient into an IdP client plus stored credentials, and reports status | Kubernetes namespace courier-system |
| Core library | All IdP and vault logic, including the ordered write; no Kubernetes dependency, so a CLI or CI job can reuse it | pkg/ |
| Identity provider | Holds the OAuth client and the groups that may use it and read its secret | Authentik (Okta adapter planned) |
| Vault | The only place Courier stores a client secret; access granted by identity group | Vault CE, KV v2 mount kv/ |
| Team access | Per team: a service account and an External Secrets store that can read only that team's credentials | ArgoCD courier-team-access-<team>, deploy/team-access |
| Consumers | People (Vault CLI), workloads (Python sample), Kubernetes apps (External Secrets) | Use your credentials |
Architecture
Pull request
Merge
ArgoCD
Controller
Team
Amber marks the only place a secret is written. Status visible to anyone with access to the request never contains it.
Request lifecycle
What happens between a team opening a pull request and reading its credentials. The order matters: steps 7 to 9 are arranged so a failure can never leave a working secret that is not stored.
- requester
Adds
requests/team-alpha/billing-sync.yamland opens a pull request. - GitHub Actions
courier validatechecks layout, object type, fields, the owner group, grant and redirect rules, redirect hosts and approval labels, then writes a summary table for reviewers. - code owners
A team-alpha code owner approves. Because the request uses
client_credentials, thesecurity-approvedlabel is also required. - GitHub
Branch protection allows the merge once the check passes and the required reviews are in.
- ArgoCD
The team's application syncs the file into namespace
team-alpha. The project rejects anything that is not anOAuthClient. - controller
Re-applies the same validation, adds a cleanup finalizer and looks up
team-alpha-billing-syncin the IdP. - controller → vault
Generates a 64-character secret in memory and writes it to
kv/teams/team-alpha/oauth-clients/team-alpha-billing-syncwithstate: pending. - controller → IdP
Creates the OAuth provider with that secret and the allowed grant types, the application (marked as managed by Courier), and a binding that lets only
team-alphause it. - controller → vault
Patches the entry to
state: activewith theclient_idand endpoints, without reading or resending the secret. - controller
Sets
Ready=Truewith the client ID and vault path in the object's status. - team-alpha member
Signs in to vault with their normal identity and reads the credentials, or a workload does the same with its own identity.
Changes and removal
Changing a request
A merged edit (for example a new redirect URI or scope) updates the IdP client in place and refreshes the non-secret fields in the vault. The secret stays the same, so running workloads are not disrupted.
Removing a request
Deleting the file prunes the object. The controller deletes the IdP client first, so the credentials stop working, and then destroys every stored version of the secret. The object disappears only after both succeed.
Rotating a secret
A team sets the annotation courier.sororlab.dev/rotate to any new value, or sets rotation.maxAgeDays so it happens on its own. The controller follows the same pending, IdP, active order as a first delivery, so the new secret is stored before the IdP accepts it. The IdP rejects the old secret straight away; workloads that cache it must re-read the vault. Each annotation value rotates once, recorded as rotationHandled.
Adopting an existing client
A request with courier.sororlab.dev/adopt: <application> takes over a client that was built by hand. It needs the identity-approved label. The controller marks the application as managed and owned by the team, keeps the client ID, and issues a new secret, so any copy that was emailed or pasted stops working.
Every ten minutes the controller also re-applies each ready request. It compares the requested settings with the live IdP object and writes only when they differ, so a setting changed by hand in the IdP console is restored, and an unchanged client produces no IdP events and no new vault versions.
Trust model
Each actor holds only the permissions its step needs. The right-hand column matters as much as the middle one.
| Actor | Can | Cannot |
|---|---|---|
| Requester | Propose a client for their team | Merge without approval; target another team's group or vault path |
| Team code owner | Approve their team's requests | Change the policy file or pipelines (identity team owns those) |
| CI check | Read the repository and report | Write anywhere; it holds no credentials |
| ArgoCD (requests project) | Create OAuthClient objects in team-* namespaces | Create any other kind of object, or deploy to other namespaces |
| Controller (vault) | Create, update, patch and delete under kv/teams/+/oauth-clients/ | Read any secret, including ones it wrote |
| Controller (IdP) | Manage OAuth providers, applications and bindings; view flows, groups, keys and scope mappings | Manage users, change group membership, alter login flows; modify apps it did not create |
| Team member | Read their own team's vault path | Read another team's path |
| Team's External Secrets store | Read its own team's vault path into a Secret in that namespace | Read another team's path; write to the vault (policy is read-only) |
In the controller's memory between generation and the vault write, in the vault, and in the IdP (which must know it to validate the client). Never in Git, CI logs or controller logs, and Courier never writes it into Kubernetes. A copy appears in a Kubernetes Secret only if the owning team chooses External Secrets, and only in that team's namespace.
From identity group to vault access
Vault trusts the same identity provider. When someone signs in, the token's groups claim decides which vault policies they receive.
# IdP group vault identity group vault policy grants
team-alpha → team-alpha (oidc/) → team-alpha → read kv/teams/team-alpha/*
team-alpha-jwt (jwt/) → team-alpha
People sign in through the oidc/ method (browser or CLI). Workloads present a JWT from the identity provider through jwt/, or use Kubernetes service-account login.
A vault external identity group can hold only one alias, and a single auth mount cannot serve both browser logins and machine JWTs. So each IdP group maps to one vault group per login method, both carrying the same policy. Getting this wrong fails silently: one login method loses its group access while the other keeps working.
Failure handling
| What fails | What happens | What recovers it |
|---|---|---|
| Vault unavailable before the pending write | Nothing is created in the IdP; status shows ReconcileFailed | Automatic retry with back-off |
| IdP unavailable after the pending write | A pending entry exists; no client exists | Retry generates a new secret, overwrites the pending entry, creates the client |
| Controller stops between IdP create and promotion | The IdP uses the secret; the vault holds it as pending | Next reconcile rotates to a fresh secret and completes delivery |
| Status is lost (object recreated) | Courier cannot confirm delivery | It issues a new secret rather than trust an unknown state |
| Name already used by a hand-built app | Ready=False, reason NameConflict; nothing modified | Rename the request or retire the old app |
| An invalid request reaches the cluster | The schema rejects it, or status shows InvalidSpec; nothing created | Fix the request in a new pull request |
| Vault pod restarts | Brief unavailability | Automatic unseal through the cloud key; no operator action |
What Courier deliberately does not do
- Replace the identity provider's user and group management. Team membership stays where it is today.
- Broker tokens at runtime. Applications talk to the identity provider directly.
- Run a public dynamic client registration endpoint.
- Manage SAML applications.
- Store secrets anywhere except the vault.
Design decisions
| Decision | Why |
|---|---|
| Vault Community Edition for the proof of value | Matches the vault many organizations already run; only Community Edition features are used, and OpenBao remains compatible |
| A controller, not Terraform | Terraform state would become a second copy of every secret |
| Kubernetes-free core, thin controller | Adopters without Kubernetes can reuse the same logic from a CLI or CI job |