ServiceAccount

A non-human identity your CI or automation becomes — with no stored secret: it trusts an external issuer, not a password.

A ServiceAccount is an identity that is not a person: the thing a CI pipeline, a scheduled job, or a piece of automation signs in as. You create it, scope it with ordinary RBAC, audit it, and revoke it.

apiVersion: core.ringleader.dev/v1
kind: ServiceAccount

The important part is what it does not hold: there is no password, no API key, no long-lived token stored anywhere. A service account holds a trust list — which external identities are allowed to become it. A GitHub Actions job proves who it is using the identity token GitHub already mints for it, Ringleader checks that proof against the trust list, and issues a short-lived token in return. Nothing to leak, nothing to rotate, nothing to paste into a secrets store.

Its subject is sa:<namespace>/<name> — always namespace-qualified, so two teams can both have a ci without them being the same identity anywhere.

The two objects

Inbound federation takes two objects, authored at two levels:

ObjectScopeWho authors itWhat it says
IdentityProvidercluster-scoped, belongs to one organ org-admin“tokens signed by this issuer may become identities here” — the trust anchor
ServiceAccountnamespacedanyone who can create one in the namespacethese specific external identities may become me

The split matters: registering an issuer is a tenancy decision for the whole organization, while deciding that one particular repository may become one particular robot is a day-to-day decision inside a namespace.

IdentityProvider

The trust anchor. It names the external issuer and the ceiling of what a trust rule may pin.

apiVersion: core.ringleader.dev/v1
kind: IdentityProvider
metadata:
  name: acmecorp-github     # must be, or start with, the owning org's name
spec:
  org: acmecorp
  profile: github-actions
  allowClaims: ["environment", "job_workflow_ref"]
FieldTypeDescription
orgstringRequired. The owning organization.
profilestringRequired. The ecosystem this provider represents: github-actions, gitlab-ci, gcp, aws, azure, or custom.
issuerstringThe issuer URL. Required for custom, and for any profile whose issuer is per-tenant or per-instance (self-hosted GitLab, Azure, an EKS cluster). Fixed by the profile otherwise — a conflicting value is refused rather than silently overridden.
audiencestringThe audience a trust rule may name. Optional: the deployment already publishes the audience it answers to. A value here may only narrow within what the deployment accepts.
requireClaims[]stringExtra claims every rule under this provider must pin, on top of the profile’s own mandatory ones. It can only tighten. Required (non-empty) for custom.
allowClaims[]stringClaims a rule may pin beyond the mandatory ones. It never restates the mandatory claims.
enabledboolMaster switch for the whole trust anchor. Absent means enabled; false refuses every exchange through it.

Two rules to know before you write one:

  • Its name must be the owning org’s name, or start with it followed by a hyphen. The kind is cluster-scoped, so every org wanting a provider called github would collide: acmecorp-github or acmecorp, never a bare github.
  • Only an org-admin can author one. A namespace member has no access to it at all. If you signed up on your own, you already administer your personal organization, so you can register one for yourself — see Organizations.

Profiles

A profile records one ecosystem’s issuer, how audiences behave there, and — most importantly — the claims a rule must pin to name a stable identity. You cannot pin fewer; you may always pin more.

ProfileA rule must pinNotes
github-actionsrepository_owner_id and repository_idHarden further with a GitHub Environment that has required reviewers (the environment claim) and/or job_workflow_ref for a reusable workflow.
gitlab-cinamespace_path or project_pathHarden with a protected environment (environment + environment_protected) and/or ref_protected.
gcpemail or subThe audience is chosen by the caller on the metadata server’s identity endpoint.
awssubThe audience is set on the projected service-account token.
azuretid, and oid or subThe audience is a registered resource rather than a URL of ours, so the org registers which resource value the deployment accepts.
customnothing built in — so requireClaims must name at least one claimAlso requires an explicit issuer.

Run rl serviceaccount trust --help for the same table as your deployment sees it.

ServiceAccount

apiVersion: core.ringleader.dev/v1
kind: ServiceAccount
metadata:
  name: ci
  namespace: dev
spec:
  description: Builds and tests the platform repo
  trust:
    - provider: acmecorp-github
      match:
        repository_owner_id: "1234567"
        repository_id: "7654321"
        environment: production
FieldTypeDescription
descriptionstringWhat this identity is for. Worth writing — it is what a reader sees months later.
enabledboolMaster switch. Absent means enabled; false refuses every sign-in immediately, without deleting the account or its permissions.
trust[]objectThe trust list. See below.

Trust rules

Each entry of spec.trust is one external identity allowed to become this account.

FieldTypeDescription
providerstringRequired. The IdentityProvider this rule trusts.
audiencestringOptional. Narrows the accepted audience for this rule within what the deployment accepts.
matchobjectRequired. The claims this rule pins, as exact values.

The matching rules are deliberately blunt:

  • Every claim in a rule must match (AND) for that rule to admit.
  • Any rule matching admits (OR), so several pipelines can share one account.
  • Matching is exact string equality. No prefixes, no wildcards, no regular expressions — an authority boundary is not a place for pattern matching.

Add a rule with the CLI rather than by hand if you like — a rule that pins too few claims for its provider’s profile is refused either way:

rl serviceaccount create ci
rl serviceaccount trust ci --provider acmecorp-github \
    --claim repository_owner_id=1234567 \
    --claim repository_id=7654321 \
    --claim environment=production

Giving it permissions

A service account holds no rights of its own. Rights come from ordinary role bindings naming its subject, exactly as they would for a person:

apiVersion: core.ringleader.dev/v1
kind: RoleBinding
metadata:
  name: ci-can-build
  namespace: dev
spec:
  subjects: ["sa:dev/ci"]
  roleRef:
    kind: GlobalRole
    name: namespace-member

You can never bind an account to more than you hold yourself, so an account is bounded by its author’s own access — before and after it is created.

Status fields

The system writes these.

FieldTypeDescription
lastAssumedAtstringWhen something last signed in as this account. Absent if nothing ever has.
lastAssumedByobjectWho did: {provider, ruleIndex, claims} — the trust anchor, which rule admitted them, and the identity claims worth showing.
authorityReasonstringWhy the account has been suspended, when it has. Absent on a healthy one.
conditions[]objectCarries Unused — see below.

The Unused condition

Every account publishes an Unused condition, answering “should someone look at this?”. Its reason says which of four situations it is:

ReasonStatusMeaning
RecentlyUsedFalseUsed within the last 30 days. Healthy.
NotYetUsedFalseNever used, but created within the last 30 days — a pipeline that has not run yet looks exactly like this, so it is not flagged.
NotUsedRecentlyTrueUsed, but not for over 30 days.
NeverUsedTrueNever used at all, and created over 30 days ago — the most suspicious of the four: an identity nothing has ever needed, still usable.

Flagging is all it does. Nothing is ever auto-disabled or auto-deleted at any threshold — a quarterly release pipeline is not abandoned, it is quarterly. When an account genuinely is finished, set enabled: false or delete it.

The window is a fixed 30 days. It is not configurable, per account or per deployment: a threshold the owner of a forgotten account could raise until the flag stopped firing would not be worth publishing.

Usage is *recorded*, not inferred

The condition says no use has been recorded, which is not the same as asserting none happened. Accounts that predate usage recording start out looking unused until something signs in as them.

Suspension

An account is suspended when the person who created it no longer has permission to create one in that namespace. It stops being usable immediately, without being deleted, and status.authorityReason says so in as many words. An administrator can restore the creator’s access, or the account can be recreated by someone who does have it.

This is checked continuously, not just at creation — which is what makes “a service account never carries more than its creator could grant directly” stay true after the fact.

Listing and inspecting

rl serviceaccount get (alias sa) answers “which identities is nothing using?” directly:

rl serviceaccount get
NAME        LAST USED   STATUS      AGE
ci          14m         Ready       62d
nightly     41d         Unused      95d
old-robot   NEVER       Unused      210d
legacy-ci   3d          Disabled    1y
  • LAST USED is how long ago something last signed in, or NEVER.
  • STATUS is one word, most-blocking first: Suspended (the platform withdrew it), Disabled (you switched it off with enabled: false), Unused (the condition above is flagged), else Ready.

rl serviceaccount describe ci adds the full status, including who last assumed the account and under which rule. Sign-ins are also recorded as events in the namespace, so rl event get shows an Assumed entry naming the provider and rule — and an AssumptionRefused warning when something proved a real identity from a provider your org registered but matched none of your rules, which is usually a rule that needs updating.

Signing in as one

From a CI job that has an ambient workload identity, log in with no secret at all:

# .github/workflows/build.yml
permissions:
  id-token: write     # required — this is what lets the job mint its own assertion
rl auth login https://ringleader.example.com \
    --service-account dev/ci \
    --provider acmecorp-github

The CLI asks the job’s platform for an identity assertion, presents it, and gets a short-lived token back. Nothing is stored on disk that is worth stealing: what it keeps is the recipe (which origin, which provider, which account), which is useless without an ambient identity only that runner can produce.

FlagDescription
--service-account <ns>/<name>Log in as this account by exchanging the job’s own workload identity.
--provider <name>Required with --service-account. The IdentityProvider to exchange against.
--audience <value>The audience to request the assertion for. Defaults to the audience the control plane advertises for itself, so you normally never set this — pass it only for a migration, or a hostname the operator accepts in addition to the canonical one.
--register-nodeAlso register this device’s node identity. Off by default: an ephemeral runner’s registration would add its key to every workstation the account owns and re-push their configuration. Turn it on for a long-lived robot on a fixed host.

This only works where an ambient workload identity exists. On a laptop there is nothing to exchange, so use the ordinary rl auth login.

A refusal never says why

Every rejected sign-in gets the same opaque answer, whatever went wrong — no such account, switched off, no rule matched, a claim mismatch. That is deliberate: the endpoint is unauthenticated, and a specific error message would let anyone probe another tenant for the names of their accounts. The detail you need is on the account’s own status and in its events, both of which you can already read.

Turning one off

Three levers, from narrowest to widest:

  1. Delete a trust rule — that external identity can no longer become the account; the others still can.
  2. spec.enabled: false on the account — nothing can become it, but the object and its permissions survive, ready to be switched back on.
  3. spec.enabled: false on the IdentityProvider — every account trusting that issuer stops working at once. The break-glass switch for a compromised issuer.

Deleting the account removes it entirely. All four take effect on the very next sign-in attempt, not on a cache’s schedule.

See also