Everyone Pull request #1 Date 2026-09-13 Times UTC

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

TimeStepActorResult
20:51:29Pull request #1 openedrequesterOne file added: requests/team-bravo/report-exporter.yaml
20:51:53Policy checkGitHub Actionsfailed approval label missing (18 s run)
20:53:08Label security-approved addedapproverCheck re-ran automatically
20:53:29Policy checkGitHub Actionspassed
21:40:39Merged as de172bdmaintainerRequest is now on main
~21:41SyncArgoCDApplication courier-requests-team-bravo created and synced
21:41:45Credentials issuedcontrollerREADY True, credentials in vault
21:42Access checkverification scriptpassed 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)
GitHub Actions run summary for the failed check: the Courier client requests table, a Problems list saying the request needs the security-approved label, and two error annotations
The failed run, as reviewers saw it. The summary lists the problem, and the annotation points at the exact file.

The run summary showed reviewers exactly what merging would do, before anything existed:

ChangeTeamClientTypeGrantsCredentials pathApproval
added or changedteam-bravoreport-exporterconfidentialclient_credentialskv/teams/team-bravo/oauth-clients/team-bravo-report-exporterneeds label security-approved
GitHub Actions run #2 for pull request #1 with status Failure, the Validate requests job, and the Courier client requests summary table showing the approval column as needs label security-approved
Run #2, triggered by opening the pull request. Status Failure in 20 seconds; the approval column explains why.
Why this matters

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 976f8ceStartedCompletedConclusion
Validate requests20:51:3520:51:53failure
Validate requests20:53:1220:53:29success
GitHub Actions run #3 triggered by labeling pull request #1, status Success, with the summary table approval column reading approved security-approved and the text All checks passed
Run #3, triggered by the label itself. Same request, now approved, and all checks passed.

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.

Pull request #1 conversation: merged, one commit, the security-approved label added, the merge commit de172bd, the branch deleted, and no reviews in the sidebar
Pull request #1 after merge. Note the sidebar: label security-approved, and No reviews, the gap described below.
Gap found during this run

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
Argo CD application courier-requests-team-bravo: Healthy, Synced, last sync OK to de172bd from the pull request #1 merge, managing a single OAuthClient named report-exporter
courier-requests-team-bravo in Argo CD. The generated application manages exactly one object: the report-exporter OAuthClient. Last sync: the merge of pull request #1.
Timing

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
Correction, 14 September 2026

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
ResultWhat it proves
team-alpha is deniedAnother team, signed in with a valid identity, gets HTTP 403. Access follows identity groups, not knowledge of the path.
team-bravo reads the credentialsThe owning team's identity maps to its vault policy, and the entry is state: active with a secret present.
Authentik accepts the delivered secretThe secret in the vault is the live one: the revocation endpoint authenticates client_id:client_secret and returns HTTP 200.
Authentik rejects a wrong secretThe 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:

PlaceHow we know
The pull request or Git historyThe request file has no secret field; the schema rejects unknown fields
CI logs and summaryThe check runs before the secret exists; its summary shows only the vault path
ArgoCD or KubernetesOAuthClient status has no secret field; kubectl describe shows client ID and path only
Controller logsThe log line above is the full record of issuing; secrets have a redacting type that prints [REDACTED]
Anyone's inbox or chatNo 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)StepEvidence
01:12:02Form submitted in BackstageScaffolder task: render 1 s, open pull request 3 s
01:12:05Pull request #2 openedTwo files: orders-portal.yaml and orders-portal.catalog.yml
01:12:27Validate requests success16-second run, approval not required
01:15:20Merged as 14bda35Other CI finished at 01:15:09
01:16:02Credentials issuedREADY True, client tK6PAj0W…; about 40 s after merge including an ApplicationSet refresh
01:17Access check passedteam-alpha reads; team-bravo denied

Form to usable credentials: about four minutes, almost all of it the review and merge.

Backstage Request an OAuth client form, Review step: owning team group:default/team-alpha, client name orders-portal, display name team-alpha orders portal, confidential client type, grant types authorization_code and refresh_token, redirect URI https://orders.sororlab.dev/oauth/callback, scope openid, and the purpose text
The request, as the requester reviewed it before clicking Create.
Backstage scaffolder task for Request an OAuth client: Render the request completed in 1 second, Open the pull request completed in 3 seconds, with Pull request and What happens next links and the task log
Backstage rendered both files and opened the pull request in four seconds.
GitHub pull request #2, request team-alpha orders-portal (authorization_code, refresh_token), with the generated description table showing team, client, type, grants and requested by paimon, the purpose and the vault path
Pull request #2 as reviewers see it. The description is generated from the form, including who asked and why.

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
Backstage group page for Team Alpha: relations graph showing parent sororlab, member Paimon, and ownership of team-alpha billing sync job and team-alpha orders portal
Team Alpha's page in Backstage, minutes after merge. The new client appears next to the existing one; no one registered it by hand.
Backstage Kubernetes tab for team-alpha orders portal: OAuthClient orders-portal with its client ID, a Ready condition with reason Delivered and the credentials path, Credentials Delivered checked, identity provider authentik
Live delivery status on the client's page, read from the OAuthClient object in the cluster. The secret itself never appears here.
Who is the author?

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"
GitHub Actions run for pull request #3: Validate requests succeeded in 20 seconds; summary table shows team-bravo report-exporter, confidential, client_credentials, credentials path kv/teams/team-bravo/oauth-clients/team-bravo-report-exporter, approved with security-approved
The policy check on pull request #3. A rotation still touches a client_credentials request, so the security label is still required.
PlatformWhat changed
KuberneteslastSecretIssued: 2026-09-14T01:38:54Z, rotationHandled: 2026-09-14-phase4-demo; client ID unchanged
VaultNew version 8 of the entry, state: active; secret fingerprint 9b96329eec4b (was 2f2a72d01e19)
AuthentikThe provider's stored secret now has the same fingerprint as the vault; one model_updated event, at 01:38:53
team-bravo's namespaceThe External Secrets copy kept the old secret until its next refresh, then picked up the new one
Vault version history for teams/team-bravo/oauth-clients/team-bravo-report-exporter: version 8 current and version 7 created 09:38 PM; versions 3 to 6 created ten minutes apart between 05:51 and 06:21 PM; versions 1 and 2 at 05:41 PM
The whole life of team-bravo's secret, in the vault. Versions 1–2: first delivery (stored as pending, then promoted to active). Versions 3–6: the early resync bug that rewrote metadata every ten minutes, fixed in 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
GitHub pull request #4, request: team-alpha adopts hand-built legacy-crm client, with labels identity-approved and security-approved
Pull request #4 carries two approvals: security-approved for the grant and identity-approved for taking over an existing client.
GitHub Actions run for pull request #4: Validate requests succeeded in 16 seconds; summary table shows team-alpha crm, confidential, client_credentials, credentials path kv/teams/team-alpha/oauth-clients/legacy-crm, approved with security-approved and identity-approved
The check passed once both labels were present. The credentials path uses the adopted name, legacy-crm.
PlatformWhat changed
Kubernetescrm Ready, client ID Fx2FjQztQf1w…, the same one the hand-built client always had
AuthentikApplication legacy-crm now described managed-by: courier; owner: team-alpha and renamed team-alpha: CRM integration (adopted); new secret set
VaultNew entry kv/teams/team-alpha/oauth-clients/legacy-crm, readable by team-alpha only
Authentik admin, application team-alpha: CRM integration (adopted), overview tab: provider courier-legacy-crm (OAuth2/OpenID Provider), policy engine mode ANY
The hand-built application after adoption. Same application, now named for its owning team, with its provider renamed to Courier's convention.
Authentik changelog for the adopted application: Model created by akadmin at 9:31:40 PM, then Model updated by courier-controller at 9:38:46 PM and 9:38:50 PM, nothing after
Its whole history. Created by hand (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).
Vault version history for teams/team-alpha/oauth-clients/legacy-crm: version 2 current and version 1, both created 09:38 PM
The adopted client's vault entry. Two versions at adoption, the new secret stored as pending and then promoted to active, and none since.
$ 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.

The first run of both checks failed, correctly

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

  1. requester

    Add requests/<team>/<name>.yaml on a branch and open a pull request. See Request a client.

  2. approver

    For client_credentials, add security-approved; wait for Validate requests to pass; review and merge.

  3. operator

    Optionally refresh the ApplicationSet, then wait for Ready:
    kubectl -n <team> wait oauthclient/<name> --for=condition=Ready --timeout=180s

  4. operator

    Prove access: deploy/phase2/check-access.sh <team> <name> must print ACCESS CHECK PASSED.