Architects and security reviewers Reference environment Authentik 2026.8 · Vault CE 2.0 · k3s · ArgoCD

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

ComponentRoleWhere it lives
Request repositoryOne YAML file per client at requests/<team>/<name>.yaml; the policy file sets organization rulesGitHub, requests/
BackstageRequest form that opens the pull request; each client appears as a Resource owned by its team, with live delivery statusTemplate backstage/templates/oauth-client; entities requests/<team>/<name>.catalog.yml
Validation checkcourier validate runs on every pull request, annotates problems and posts a reviewer summaryGitHub Actions, .github/workflows/requests.yml
Code ownersDecide who must approve each team's requests and any policy change.github/CODEOWNERS plus branch protection
SyncOne ArgoCD application per team directory; a locked project allows only OAuthClient objects in team-* namespacesArgoCD, deploy/argocd/
ControllerTurns each OAuthClient into an IdP client plus stored credentials, and reports statusKubernetes namespace courier-system
Core libraryAll IdP and vault logic, including the ordered write; no Kubernetes dependency, so a CLI or CI job can reuse itpkg/
Identity providerHolds the OAuth client and the groups that may use it and read its secretAuthentik (Okta adapter planned)
VaultThe only place Courier stores a client secret; access granted by identity groupVault CE, KV v2 mount kv/
Team accessPer team: a service account and an External Secrets store that can read only that team's credentialsArgoCD courier-team-access-<team>, deploy/team-access
ConsumersPeople (Vault CLI), workloads (Python sample), Kubernetes apps (External Secrets)Use your credentials

Architecture

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.

  1. requester

    Adds requests/team-alpha/billing-sync.yaml and opens a pull request.

  2. GitHub Actions

    courier validate checks layout, object type, fields, the owner group, grant and redirect rules, redirect hosts and approval labels, then writes a summary table for reviewers.

  3. code owners

    A team-alpha code owner approves. Because the request uses client_credentials, the security-approved label is also required.

  4. GitHub

    Branch protection allows the merge once the check passes and the required reviews are in.

  5. ArgoCD

    The team's application syncs the file into namespace team-alpha. The project rejects anything that is not an OAuthClient.

  6. controller

    Re-applies the same validation, adds a cleanup finalizer and looks up team-alpha-billing-sync in the IdP.

  7. controller → vault

    Generates a 64-character secret in memory and writes it to kv/teams/team-alpha/oauth-clients/team-alpha-billing-sync with state: pending.

  8. 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-alpha use it.

  9. controller → vault

    Patches the entry to state: active with the client_id and endpoints, without reading or resending the secret.

  10. controller

    Sets Ready=True with the client ID and vault path in the object's status.

  11. 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.

ActorCanCannot
RequesterPropose a client for their teamMerge without approval; target another team's group or vault path
Team code ownerApprove their team's requestsChange the policy file or pipelines (identity team owns those)
CI checkRead the repository and reportWrite anywhere; it holds no credentials
ArgoCD (requests project)Create OAuthClient objects in team-* namespacesCreate 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 mappingsManage users, change group membership, alter login flows; modify apps it did not create
Team memberRead their own team's vault pathRead another team's path
Team's External Secrets storeRead its own team's vault path into a Secret in that namespaceRead another team's path; write to the vault (policy is read-only)
Where the secret exists

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.

Why each group appears twice

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 failsWhat happensWhat recovers it
Vault unavailable before the pending writeNothing is created in the IdP; status shows ReconcileFailedAutomatic retry with back-off
IdP unavailable after the pending writeA pending entry exists; no client existsRetry generates a new secret, overwrites the pending entry, creates the client
Controller stops between IdP create and promotionThe IdP uses the secret; the vault holds it as pendingNext reconcile rotates to a fresh secret and completes delivery
Status is lost (object recreated)Courier cannot confirm deliveryIt issues a new secret rather than trust an unknown state
Name already used by a hand-built appReady=False, reason NameConflict; nothing modifiedRename the request or retire the old app
An invalid request reaches the clusterThe schema rejects it, or status shows InvalidSpec; nothing createdFix the request in a new pull request
Vault pod restartsBrief unavailabilityAutomatic 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

DecisionWhy
Vault Community Edition for the proof of valueMatches the vault many organizations already run; only Community Edition features are used, and OpenBao remains compatible
A controller, not TerraformTerraform state would become a second copy of every secret
Kubernetes-free core, thin controllerAdopters without Kubernetes can reuse the same logic from a CLI or CI job