One request, end to end
This page follows a real request through Courier in the reference environment: team-bravo asks for a machine-to-machine client. At every step it shows what happened, the evidence captured at the time, and the command that lets you check the same thing yourself. Nothing here is illustrative; timestamps, messages and outputs are from the run.
For the full record of what this request set out to do and every object it created in each platform, see What PR #1 did for team-bravo.
At a glance
| Time | Step | Actor | Result |
|---|---|---|---|
20:51:29 | Pull request #1 opened | requester | One file added: requests/team-bravo/report-exporter.yaml |
20:51:53 | Policy check | GitHub Actions | failed approval label missing (18 s run) |
20:53:08 | Label security-approved added | approver | Check re-ran automatically |
20:53:29 | Policy check | GitHub Actions | passed |
21:40:39 | Merged as de172bd | maintainer | Request is now on main |
~21:41 | Sync | ArgoCD | Application courier-requests-team-bravo created and synced |
21:41:45 | Credentials issued | controller | READY True, credentials in vault |
21:42 | Access check | verification script | passed owner reads and uses them; other team denied |
Merge to usable credentials: about 66 seconds, including a manual ArgoCD refresh (see step 5). No person saw, copied or transmitted the secret.
1. The request
The requester added a single file on branch request/team-bravo-report-exporter. The team directory makes team-bravo the owner; nothing in the file can point the credentials anywhere else.
# requests/team-bravo/report-exporter.yaml
apiVersion: courier.sororlab.dev/v1alpha1
kind: OAuthClient
metadata:
name: report-exporter
namespace: team-bravo
spec:
displayName: "team-bravo: nightly report exporter"
clientType: confidential
grantTypes:
- client_credentials
scopes:
- openid
- profile
- groups
Check it yourself
go run ./cmd/courier validate requests
2. The policy gate stops it
Opening the pull request triggered the Client requests / Validate requests check. The file was valid, but client_credentials lets a workload act as itself, so the repository policy requires an explicit approval label. The check failed in 18 seconds and put this annotation on the file:
requests/team-bravo/report-exporter.yaml
needs the "security-approved" label on the pull request (grant requires approval)
The run summary showed reviewers exactly what merging would do, before anything existed:
| Change | Team | Client | Type | Grants | Credentials path | Approval |
|---|---|---|---|---|---|---|
| added or changed | team-bravo | report-exporter | confidential | client_credentials | kv/teams/team-bravo/oauth-clients/team-bravo-report-exporter | needs label security-approved |
The rule came from requests/.policy.yaml (grantApprovals: client_credentials: security-approved), a file only the identity team can change. The approval requirement applies to the requests a pull request changes, so existing clients never need re-approval.
3. Approval re-runs the gate
At 20:53:08 the security-approved label was added. The workflow listens for labeled events, so the check re-ran without a new commit and passed at 20:53:29.
Check run on 976f8ce | Started | Completed | Conclusion |
|---|---|---|---|
| Validate requests | 20:51:35 | 20:51:53 | failure |
| Validate requests | 20:53:12 | 20:53:29 | success |
Check it yourself
curl -s https://api.github.com/repos/paimonsoror/courier/commits/976f8ce/check-runs \
| jq '.check_runs[] | select(.name=="Validate requests") | {conclusion, started_at, completed_at}'
4. Merge
The pull request merged at 21:40:39 as commit de172bd, and the branch was deleted.
security-approved, and No reviews, the gap described below.No code-owner review is recorded on #1. Branch protection was not yet enabled on main (the GitHub API reported protected: false), so GitHub allowed the merge with a passing check but no review. The label gate did its job; the review gate was not switched on. Enable branch protection as described in the implementation guide before real teams use the repository.
5. ArgoCD picks up the merge
The courier-requests ApplicationSet saw a new directory, requests/team-bravo, and generated an application for it. The locked project allowed it to create exactly one kind of object: the OAuthClient in namespace team-bravo.
$ kubectl -n argocd get application -l app.kubernetes.io/part-of=courier
NAME SYNC HEALTH REV
courier-controller Synced Healthy de172bd07d1f67f87205325ff5fbde04ee12ea2d
courier-requests-team-alpha Synced Healthy de172bd07d1f67f87205325ff5fbde04ee12ea2d
courier-requests-team-bravo Synced Healthy de172bd07d1f67f87205325ff5fbde04ee12ea2d
report-exporter OAuthClient. Last sync: the merge of pull request #1.A minute after merge the team-bravo application did not exist yet: ArgoCD polls Git every few minutes by default. We asked the ApplicationSet to re-read Git immediately, and the application appeared within seconds. Without the refresh the same thing happens on the next poll. A GitHub webhook to ArgoCD removes the wait.
kubectl -n argocd annotate applicationset courier-requests \
argocd.argoproj.io/application-set-refresh=true --overwrite
6. The controller delivers
The controller validated the object again with the same rules as the pull request check, wrote the new secret to the vault as pending, created the client in Authentik with that secret, bound it to group team-bravo, and promoted the vault entry to active.
$ kubectl get oauthclients -A
NAMESPACE NAME READY TYPE CLIENT ID SECRET PATH
team-alpha billing-sync True confidential BmNrqqyjaqWKLHgXPC7CtpBmKNo2Pye6DQjNMj6F kv/teams/team-alpha/oauth-clients/team-alpha-billing-sync
team-bravo report-exporter True confidential 8BhnTaEg83o1lfhlwLSbBqHL6N5rMGOdsUi9rfA9 kv/teams/team-bravo/oauth-clients/team-bravo-report-exporter
The controller log records the event with the client ID and path only:
2026-09-13T21:41:45Z INFO issued credentials
OAuthClient: team-bravo/report-exporter
client: team-bravo-report-exporter
clientId: 8BhnTaEg83o1lfhlwLSbBqHL6N5rMGOdsUi9rfA9
path: kv/teams/team-bravo/oauth-clients/team-bravo-report-exporter
Check it yourself
kubectl -n team-bravo describe oauthclient report-exporter
kubectl -n courier-system logs deploy/courier-controller-manager | grep report-exporter
7. Access verified
Delivery is only half the claim. The other half is that the right team, and only that team, can use it. deploy/phase2/check-access.sh signs in to the vault as each team's test service account, exactly as a workload would, and tries to read the path.
$ deploy/phase2/check-access.sh team-bravo report-exporter
request team-bravo/report-exporter is Ready; credentials at kv/teams/team-bravo/oauth-clients/team-bravo-report-exporter
PASS team-alpha is denied
PASS team-bravo (owner) reads the credentials
PASS team-bravo credentials get a token from Authentik
ACCESS CHECK PASSED
The third line above did not prove what we said. Authentik's client_credentials grant also takes a service account's username and app password, and when those are present it returns a token whatever client_secret is sent. We found this while verifying rotation: the old, a made-up and a missing secret all got HTTP 200. The token proved the service account, not the client secret.
The check now asks Authentik's token revocation endpoint, which always authenticates the client with client_id:client_secret: 200 for the right secret, 401 invalid_client for anything else. It also sends a wrong secret, so the check can visibly fail. Re-run against the same client:
$ deploy/phase2/check-access.sh team-bravo report-exporter
request team-bravo/report-exporter is Ready; credentials at kv/teams/team-bravo/oauth-clients/team-bravo-report-exporter
PASS team-alpha is denied
PASS team-bravo (owner) reads the credentials
PASS Authentik accepts the delivered secret
PASS Authentik rejects a wrong secret (so the check above can fail)
ACCESS CHECK PASSED
| Result | What it proves |
|---|---|
| team-alpha is denied | Another team, signed in with a valid identity, gets HTTP 403. Access follows identity groups, not knowledge of the path. |
| team-bravo reads the credentials | The owning team's identity maps to its vault policy, and the entry is state: active with a secret present. |
| Authentik accepts the delivered secret | The secret in the vault is the live one: the revocation endpoint authenticates client_id:client_secret and returns HTTP 200. |
| Authentik rejects a wrong secret | The same call with a wrong secret returns HTTP 401, so the previous line is a real test and not a formality. |
What never happened
The strongest evidence is what is missing. For this request, the secret did not appear in:
| Place | How we know |
|---|---|
| The pull request or Git history | The request file has no secret field; the schema rejects unknown fields |
| CI logs and summary | The check runs before the secret exists; its summary shows only the vault path |
| ArgoCD or Kubernetes | OAuthClient status has no secret field; kubectl describe shows client ID and path only |
| Controller logs | The log line above is the full record of issuing; secrets have a redacting type that prints [REDACTED] |
| Anyone's inbox or chat | No one received it; the owning team reads it from the vault with their own identity |
A second request, from Backstage (pull request #2)
Pull request #1 was written by hand. The second request came from the Backstage form, to prove the self-service path end to end: team-alpha asks for orders-portal, a web application that signs users in. It needs no approval label because it does not use client_credentials.
| Time (UTC) | Step | Evidence |
|---|---|---|
01:12:02 | Form submitted in Backstage | Scaffolder task: render 1 s, open pull request 3 s |
01:12:05 | Pull request #2 opened | Two files: orders-portal.yaml and orders-portal.catalog.yml |
01:12:27 | Validate requests success | 16-second run, approval not required |
01:15:20 | Merged as 14bda35 | Other CI finished at 01:15:09 |
01:16:02 | Credentials issued | READY True, client tK6PAj0W…; about 40 s after merge including an ApplicationSet refresh |
01:17 | Access check passed | team-alpha reads; team-bravo denied |
Form to usable credentials: about four minutes, almost all of it the review and merge.
Delivered, and visible to the team
$ deploy/phase2/check-access.sh team-alpha orders-portal
request team-alpha/orders-portal is Ready; credentials at kv/teams/team-alpha/oauth-clients/team-alpha-orders-portal
PASS team-alpha (owner) reads the credentials
SKIP token test: authorization_code refresh_token clients need a user sign-in, not client_credentials
PASS team-bravo is denied
ACCESS CHECK PASSED
OAuthClient object in the cluster. The secret itself never appears here.GitHub shows the pull request as authored by the account behind Backstage's GitHub token, for every requester. The actual requester is recorded in the description (Requested by). A GitHub App or per-user GitHub sign-in in Backstage would make GitHub attribute it directly.
Re-run on 14 September with the corrected secret check (see the correction in step 7), which now applies to sign-in clients too:
$ deploy/phase2/check-access.sh team-alpha orders-portal
request team-alpha/orders-portal is Ready; credentials at kv/teams/team-alpha/oauth-clients/team-alpha-orders-portal
PASS team-alpha (owner) reads the credentials
PASS Authentik accepts the delivered secret
PASS Authentik rejects a wrong secret (so the check above can fail)
PASS team-bravo is denied
ACCESS CHECK PASSED
Rotation and adoption (pull requests #3 and #4)
Phase 4 added two lifecycle operations, both requested the same way as everything else: a small change in a pull request. The Phase 4 controller was rolled out at 01:31:38 UTC on 14 September; both pull requests were merged after it.
Pull request #3: rotate team-bravo's secret
The only change is one annotation on requests/team-bravo/report-exporter.yaml. Any new value asks for exactly one rotation.
metadata:
name: report-exporter
annotations:
courier.sororlab.dev/rotate: "2026-09-14-phase4-demo"
client_credentials request, so the security label is still required.| Platform | What changed |
|---|---|
| Kubernetes | lastSecretIssued: 2026-09-14T01:38:54Z, rotationHandled: 2026-09-14-phase4-demo; client ID unchanged |
| Vault | New version 8 of the entry, state: active; secret fingerprint 9b96329eec4b (was 2f2a72d01e19) |
| Authentik | The provider's stored secret now has the same fingerprint as the vault; one model_updated event, at 01:38:53 |
| team-bravo's namespace | The External Secrets copy kept the old secret until its next refresh, then picked up the new one |
2ed3a9d. Versions 7–8: this rotation, pending then active. Nothing after, through later resyncs. Times in the browser's zone (UTC−4).$ deploy/phase4/verify-rotation.sh
==> request status
lastSecretIssued: 2026-09-14T01:38:54Z
rotationHandled: 2026-09-14-phase4-demo
==> before External Secrets refresh
vault version 8, state active, secret fingerprint 9b96329eec4b
kubernetes Secret fingerprint 2f2a72d01e19 (client_id unchanged)
PASS vault holds a new secret; the synced copy still holds the previous one
PASS client ID is unchanged by rotation
PASS Authentik rejects the previous secret
PASS Authentik accepts the new secret
==> force External Secrets to refresh
vault version 8, state active, secret fingerprint 9b96329eec4b
kubernetes Secret fingerprint 9b96329eec4b (client_id unchanged)
PASS External Secrets now holds the new secret
PASS Authentik accepts the synced copy
ROTATION VERIFIED
The window between "vault has the new secret" and "every copy has it" is the refresh interval of whatever caches it. Workloads that read the vault directly see the new secret on their next read; External Secrets copies wait for their refresh (one hour here) unless forced.
Pull request #4: adopt a hand-built client
Before the pull request, deploy/phase4/create-legacy-client.sh built legacy-crm by hand in Authentik, the way clients were made before Courier, and kept its "emailed" secret aside so we could prove it stops working. Team-alpha then requested crm with one extra annotation:
metadata:
name: crm
annotations:
courier.sororlab.dev/adopt: legacy-crm
security-approved for the grant and identity-approved for taking over an existing client.
legacy-crm.| Platform | What changed |
|---|---|
| Kubernetes | crm Ready, client ID Fx2FjQztQf1w…, the same one the hand-built client always had |
| Authentik | Application legacy-crm now described managed-by: courier; owner: team-alpha and renamed team-alpha: CRM integration (adopted); new secret set |
| Vault | New entry kv/teams/team-alpha/oauth-clients/legacy-crm, readable by team-alpha only |
akadmin, 01:31:40 UTC), taken over by courier-controller in two writes at 01:38, then nothing: nineteen minutes and two resyncs later there are no further updates. Times shown in the browser's zone (UTC−4).
$ deploy/phase4/verify-adoption.sh
NAME READY TYPE CLIENT ID SECRET PATH
crm True confidential Fx2FjQztQf1w2bxpAk9MrDawX2XlGlMVaal9JaEU kv/teams/team-alpha/oauth-clients/legacy-crm
==> Authentik application
name: team-alpha: CRM integration (adopted) | meta_description: managed-by: courier; owner: team-alpha
PASS team-alpha reads the adopted client's credentials
PASS client ID is unchanged by adoption
PASS Authentik rejects the emailed secret
PASS Authentik accepts the secret Courier stored in vault
PASS team-bravo is denied
ADOPTION VERIFIED
Integrations that use the client keep their client ID and redirect settings; they only need to read the new secret from the vault. The copy that was emailed is now worthless.
Our first versions asked Authentik for a token with the old secret and expected a refusal. Both got HTTP 200. The secrets had rotated (Authentik's stored secret matched the vault), but Authentik's client_credentials grant with a service account's username and app password does not check client_secret at all: the old, a made-up and a missing secret were all accepted. The checks were rewritten to use the revocation endpoint, which always authenticates the client, and re-run with the results above.
Drift repair that stays quiet
Before Phase 4, every ten-minute resync rewrote every client in Authentik: 12 model_updated events an hour for report-exporter alone. Phase 4 compares first and writes only on a difference. In the first 16 minutes after rollout, billing-sync had no events and report-exporter had one, the rotation. orders-portal still had one every resync, because Authentik adds redirect_uri_type to redirect URIs and the controller did not send it; that was fixed in 8e454ad.
What we learned
- The label gate works as designed. It blocked a high-risk grant, told the requester exactly what was needed, and cleared itself on approval without a new commit.
- Branch protection is a required setup step, not optional. Without it, the check still reports but merges do not wait for code-owner review.
- Sync latency is ArgoCD's polling interval. Delivery itself took seconds; add an ArgoCD webhook if teams expect credentials immediately after merge.
- Verification must include the negative case. "The owner can read it" alone would also pass if every team could read everything.
- A check that cannot fail proves nothing. "The credentials get a token" also passed with a rotated-out secret and with a made-up one. Every secret check now also sends a wrong secret and requires a refusal.
Reproduce it
- requester
Add
requests/<team>/<name>.yamlon a branch and open a pull request. See Request a client. - approver
For
client_credentials, addsecurity-approved; wait for Validate requests to pass; review and merge. - operator
Optionally refresh the ApplicationSet, then wait for Ready:
kubectl -n <team> wait oauthclient/<name> --for=condition=Ready --timeout=180s - operator
Prove access:
deploy/phase2/check-access.sh <team> <name>must printACCESS CHECK PASSED.