Case study Pull request #1 Team team-bravo Recorded 2026-09-13, UTC

What pull request #1 did for team-bravo

The proof of value asked one question: can a team get a working OAuth client, with its secret delivered only to that team, from nothing more than a reviewed file? This page answers it with the records of every system involved. Every value below was read back from GitHub, ArgoCD, Kubernetes, Authentik and Vault after the run; the secret itself appears only as "present, 64 characters".

For the step-by-step verification of the same run, see the walkthrough.

What we wanted

team-bravo runs a nightly report exporter: a job with no user present that calls internal APIs as itself. That is an OAuth client credentials client, which means a real secret.

Outcome

A confidential client the job can use to get access tokens from Authentik.

Constraint

No person hands over the secret, and only team-bravo can read it. Other teams must be refused even with valid identities.

Governance

A client that acts as itself is higher risk, so it needs explicit security approval before it exists.

What the pull request contained

One commit (976f8ce), one new file, 14 lines, on branch request/team-bravo-report-exporter:

# 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

What the file says

  • The owner is team-bravo, because the file lives in requests/team-bravo/
  • A confidential client using client credentials
  • Tokens carry identity and group claims

What the PR did not need

  • A ticket, email or chat with the identity team
  • Access to the Authentik or Vault consoles
  • Kubernetes manifests, namespaces or infrastructure changes
  • A secret, anywhere

Review: the policy check failed until the security-approved label was added (20:51:53), then passed (20:53:29). The PR merged at 21:40:39 as de172bd. Details in the walkthrough.

What happened on merge

Assembled from five independent records: ArgoCD's sync history, Kubernetes object timestamps, the controller's status, Authentik's event log and Vault's audit log.

  1. 21:40:39 · GitHub

    PR #1 merged into main as de172bd.

  2. 21:41:38 · ArgoCD

    ApplicationSet courier-requests generated Application courier-requests-team-bravo for the new directory and started syncing. Namespace team-bravo was created.

  3. 21:41:40 · Kubernetes

    OAuthClient team-bravo/report-exporter created (generation 1). Sync finished: successfully synced (all tasks run).

  4. 21:41:41 · Vault

    Controller identity kubernetes-courier-system-courier-controller-manager (policy courier) created version 1 of the credentials entry: the new secret, state: pending.

  5. 21:41:42 · Authentik

    User courier-controller created OAuth2 provider courier-team-bravo-report-exporter with that secret, plus its application and a binding to group team-bravo.

  6. 21:41:45 · Vault

    Same controller identity patched the entry to version 2: state: active, client_id and endpoints. The secret was not read or resent.

  7. 21:41:45 · Kubernetes

    OAuthClient condition Ready=True, reason Delivered.

  8. 21:42:24 · Vault

    Access check: jwt-courier-svc-alpha (team-alpha) denied; jwt-courier-svc-bravo (team-bravo) read ok.

Independent proof of the ordering guarantee

Vault's audit log shows the secret stored at 21:41:41; Authentik's event log shows the client that uses it created at 21:41:42. Two systems that do not share a clock source or a log agree that the secret was safely stored before it became live.

What was created, platform by platform

Created by the PR versus set up at onboarding

The team-bravo identity group, its vault policy and identity groups, and the test service account existed before the PR; they are part of onboarding a team. Everything listed below under ArgoCD, Kubernetes, Authentik and the vault entry was created by merging this one file.

GitHub

ObjectValue
Pull request#1 request: team-bravo report-exporter (client_credentials), 1 commit, 1 file
Labelsecurity-approved, added 20:53:08
Check runs on 976f8ceValidate requests: failure 20:51:53, success 20:53:29
Merge commitde172bd at 21:40:39; head branch deleted 21:40:47
Durable recordThe request file on main is now the source of truth for this client
Pull request #1 conversation showing the merged state, the security-approved label and the deleted branch
The whole request, as GitHub records it: one commit, one label, one merge.

ArgoCD

FieldValue
Applicationcourier-requests-team-bravo, created 21:41:38
Owned byApplicationSet courier-requests (generated, not hand-made)
Projectcourier-requests: only OAuthClient objects, only team-* namespaces
Sourcegithub.com/paimonsoror/courier, path requests/team-bravo, files *.yaml, revision main
Destinationnamespace team-bravo
Sync policyautomated, prune, self-heal, CreateNamespace=true
Manages1 resource: courier.sororlab.dev/OAuthClient team-bravo/report-exporter, Synced
First syncrevision de172bd, deployed 21:41:40; status Synced, Healthy
Argo CD application details tree for courier-requests-team-bravo with one oauthclient resource, report-exporter, Healthy and Synced
The application the merge created. One managed resource, the report-exporter OAuthClient; last sync from the pull request #1 merge.
Argo CD applications list filtered to courier: courier, courier-controller, courier-requests-team-alpha, courier-requests-team-bravo, courier-team-access-team-alpha and courier-team-access-team-bravo, all Healthy and Synced
Every Courier application in Argo CD. Each team directory produces a request application and, since phase 2b, a team-access application.

Kubernetes

ObjectDetails
Namespace team-bravoCreated 21:41:38 by ArgoCD
OAuthClient report-exporterCreated 21:41:40; finalizer courier.sororlab.dev/cleanup; label app.kubernetes.io/instance=courier-requests-team-bravo

OAuthClient status

clientId:             8BhnTaEg83o1lfhlwLSbBqHL6N5rMGOdsUi9rfA9
credentialsDelivered: true
identityProvider:     authentik
lastSecretIssued:     2026-09-13T21:41:45Z
observedGeneration:   1
secretPath:           kv/teams/team-bravo/oauth-clients/team-bravo-report-exporter
conditions:
  - Ready=True  reason=Delivered  since 21:41:45
    "credentials are available at kv/teams/team-bravo/oauth-clients/team-bravo-report-exporter"

Nothing else was created in the namespace: no Secret, no workload. The only other objects are the ones Kubernetes adds to every namespace (ServiceAccount default, ConfigMap kube-root-ca.crt).

Authentik

OAuth2 provider (pk 12)

SettingValueWhy
namecourier-team-bravo-report-exporterCourier's naming, courier-<team>-<name>
client_typeconfidentialFrom the request
client_id8BhnTaEg83o1lfhlwLSbBqHL6N5rMGOdsUi9rfA9Generated by Authentik; not sensitive
client_secretpresent, 64 charsGenerated by Courier; same value as in the vault
grant_typesclient_credentials onlyAuthentik rejects any grant not listed
redirect_urisnoneNo browser sign-in for a machine client
property_mappingsOpenID openid, OpenID profile, oauth-groupsThe three requested scopes
sub_modeuser_emailCourier default
include_claims_in_id_tokentrueGroups available to relying parties
access / refresh token validityhours=1 / days=30Authentik defaults
authorization flowdefault-provider-authorization-implicit-consentCourier default
signing keyauthentik Self-signed CertificateCourier default
Authentik admin page for OAuth2/OpenID provider courier-team-bravo-report-exporter: assigned to application team-bravo nightly report exporter, client type Confidential, the client ID, no redirect URIs, and the issuer, authorize, token, userinfo and JWKS URLs
The provider in Authentik's admin console. Confidential, no redirect URIs, and the issuer and token endpoints that also appear in the vault entry.

Application and access

ObjectValue
Applicationname team-bravo: nightly report exporter, slug team-bravo-report-exporter, provider 12
Ownership markermeta_description: managed-by: courier; owner: team-bravo
Policy bindinggroup team-bravo, order 0, enabled; policy engine mode any
Issuerhttps://auth.sororlab.dev/application/o/team-bravo-report-exporter/
Authentik application team-bravo nightly report exporter, Policy Group User Bindings tab, policy engine mode ANY, a single binding: order 0, Group team-bravo, enabled, timeout 30
Who may use the client. A single binding to group team-bravo, created by Courier from the request's owner.

Authentik event log for this client

21:41:42  model_created  by courier-controller  oauth2provider  courier-team-bravo-report-exporter
21:51:48  model_updated  by courier-controller  oauth2provider  courier-team-bravo-report-exporter
21:51:48  model_updated  by courier-controller  application     team-bravo: nightly report exporter
22:01:51  model_updated  by courier-controller  oauth2provider  courier-team-bravo-report-exporter
22:01:52  model_updated  by courier-controller  application     team-bravo: nightly report exporter

Every change is attributed to the scoped courier-controller account, not to an administrator. The updates every ten minutes are the controller's routine drift repair, discussed below.

Authentik provider changelog listing Model updated entries by user courier-controller every ten minutes, from client IP 10.0.0.206
The provider's changelog, hours later (browser local time, UTC−4). Every entry is attributed to courier-controller. The steady ten-minute updates are IdP drift repair; the vault side of this churn was fixed, and quieting these entries is on the roadmap.

Vault

The credentials entry

Path kv/teams/team-bravo/oauth-clients/team-bravo-report-exporter

KeyValue
client_id8BhnTaEg83o1lfhlwLSbBqHL6N5rMGOdsUi9rfA9
client_secretpresent, 64 chars
client_typeconfidential
grant_typesclient_credentials
scopesopenid profile groups
issuerhttps://auth.sororlab.dev/application/o/team-bravo-report-exporter/
token_endpointhttps://auth.sororlab.dev/application/o/token/
authorization_endpointhttps://auth.sororlab.dev/application/o/authorize/
jwks_urihttps://auth.sororlab.dev/application/o/team-bravo-report-exporter/jwks/
userinfo_endpointhttps://auth.sororlab.dev/application/o/userinfo/
owner_groupteam-bravo
idp / managed_byauthentik / courier-controller
stateactive
Vault UI showing teams/team-bravo/oauth-clients/team-bravo-report-exporter on the Secret tab, version 6, with keys authorization_endpoint, client_id, client_secret, client_type, grant_types, idp, issuer and jwks_uri, every value masked
The entry in the Vault UI, viewed by an administrator. Every value is masked by default, and none were revealed for this capture.

Who can read it

# policy team-bravo
path "kv/data/teams/team-bravo/*"      { capabilities = ["read"] }
path "kv/metadata/teams/team-bravo/*"  { capabilities = ["read", "list"] }
path "kv/metadata/teams/team-bravo"    { capabilities = ["list"] }

# granted through identity groups mapped from the IdP group team-bravo
identity group team-bravo       alias team-bravo on oidc/  →  policy team-bravo   # people
identity group team-bravo-jwt   alias team-bravo on jwt/   →  policy team-bravo   # workloads

Vault audit log for this path

TimeOperationIdentityPoliciesResult
21:41:41createkubernetes-courier-system-courier-controller-managercourierok v1, pending
21:41:45patchkubernetes-courier-system-courier-controller-managercourierok v2, active
21:42:24readjwt-courier-svc-alphadefault, team-alphapermission denied
21:42:24readjwt-courier-svc-bravodefault, team-bravook
21:44:09read metadataoidc-paimon.soror@gmail.comdefault, team-alpha, vault-adminok administrator browsing the UI
21:51:49patchkubernetes-courier-system-courier-controller-managercourierok v3, resync
22:01:52patchkubernetes-courier-system-courier-controller-managercourierok v4, resync

Three things stand out. The controller never reads the entry; it only creates and patches. The only successful data read is team-bravo's. And an administrator's access is recorded like anyone else's, which is why the broad vault-admin policy is on the hardening checklist.

What team-bravo can do now

Can

  • People in the team-bravo group sign in with vault login -method=oidc and read the credentials
  • Workloads with a team-bravo identity log in to Vault (jwt/, role machine) and read them at start-up
  • Exchange them for access tokens at the token endpoint (client_credentials, valid 1 hour). Authentik accepts the stored secret (HTTP 200 at the revocation endpoint) and rejects a wrong one (HTTP 401); see the correction in the walkthrough for why a token alone is not proof
  • Change the client (scopes, display name, groups) with a pull request; the secret stays the same
  • Retire it with a pull request; the client is deleted and the secret destroyed
  • See status in ArgoCD (courier-requests-team-bravo) or with kubectl -n team-bravo get oauthclients

Cannot

  • Find the secret anywhere but Vault: not in Git, CI, ArgoCD, Kubernetes status or logs
  • Let another team read it; team-alpha was refused with a valid identity
  • Request a client under another team's name or vault path
  • Add a high-risk grant without the security-approved label
  • Edit the client in Authentik out of band and have it stick; the controller restores the requested settings

Added since: two ready-made ways to consume

CapabilityWhat team-bravo getsVerified
Vault connection in its namespaceServiceAccount courier-secrets and SecretStore courier-vault, created automatically by ArgoCD application courier-team-access-team-bravoReady
External Secrets syncExternalSecret report-exporter-oauth produces a Secret with all 14 fields; Authentik accepts the synced secret22:19:44 vault read ok
Python sampleread_credentials(vault_client(), "team-bravo", "report-exporter") as the workload's own identity, then a one-hour bearer tokenpassed
Isolation from other teamsteam-alpha's store and team-alpha's workload are both refused on this pathpermission denied in the vault audit log

Details and full output: Use your credentials.

After delivery

Every ten minutes the controller re-applies each ready request. The records show exactly what that did:

  • Authentik: provider and application model_updated at 21:51 and 22:01. This is intended: it restores any settings changed by hand in the console. Because nothing had changed, the updates were no-ops, though they add entries to the event log.
  • Vault: patches at 21:51:49 and 22:01:52 created versions 3 and 4 with identical content. This was not intended.
Finding from this inventory, fixed

Each KV v2 write creates a new version, and each version retains a copy of the secret, so the periodic resync was producing a new version of unchanged credentials every ten minutes. The controller now rewrites vault metadata only when the request itself changes (its generation differs from the last reconciled one). IdP drift repair is unchanged. Comparing IdP settings before writing, to quiet the Authentik event log, is planned with drift detection.

The vault's own version history confirms the fix. Versions 1 and 2 are the delivery (pending, then active). Versions 3 to 6 are resyncs, exactly ten minutes apart. The fixed controller rolled out at about 22:27 UTC, and no version has been written since.

Vault UI version history for teams/team-bravo/oauth-clients/team-bravo-report-exporter: versions 1 and 2 created 5:41 PM, versions 3, 4, 5 and 6 created at 5:51, 6:01, 6:11 and 6:21 PM, version 6 current
Version history, captured more than two hours after the fix (browser local time, UTC−4). The last version, 6, was created at 6:21 PM, before the fixed controller rolled out at about 6:27 PM.

Reproduce the inventory

Every table on this page came from one script, run after the merge:

deploy/phase2/inventory.sh team-bravo report-exporter

It reads each platform directly: the namespace and OAuthClient; the ArgoCD application and sync history; the Authentik provider, application, bindings and event log; and the Vault entry metadata, fields (secret shown only as present with its length), access policy, identity groups and audit log.