All lessons › Function B — Securing AI Architectures › Securing the Architecture — Identity and Ingress
B2.5

The non-human identity lifecycle

B2
▶
Lightboard recording

Not recorded yet. The lesson below is complete and runnable today — the video is added later without touching this page.

Video placeholderpublishes automatically from recordings/B2.5.mp4
Risk

Agents accumulate with no owner and no expiry, and an unregistered agent joins a topology as a peer.

Control

A registry with a named owner, an expiry, and admission bound to a registered identity.

The lab

Admit agents against a registry and show an unregistered one refused at the door.

◉Use case relevance

Non-human identities already outnumber humans in most estates, and they sit outside joiner-mover-leaver entirely. Nobody ever leaves, so nothing is ever revoked, and last year's proof-of-concept still holds production write. The protocol that fixes it is one you already run for humans.

At CyberTravels. You add rotate, revoke and orphans() to the registry. Revoking the File System Agent stops its next token exchange, not its next restart. orphans() answers both directions — registered identities nothing runs, and running workloads nobody registered.

◴What this lesson is — Day 0, Day 1, Day 2

The non-human identity lifecycle: rotation, revocation that takes effect at the next call rather than the next restart, and finding orphans in both directions.

Day 0 — why

Agents accumulate with no owner and no expiry, and an unregistered one joins the topology as a peer.

Day 1 — how

A registry with a named owner, an expiry, and admission bound to a registered identity.

Day 2 — measure

Agents with an owner and a future expiry, as a share of those actually observed running.

Measure this

what to measuredenominator or target
agents with an owner and a future expiryshare of those actually observed running

▦The framework, and how it works

   humans                         non-human identities
   joiner -> mover -> leaver      created -> ... -> ?
      |                              |
   HR system drives it            nothing drives it
   revocation is automatic        nobody ever leaves

   the fix is to run ONE protocol over both:

     HR/IdP --SCIM--> POST   /Users     a person joins
                      POST   /Agents    an agent is deployed
                      PATCH  active:false   a leaver, or a retirement

   owner is a $ref to a User, not a string, so the leaver
   event that already exists is the one that retires the agent

Mitigates: T9 Identity Spoofing · T13 Rogue Agents · T3 Privilege Compromise.

Human identities have a lifecycle: someone joins, moves team, leaves, and an HR event drives the change. Non-human identities have none of that. They are created by whoever needed one, owned by nobody in particular, and removed never.

They also outnumber humans, often by a large multiple.

B1.11's rogue agent was admitted because the orchestrator had no notion of an approved agent. The control is a registry, and a registry is only useful if it carries three fields:

A named owner. A person, not a team alias. An identity with no owner cannot be renewed, questioned or revoked, because nobody is accountable for answering.

An expiry. Not for the credential — for the registration. It forces a recurring decision about whether this agent should still exist, which is the only mechanism that removes the ones nobody uses.

An admission binding. The registry entry names the workload identity from B2.2. Admission then checks the presented identity against the registry rather than checking a name the caller supplied.

That last point is what makes it a control rather than a spreadsheet. A registry consulted by name is documentation; a registry consulted by attested identity is an authorization decision.

The registry has to be driven by something, and that something is SCIM

A registry nobody updates decays into the spreadsheet it replaced. Humans do not have this problem, because their lifecycle is already automated by a protocol: SCIM — System for Cross-domain Identity Management, RFC 7643 for the schema and RFC 7644 for the protocol. The HR system creates a user, the identity provider POSTs it to /Users, a leaver event PATCHes {"active": false}, and every downstream application finds out without anybody filing a ticket.

Point the same protocol at agents and three things become true at once:

The joiner-mover-leaver machinery you already run is the machinery that governs agents. No parallel process, no second system of record. Deploying an agent means creating a SCIM resource; retiring it means one PATCH.

The owner is a reference, not a string. owner.$ref points at the SCIM User — so when Sam leaves, Sam's own leaver event is enough to answer "which agents just lost their owner", automatically, on the day it happens rather than at the next audit.

Orphans become a query. GET /Agents?filter=active eq true and owner pr false is a one-line answer to a question that is otherwise a quarter of someone's life.

What is and is not standard here. SCIM's protocol — the endpoints, the filter syntax, PATCH with active: false, the meta block — is RFC 7644 and your IdP already speaks it. A resource type for agents is not in RFC 7643; you declare it as a schema extension under your own URN, exactly as the enterprise extension does for manager. The protocol is standard, the schema is yours, and that split is the whole reason this is cheap to adopt.
What this control closes. Turns 'which agents are allowed here' from a convention into a check, and turns 'is this one still wanted' from an audit question into a leaver event. Closes B1.11 at the door.

2 · Provisioning an agent the same way you provision a person

system of record
🏢 HR / IdP
the one place a joiner, mover or leaver is recorded
›
scim protocol
👤 POST /Users
a person joins
🤖 POST /Agents
an agent is deployed — same protocol, your own schema URN
⛔ PATCH active:false
a leaver, or a retirement. One call, and every consumer finds out
NOT DELETE — THE RECORD SURVIVES
›
agent registry
📜 the registry
owner is a $ref to a User, not a string. Expiry is on the registration, not the credential
›
admission
🛡️ the orchestrator
checks the attested SPIFFE ID against the registry, and resolves the owner ref before it admits anything
R2
The point of using SCIM rather than a table is the middle column: the leaver event that already exists is the one that retires the agent, and the owner reference is what makes it cascade.

3 · Admission, and the two entries that fail it

4 · Sam leaves. One SCIM PATCH, and what it does not reach

This is the part a registry without a protocol behind it gets wrong. Sam's leaver event fires correctly — his own account is deactivated the same afternoon. The agent he deployed keeps running.

5 · The check, as a skill

cybertravels-svc was created for a proof of concept in March and is still authenticating. The skill provisions agents as SCIM resources with an owner reference, then runs Sam's leaver event and re-runs admission — because a reference can be followed and a name in a text field cannot.

▶Real time execution as skill

The skill — skills/identity/nhi-lifecycle-audit/SKILL.md

name: nhi-lifecycle-audit
description: >-
  Audit non-human identities as provisioned resources with a human owner
  reference — checking admission, orphaning, lapse, and whether a leaver event
  on the owner actually removes the agent's access. Use when agents outlive the
  people who created them, or when asked who owns a service account.
allowed-tools: Read, Grep, Glob

An agent identity with no owner is a permanent one

Non-human identities are created for a proof of concept and are still authenticating two years later. The lifecycle question is not "does it exist" but "what event removes it", and the answer that works is an owner reference resolved at admission — so the leaver process you already run does the work.

When to use this

Any estate with service accounts, agent identities or machine credentials — particularly where agents are created programmatically, or where a proof-of-concept identity was never retired.

Procedure

1 — Enumerate the identities as resources. Name, created-at, last-used, and the schema they are provisioned under. SCIM (RFC 7643/7644) is the interface most estates already have; use it rather than inventing a registry.

2 — Require an owner reference, not an owner string. owner.$ref pointing at a User resource resolves; a name in a text field does not. The difference is whether a leaver event can be followed.

3 — Classify every identity against admission. Four outcomes and all four must be exercised: admitted, unregistered (exists, nobody provisioned it), orphaned (owner reference does not resolve), lapsed (unused past its window).

4 — Run a leaver event. A single PATCH {active: false} on the owner. Then re-run admission. Any agent whose owner is now inactive must be refused on the next presentation — not swept overnight, refused.

5 — Report the sweep as a control, not a report. A list of orphaned identities that nobody acts on is a recurring finding. Name the automatic consequence.

Example

Input — the fixture committed at the top of scripts/nhi_lifecycle_audit.py. Edit it and re-run: the buckets, counts and verdicts below are derived from it, not hard-coded.

Output — the opening lines of a real run:

POST /scim/v2/Agents
{
 "active": true,
 "displayName": "pricing-agent",
 "externalId": "spiffe://cybertravels.com/ns/prod/sa/pricing-agent",
 "id": "b7f3a1c2",
 "meta": {
  "created": "2026-01-14T09:02:00Z",

The run continues past this. test_skills.py executes the script on every build, with no model configured — so what CI proves is that it runs and refuses legibly, not that it prints these lines. The output above is a recorded run against a real model, and nothing re-diffs it: a model's answer is not reproducible, and this repository does not claim otherwise anywhere it can be checked.

Output contract

{
  "identities": [{"name": "str", "owner_ref": "str|null", "last_used": 0,
                  "verdict": "admitted|unregistered|orphaned|lapsed"}],
  "owner_resolution": {"kind": "reference|string", "resolvable": false},
  "leaver": {"event": "str", "agents_refused_after": ["str"], "on_next_presentation": true},
  "sweep": {"window_days": 0, "automatic_consequence": "str"}
}

Failure modes

  • An owner recorded as text. It cannot be joined to the leaver process, so it is documentation.
  • Sweeping instead of refusing. A nightly job leaves a window; admission does not.
  • Counting unregistered as orphaned. They have different owners and different fixes.

Three ways to do this lesson. Pick one. They run the same procedure against the same code — what changes is who drives it, and how much you see.

1In your AI agent — you are taught

The agent reads the lesson, explains the idea, fetches the code, runs the check and answers your questions as you go. This is the one to take if you are here to learn.

Install it, once. From the folder you cloned. --all does every agent it finds on your machine:

python3 scripts/install_skills.py --all --lessons B2.5

Then restart your agent and open it in that folder. On Windows, if python3 says Python was not found, use python instead.

Then pick b2-5-the-non-human-identity-lifecycle:

agentinstall just for this agentstart it
Claude Codepython3 scripts/install_skills.py --tool claude --lessons B2.5type /b2-5-the-non-human-identity-lifecycle
GitHub Copilot (VS Code)python3 scripts/install_skills.py --tool copilot --lessons B2.5type /b2-5-the-non-human-identity-lifecycle in Chat
Cursorpython3 scripts/install_skills.py --tool cursor --lessons B2.5type / in Agent chat and search for it
Codexpython3 scripts/install_skills.py --tool agents --lessons B2.5type $b2-5-the-non-human-identity-lifecycle, or run /skills

2As one Python command — you just run it

No agent, no conversation. The same lesson executes and prints the same readback. Take this one if you have no assistant, if you are reviewing rather than learning, or if you want the result without the teaching around it.

python3 scripts/lesson.py B2.5

3Read the code, and the difference — nothing runs

Neither of the above, and no model needed. The first command writes CyberTravels exactly as it stood at the end of this lesson; the second prints the diff — the lines this lesson added or changed, and nothing from any later one.

python3 scripts/checkpoint.py --at B2.5 --out work/cybertravels
python3 scripts/checkpoint.py --at B2.5 --diff

On Windows, type python wherever these say python3 — and use \ instead of / in the paths. Everything else is identical. A0.0 step 2 explains why.

Ways 1 and 2 need a model; way 3 does not. A signed-in Claude Code CLI needs no API key, and any OpenAI-compatible endpoint works too. With neither, the audit exits 2 and says so rather than inventing an answer. A0.0 sets this up.

Ways 1 and 2 both end with the same readback: What I did, What changed, The number and Read this next. A step that was skipped or refused is reported as exactly that, never as a pass.

If you did the last lesson’s challenge, expect this to stop. Most challenges ask you to edit the code, and ways 1 and 2 will not overwrite your edits: you get stopped, not a failure, with two options — --out for a fresh folder that keeps your work, or --force to discard it and start this lesson clean.

✓What you just proved

An agent is provisioned as a SCIM resource whose owner is a $ref to a User. Four agents present identities and one is admitted — unregistered, orphaned and lapsed are all refused. Sam's leaver event is a single PATCH {active: false} on /Users, and because admission resolves the owner reference, his agent is refused on the next call. The orphan query returns one result.

✎Your turn

Count your non-human identities and how many have a named human owner that resolves to a live account. The difference is the set nobody can revoke during an incident, because nobody can be asked whether it is still needed. Then check whether your IdP's SCIM connector can carry a custom resource type — most can, and it is usually a configuration rather than a project.

Tools used

SCIM 2.0 (RFC 7643/7644)KeycloakSPIFFE/SPIRE

Skill source: skills/identity/nhi-lifecycle-audit

← PreviousB2.4 · Just-in-time authority