ai newsDo., 15. Okt.
EilmeldungMeta and Apple team up to make Muse AI models run better on Macs

Workday Is Buying Identity for AI Agents. Let's Build a Tiny Access Grant in TypeScript.

Most companies already know how to onboard a person. There's a start date. A manager. A laptop. A list of apps. There's also a last day. Access is supposed to end there. Agents are joining the same companies now. They need email, the CRM, a repo, sometimes finance. Here's the part I keep thinking a

dev.toMittwoch 02:14 · 14.10.22 Min. Lesezeit
Bild: dev.to

Most companies already know how to onboard a person.

There's a start date. A manager. A laptop. A list of apps.

There's also a last day. Access is supposed to end there.

Agents are joining the same companies now. They need email, the CRM, a repo, sometimes finance.

Nobody files a ticket for a bot's last day.

On October 13, 2026, Workday announced it has "signed a definitive agreement to acquire C1.ai, a leading agentic identity platform for people and agents." Workday says C1.ai connects to the systems a business runs on, "from email and CRM to code repositories, finance systems, and cloud infrastructure."

The release puts the agent part plainly:

Once registered, an agent can receive the access it needs, when it needs it, and lose that access immediately when its work or role ends.

C1.ai would build on the Workday Agent System of Record. Workday says that record "already gives companies one place to see and manage their agents."

C1.ai would go "beyond registering agents to also governing what they can access."

One more line from the release stuck with me:

An agent that was retired last quarter shouldn't still be able to get in

The deal hasn't closed. Workday expects it to close in the fourth quarter of its fiscal year 2027, which ends January 31, 2027, subject to customary closing conditions.

The same day, Workday made its Trusted Agent Platform headless. "Headless can't mean lawless," said CTO Gabe Monroy.

It also announced a partnership with Anthropic where Claude and Sana agents talk over MCP, and "every action follows the customer's existing permissions and audit controls in Workday."

The agent's access should follow the agent's lifecycle.

I've been waiting for this one. On September 19, in The Computer Is Becoming an API for AI, I wrote a short timeline. One line said:

2027: Agents get identities. Dedicated accounts, credentials, budgets, permissions and audit trails become normal.

A signed deal is not "normal" yet. But it's the right direction.

It's also a question I run into building Roster, where AI employees get real tools and real access.

npx tsx access-grant.ts Enter fullscreen mode Exit fullscreen mode And watch an agent get registered, get scoped grants, get denied above its role, lose finance on a role change, hit an expired grant, and lose everything the moment it ends.

This is my small model of the idea. It is not how Workday or C1.ai work inside.

The agents, roles, systems, scopes and times are example inputs. Everything runs in memory with a fake clock.

Most access today is a pile of separate decisions.

Someone files a ticket. Someone approves it. An app gets a token.

Then the work ends. The agent gets marked as retired somewhere.

The C1.ai release frames the question well: organizations "need to know who and what has access, why they have it, and when to remove it."

The first one is the easy one. The other two are where it gets hard.

Workday's answer is to tie access to lifecycle events. In its words, "every new hire, job change, and exit is a Workday event."

So in our tiny version, access moves with three events:

Plus one more: a grant can expire on its own.

A grant without a reason and an end is just a door left open.

Save the following TypeScript blocks in order as access-grant.ts.

// tiny-access-grant: lifecycle-bound access for registered AI agents. // A small model of the idea. Not how Workday or C1.ai work inside. // Agents, roles, systems, scopes and times are example inputs. No network.

No API key. type System = " email " | " crm " | " repo " | " finance " | " cloud "; type Status = " active " | " ended "; interface Agent { id: string; owner: string; // the person accountable for this agent role: string; status: Status; } interface Grant { agentId: string; system: System; scopes: string []; reason: string; grantedAt: number; // minutes since midnight on a fake clock expiresAt?: number; revokedAt?: number; revokeReason?: string; } interface Decision { allow: boolean; reason: string; } interface AuditEvent { at: string; kind: " register " | " grant " | " reject " | " check " | " revoke " | " role " | " end "; agentId: string; detail: string; } // The most each role may hold. A grant can be smaller, never bigger. const ROLE_CEILING: Record < string, Partial < Record < System, string [] >>> = { " sales-assistant ": { email: [ " read ", " send " ], crm: [ " read ", " update " ] }, " revops-analyst ": { crm: [ " read " ], finance: [ " read " ] }, " support-triage ": { email: [ " read " ], crm: [ " read " ] }, }; const hhmm = ( t: number ) => ` ${ String ( Math. floor ( t / 60 )). padStart ( 2, " 0 " )}: ${ String ( t % 60 ). padStart ( 2, " 0 " )} `; Enter fullscreen mode Exit fullscreen mode Every agent has an owner. That's the person accountable for it.

Every grant has a reason. That answers "why do they have it?"

expiresAt and revokedAt answer "when does it end?"

ROLE_CEILING is the most a role can ever hold. A sales assistant can send email and update the CRM. It never touches finance.

// Step 1: the agent system of record interface Store { now: number; agents: Map < string, Agent >; grants: Grant []; audit: AuditEvent []; } function newStore ( start = 9 * 60 ): Store { return { now: start, agents: new Map (), grants: [], audit: [] }; } function log ( s: Store, kind: AuditEvent [ " kind " ], agentId: string, detail: string ): void { s. audit. push ({ at: hhmm ( s. now ), kind, agentId, detail }); } function register ( s: Store, id: string, owner: string, role: string ): Agent { if ( s. agents. has ( id )) throw new Error ( ` ${ id } is already registered` ); if (! ROLE_CEILING [ role ]) throw new Error ( `unknown role ${ role } ` ); const agent: Agent = { id, owner, role, status: " active " }; s. agents. set ( id, agent ); log ( s, " register ", id, `owner= ${ owner } role= ${ role } ` ); return agent; } Enter fullscreen mode Exit fullscreen mode register is the only way in.

An agent that is not in the record can't get a grant. It can't pass a check either.

If it isn't registered, it doesn't exist.

// Step 2: grants that live inside the role function isLive ( g: Grant, now: number ): boolean { return g. revokedAt === undefined && ( g. expiresAt === undefined || now < g. expiresAt ); } function grant ( s: Store, agentId: string, system: System, scopes: string [], reason: string, ttlMinutes?: number, ): Grant | undefined { const agent = s. agents. get ( agentId ); const ceiling = agent? ROLE_CEILING [ agent. role ][ system ]?? []: []; const extra = scopes. filter (( x ) =>! ceiling. includes ( x )); const why =! agent? " not registered ": agent. status!== " active "?

" agent has ended ":! reason. trim ()? " no reason given ": extra. length? ` ${ system }. ${ extra. join ( ", " )} is above role ${ agent. role } `: ""; if ( why ) { log ( s, " reject ", agentId, ` ${ system } [ ${ scopes } ]: ${ why } ` ); return undefined; } for ( const old of s. grants ) { if ( old. agentId === agentId && old. system === system && isLive ( old, s. now )) { revoke ( s, old, " replaced by a new grant " ); } } const g: Grant = { agentId, system, scopes, reason, grantedAt: s. now, expiresAt: ttlMinutes === undefined? undefined: s. now + ttlMinutes, }; s. grants. push ( g ); const until = g. expiresAt === undefined? "": ` until ${ hhmm ( g. expiresAt )} `; log ( s, " grant ", agentId, ` ${ system } [ ${ scopes } ] ${ until }: ${ reason } ` ); return g; } function revoke ( s: Store, g: Grant, why: string ): void { g. revokedAt = s. now; g. revokeReason = why; log ( s, " revoke ", g. agentId, ` ${ g. system }: ${ why } ` ); } Enter fullscreen mode Exit fullscreen mode A grant gets rejected for four reasons.

The agent isn't registered. The agent has ended. There's no reason.

Or the scopes are above the role.

The important part is the ceiling check.

The caller can ask for less than the role allows. It can never ask for more.

A new grant for the same system replaces the old one. The old one gets revoked, with a reason, in the log.

ttlMinutes makes a grant time-boxed. That's the "when it needs it" part.

// Step 3: check on every action function checkAccess ( s: Store, agentId: string, system: System, action: string ): Decision { const d = decide ( s, agentId, system, action ); log ( s, " check ", agentId, ` ${ system }. ${ action } ${ d. allow? " ALLOW ": " DENY " }: ${ d. reason } ` ); return d; } function decide ( s: Store, agentId: string, system: System, action: string ): Decision { const agent = s. agents. get ( agentId ); if (! agent ) return { allow: false, reason: " not in the system of record " }; if ( agent. status!== " active " ) return { allow: false, reason: `agent ${ agent. status } ` }; const mine = s. grants. filter (( g ) => g. agentId === agentId && g. system === system ); const live = mine. find (( g ) => isLive ( g, s. now )); if (! live ) { const last = mine. at ( - 1 ); if (! last ) return { allow: false, reason: `no ${ system } grant` }; if ( last. revokedAt!== undefined ) { return { allow: false, reason: `revoked ${ hhmm ( last. revokedAt )}, ${ last. revokeReason } ` }; } return { allow: false, reason: `expired ${ hhmm ( last. expiresAt! )} ` }; } if (! live. scopes. includes ( action )) { return { allow: false, reason: `scope ${ action } not in [ ${ live. scopes } ]` }; } return { allow: true, reason: live. reason }; } Enter fullscreen mode Exit fullscreen mode The order matters.

First, is this agent in the record? Then, is it still active? Then, is there a live grant? Then, does the grant cover this action?

The deny reasons are specific on purpose.

"No finance grant" is a different problem from "revoked at 09:45, role changed."

"Expired at 11:00" is different again. A person debugging this later needs to know which one.

Every check goes in the audit log, allowed or not.

// Step 4: lifecycle events move access function changeRole ( s: Store, agentId: string, role: string ): void { const agent = s. agents. get ( agentId ); if (! agent || agent. status!== " active " ) throw new Error ( ` ${ agentId } is not active` ); if (! ROLE_CEILING [ role ]) throw new Error ( `unknown role ${ role } ` ); log ( s, " role ", agentId, ` ${ agent. role } -> ${ role } ` ); agent. role = role; for ( const g of s. grants ) { if ( g. agentId!== agentId ||! isLive ( g, s. now )) continue; const ceiling = ROLE_CEILING [ role ][ g. system ]?? []; const keep = g. scopes. filter (( x ) => ceiling. includes ( x )); if ( keep. length === g. scopes. length ) continue; revoke ( s, g, `role changed to ${ role } ` ); if ( keep. length ) grant ( s, agentId, g. system, keep, ` ${ g. reason } (trimmed for ${ role } )`, remaining ( g, s. now )); } } function remaining ( g: Grant, now: number ): number | undefined { return g. expiresAt === undefined? undefined: g. expiresAt - now; } function endAgent ( s: Store, agentId: string, why: string ): void { const agent = s. agents. get ( agentId ); if (! agent || agent. status === " ended " ) return; agent. status = " ended "; log ( s, " end ", agentId, why ); for ( const g of s. grants ) { if ( g. agentId === agentId && isLive ( g, s. now )) revoke ( s, g, `agent ended: ${ why } ` ); } } Enter fullscreen mode Exit fullscreen mode This is the heart of it.

changeRole walks every live grant and compares it to the new ceiling.

If a grant has scopes the new role can't hold, it gets revoked. Whatever is still allowed gets re-granted, trimmed, with the same expiry.

endAgent does two things in one call. It marks the agent ended. Then it revokes every live grant.

That second part is the one people skip.

Ending the agent and ending its access should be the same event.

To see why, here's the version most of us start with.

// Step 5: the ticket version, and who has access right now // The downstream app only sees grants. It never asks the system of record. function appSees ( s: Store, agentId: string, system: System, action: string ): boolean { return s. grants. some ( ( g ) => g. agentId === agentId && g. system === system && isLive ( g, s. now ) && g. scopes. includes ( action ), ); } // Ticket world: the agent is marked ended, but nobody files the access ticket. function endInRecordOnly ( s: Store, agentId: string, why: string ): void { const agent = s. agents. get ( agentId ); if ( agent ) agent. status = " ended "; log ( s, " end ", agentId, ` ${ why } (grants untouched)` ); } function whoHasAccess ( s: Store ): string [] { return s. grants. filter (( g ) => isLive ( g, s. now )). map (( g ) => { const a = s. agents. get ( g. agentId )!; const until = g. expiresAt === undefined? "": ` until ${ hhmm ( g. expiresAt )} `; return ` ${ g. agentId } ( ${ a. status }, owner ${ a. owner } ) ${ g. system } [ ${ g. scopes } ] ${ until }: ${ g. reason } `; }); } Enter fullscreen mode Exit fullscreen mode appSees is what a downstream app knows.

The email app doesn't call the system of record on every request. It sees a grant, or a token, and that's it.

endInRecordOnly is the ticket world. The agent is marked ended in one place. The access lives somewhere else.

whoHasAccess answers the release's question directly. Who, what system, which scopes, until when, and why.

// Step 6: run it function show ( s: Store, agentId: string, system: System, action: string ): void { const d = checkAccess ( s, agentId, system, action ); const verdict = d. allow? " ALLOW ": " DENY "; console. log ( ` ${ hhmm ( s. now )} ${ verdict } ${ agentId. padEnd ( 12 )} ${ ` ${ system }. ${ action } `. padEnd ( 14 )} ${ d. reason } ` ); } function at ( s: Store, t: string ): void { const [ h, m ] = t. split ( ": " ). map ( Number ); s. now = h * 60 + m; } const s = newStore (); console. log ( " == register and grant == " ); register ( s, " pipeline-bot ", " maya ", " sales-assistant " ); grant ( s, " pipeline-bot ", " email ", [ " read ", " send " ], " draft Q4 pipeline follow-ups " ); grant ( s, " pipeline-bot ", " crm ", [ " read ", " update " ], " log calls from follow-ups ", 120 ); grant ( s, " pipeline-bot ", " finance ", [ " read " ], " check deal discounts " ); grant ( s, " pipeline-bot ", " crm ", [ " read ", " delete " ], " clean up duplicates " ); for ( const e of s. audit. filter (( e ) => e. kind === " grant " || e. kind === " reject " )) { console. log ( ` ${ e. at } ${ e. kind. padEnd ( 6 )} ${ e. detail } ` ); } console. log ( " \n == check on every action == " ); at ( s, " 09:05 " ); show ( s, " pipeline-bot ", " email ", " send " ); show ( s, " pipeline-bot ", " crm ", " update " ); show ( s, " pipeline-bot ", " crm ", " delete " ); show ( s, " pipeline-bot ", " finance ", " read " ); show ( s, " ghost-bot ", " email ", " read " ); console. log ( " \n == role change == " ); at ( s, " 09:10 " ); register ( s, " close-helper ", " dev ", " revops-analyst " ); grant ( s, " close-helper ", " crm ", [ " read " ], " match invoices to deals " ); grant ( s, " close-helper ", " finance ", [ " read " ], " month-end invoice exceptions ", 240 ); at ( s, " 09:40 " ); show ( s, " close-helper ", " finance ", " read " ); at ( s, " 09:45 " ); changeRole ( s, " close-helper ", " support-triage " ); show ( s, " close-helper ", " finance ", " read " ); show ( s, " close-helper ", " crm ", " read " ); console. log ( " \n == time-boxed grant == " ); at ( s, " 11:05 " ); show ( s, " pipeline-bot ", " crm ", " update " ); show ( s, " pipeline-bot ", " email ", " send " ); console. log ( " \n == end the agent == " ); at ( s, " 11:30 " ); endAgent ( s, " pipeline-bot ", " Q4 follow-up project closed " ); show ( s, " pipeline-bot ", " email ", " send " ); show ( s, " pipeline-bot ", " email ", " read " ); grant ( s, " pipeline-bot ", " email ", [ " read " ], " one more thing " ); const last = s. audit. at ( - 1 )!; console. log ( ` ${ last. at } ${ last. kind. padEnd ( 6 )} ${ last. detail } ` ); console. log ( " \n == same ending, ticket world == " ); const t = newStore ( 11 * 60 + 30 ); register ( t, " pipeline-bot ", " maya ", " sales-assistant " ); grant ( t, " pipeline-bot ", " email ", [ " read ", " send " ], " draft Q4 pipeline follow-ups " ); endInRecordOnly ( t, " pipeline-bot ", " Q4 follow-up project closed " ); console. log ( `ticket world: record says ended, email app allows send? ${ appSees ( t, " pipeline-bot ", " email ", " send " )} ` ); console. log ( `lifecycle world: record says ended, email app allows send? ${ appSees ( s, " pipeline-bot ", " email ", " send " )} ` ); console. log ( " \n == who has access at 11:30 == " ); for ( const line of whoHasAccess ( s )) console. log ( line ); console. log ( " \n == audit == " ); const counts: Record < string, number > = {}; for ( const e of s. audit ) counts [ e. kind ] = ( counts [ e. kind ]?? 0 ) + 1; console. log ( Object. entries ( counts ). map (([ k, v ]) => ` ${ k } ${ v } ` ). join ( ", " )); for ( const e of s. audit. filter (( e ) => e. agentId === " pipeline-bot " && ( e. kind === " end " || e. kind === " revoke " ))) { console. log ( ` ${ e. at } ${ e. kind. padEnd ( 6 )} ${ e. detail } ` ); } Enter fullscreen mode Exit fullscreen mode Install and run:

npm install npx tsx access-grant.ts Enter fullscreen mode Exit fullscreen mode Output:

== register and grant == 09:00 grant email [read,send]: draft Q4 pipeline follow-ups 09:00 grant crm [read,update] until 11:00: log calls from follow-ups 09:00 reject finance [read]: finance.read is above role sales-assistant 09:00 reject crm [read,delete]: crm.delete is above role sales-assistant == check on every action == 09:05 ALLOW pipeline-bot email.send draft Q4 pipeline follow-ups 09:05 ALLOW pipeline-bot crm.update log calls from follow-ups 09:05 DENY pipeline-bot crm.delete scope delete not in [read,update] 09:05 DENY pipeline-bot finance.read no finance grant 09:05 DENY ghost-bot email.read not in the system of record == role change == 09:40 ALLOW close-helper finance.read month-end invoice exceptions 09:45 DENY close-helper finance.read revoked 09:45, role changed to support-triage 09:45 ALLOW close-helper crm.read match invoices to deals == time-boxed grant == 11:05 DENY pipeline-bot crm.update expired 11:00 11:05 ALLOW pipeline-bot email.send draft Q4 pipeline follow-ups == end the agent == 11:30 DENY pipeline-bot email.send agent ended 11:30 DENY pipeline-bot email.read agent ended 11:30 reject email [read]: agent has ended == same ending, ticket world == ticket world: record says ended, email app allows send? true lifecycle world: record says ended, email app allows send? false == who has access at 11:30 == close-helper (active, owner dev) crm [read]: match invoices to deals == audit == register 2, grant 4, reject 3, check 12, role 1, revoke 2, end 1 11:30 end Q4 follow-up project closed 11:30 revoke email: agent ended: Q4 follow-up project closed Enter fullscreen mode Exit fullscreen mode Here's what happened.

pipeline-bot asked for finance and for CRM delete. Both were above its role, so both were rejected at grant time.

At 09:05 it could send email and update the CRM. It couldn't delete records or read finance. It never got either one.

close-helper read finance at 09:40. At 09:45 it moved to a support role. Finance was revoked on the spot. CRM read stayed, because the new role still allows it.

At 11:05, the CRM grant from 09:00 had expired. Email still worked, because that grant had no expiry.

At 11:30, pipeline-bot ended. Every check after that says "agent ended." A new grant request was rejected too.

In the ticket world, the record says ended. The email app still allows send.

That's the retired agent from the release. A retired agent with live credentials is just a ghost with an API key.

In the lifecycle world, the same app says no. The revoke reached the grant.

The audit log kept all of it. Two registrations, four grants, three rejections, twelve checks, one role change, two revokes, one ending.

This is a demo. Real access is much messier.

The revoke has to reach every app. In this demo, appSees reads the same array as the record. That's cheating. Real apps hold their own tokens, sessions, and API keys. Revoking means calling each one.

"Immediately" is hard. A cached token or a long session keeps working until it expires. Short-lived credentials help. So does making apps check back.

Scopes are strings here. Real permission models are nested, per-object, and different in every system. "crm.update" hides a lot.

On-behalf-of agents need an intersection. The release says some agents "act on behalf of an employee and should only reach part of what that person can."

This model has an owner field. It never checks the owner's own access.

A real version should cap the agent at both the owner's access and the role.

There's no approval step. Grants here are instant. C1.ai describes "human approval" as part of its platform. A real grant for finance should probably wait for a person.

The audit log is in memory. Anyone with the store can rewrite it. A real one is append-only and stored somewhere the agent can't touch.

The role ceilings are hand-written. Someone has to decide them. That's a policy problem, not a code problem.

None of that changes the core idea. It just makes the plumbing bigger.

I think agent identity is going to look a lot like employee identity.

Not because agents are people. Because companies already run one process for "start, move, leave."

Lifecycle event (register, role change, end) ↓ System of record (who it is, who owns it, what role) ↓ Grants (system, scopes, reason, expiry) ↓ Check on every action ↓ Audit log (who had access, and why) Enter fullscreen mode Exit fullscreen mode The model provides reasoning.

Lifecycle events provide the off switch.

The audit log provides the answer when someone asks.

Workday buying C1.ai, if it closes, is a bet that this lives next to HR.

That's a big bet. I think it's the right instinct.

Agents are going to have first days. They should have last days too.

Access should start with a reason and end with the work.

I'm building Roster around this idea: AI employees with real responsibilities, tools, memory, schedules and computer access. Real access means it needs a clear start, a clear owner, and a clear end.

If the same follow-ups, handoffs, and waiting loops keep eating your week, give them to an AI employee.

Quelle: dev.to (dev.to). Original öffnen