最新资讯
dev.to 我们在 DolphinBench 上测试记忆检索:纯关键词搜索暴露了自身回归 dev.to 别再把 Agent 写成 while 循环,把它建模为状态机 bloomberg.com Rightmove 与 Scout24 借 AI 产品应对低迷房市并上调展望 stackoverflow.blog 欢迎来到芯片时代:半导体制造、AI 数据中心与芯片设计的未来 www.youtube.com AI Agent 总犯同样的错:CentralAgent AI 将记忆重构为经验系统 pub.towardsai.net Netflix 科学家建议重采样 LLM 评判:5 次仅将误差棒缩小 13% pub.towardsai.net Jev 作为 LLM 前的静默层:构建提示感知的模型路由器 reddit.com OpenAI 称 Astra 仅满足 10% 安全任务标准 dev.to FreeToken:在自有 GPU 上运行前沿 MoE 模型及其内存需求 dev.to 当 AI Agent 说“完成”时,究竟完成了什么? dev.to 部署 Underdog Bark 3.0 Pro:逐字转写、自定义后端与分块设置 dev.to 为艰难对话构建 AI:五个 Agent、一条安全正则与一位盲评监督者
多元动态
登录 注册
智能体 dev.to · @Shola Jegede

用户离职后如何用 Kinde Webhook 自动撤销 Claude Agent 的访问权限

原文 我的收藏

作者构建了一个小应用测试场景:用户被停用后,其正在执行任务的 AI Agent 是否停止。实测显示 Agent 继续工作,因为停用或删除用户只改变 Kinde 对用户的判断,不会影响 Agent 已持有的访问令牌——该令牌在停用前已签发,仍能正常验证。文章据此讨论用 Kinde Webhook 自动撤销 Agent 访问权限的做法。

AI推荐理由 作者用 Kinde 构建测试应用,验证用户被停用后其 Claude Agent 仍持有效令牌继续执行任务,说明停用或删除用户不会回收已签发的访问令牌。属可复现的一手实验,对 Agent 权限生命周期与离职流程安全有实际参考,但为小型演示应用,未覆盖生产规模。

66

正文

Imagine this scenario, someone on your team gets offboarded while their AI agent is still mid-task. Nobody remembers the agent is even running. It's just doing what it was told, on behalf of someone who, as of a minute ago, doesn't work at your company or on your team anymore. Does it stop? Well, I built a small app to test that scenario, using Kinde to handle sign-in and to hold the record of who's still active. I signed a real user in, handed their agent a task, and while the agent was still working through it, suspended that same user, in Kinde's dashboard, mid-run, to see what the agent would do next. Unsurprisingly, the agent kept working on the task. You see, suspending or deleting a person changes how Kinde itself sees that user, but it doesn't touch the access token their agent is already holding, because nothing about a suspension reaches back into a token that was already signed and handed out before it happened. The token still verifies exactly as it did before the suspension, so the agent has no way to know anything changed. Everything I am going to talk about in this article is about closing that gap, and about what I actually found while doing it: webhook deliveries measured live, a production bug that could have left an offboarded user's record looking active forever, and a hard number for how long an offboarded person's agent keeps acting before anything catches it. Why a suspended user's token still works Let's start with what an access token actually is, because the whole gap follows from it. An OAuth access token isn't a receipt you hand back to check against a ledger. It's a signed claim, a small JSON payload with a cryptographic signature attached, and whatever's checking it just verifies that signature against a public key rather than calling home to ask if the token's still good. That's the entire appeal of the design: an API can confirm a token is genuine without a database round trip on every request. Which means suspending a user in Kinde only changes a row in Kinde's own database. It doesn't reach the token at all, because there's nothing there for it to reach: the token was already handed out, already signed, already valid until whatever expiry it was minted with. Revoking it properly would mean tracking every issued token in a lookup table somewhere, which throws away the entire point of signing one in the first place, or it would mean just waiting for the thing to expire on its own. I suspended a signed-in test user mid-session, and the app's own check kept reporting that user's access token as valid, seconds after Kinde had already suspended them. So that gap isn't a bug in Kinde, and it isn't a bug in OAuth either. It's just what a stateless credential is, by design, and the real question is what you build on top of it. The shape of the fix Two pieces close the gap. A webhook tells the app when Kinde's view of a user changes, and a check runs before every single agent action, reading the app's own record of that user instead of trusting whatever was true when the session started. The agent itself is a small Claude Messages API loop, working through a closed set of three tools against a demo set of internal resources: list_resources, read_resource, write_resource. None of what follows is specific to what the agent does. It's specific to the one place every tool call has to pass through before it's allowed to run at all. Building the seam First, we start with the registry that defines those three actions, because it's closed by construction rather than by convention. An action that isn't in this table doesn't half-exist somewhere in the code, waiting to be called by accident. It just doesn't exist: export const ACTION_REGISTRY: Record = { list_resources: { name: "list_resources", destructive: false, params: {} }, read_resource: { name: "read_resource", destructive: false, params: { resourceId: { type: "string", required: true } }, }, write_resource: { name: "write_resource", destructive: true, params: { resourceId: { type: "string", required: true }, title: { type: "string", required: false }, body: { type: "string", required: false }, }, }, }; Enter fullscreen mode Exit fullscreen mode Both the tool schema handed to Claude and the enforcement check are built from this same table, so the two can never quietly drift apart from each other the way a schema and a permissions list usually do once someone forgets to update one of them. Every tool call the model makes passes through a single function, enforceToolCall, which looks up the acting user's current status and hands it to a small, pure decision function underneath it: export function decideAccess(input: { mode: EnforcementMode; userStatus: UserStatus; }): { decision: SeamDecision; reason: SeamReason } { if (input.mode === "naive") { return { decision: "allow", reason: "naive_mode_no_check" }; } if (input.userStatus === "active") { return { decision: "allow", reason: "user_active" }; } if (input.userStatus === "offboarded") { return { decision: "refuse", reason: "user_offboarded" }; } return { decision: "refuse", reason: "user_unknown" }; } Enter fullscreen mode Exit fullscreen mode Naive mode allows every call without ever looking at that status, which is the vulnerability this whole piece is about, reproduced on purpose so both modes can run side by side against the exact same code and prove the point cleanly. Enforced mode is stricter in a way that matters: it allows exactly one case, a confirmed active user, and refuses everything else, including a status the seam couldn't even resolve because a read to Convex failed. An unknown status doesn't get the benefit of the doubt. That status comes from a webhook. Kinde sends a signed event on user.updated and user.deleted, the receiver verifies the signature, and then it does one more thing that has nothing to do with the signature at all: export function isFreshWebhookEvent( event: WebhookEvent, now: number = Date.now(), ): boolean { const eventTime = Date.parse(event.timestamp); if (Number.isNaN(eventTime)) return false; return Math.abs(now - eventTime) <= MAX_CLOCK_SKEW_MS; } Enter fullscreen mode Exit fullscreen mode A valid signature only proves Kinde signed this payload at some point. It says nothing about when that was, so without this check, a captured event replayed months later would sail straight past signature verification, since the signature itself never expires, and past deduplication too, because dedup only catches an event id it's already seen before. MAX_CLOCK_SKEW_MS is five minutes. Anything older than that gets rejected the same way a forged signature would. The bug the build actually found Hardening this receiver turned up a real ordering bug. The first version recorded the webhook's delivery, for deduplication, before it applied the actual effect of marking the user offboarded. That ordering has a quiet failure mode: if the effect write failed right after the delivery had already been logged, a retried webhook would look like a duplicate of one already handled and get skipped. The user would never actually get offboarded, and nothing about the system would ever try again. The fix took one line of reordering, but it only works because of one property underneath it: the effect, markOffboarded, is idempotent, so running it twice is always safe. That's why it now runs first, unconditionally, ahead of the bookkeeping whose entire job is to stop it from running a third or fourth time. Recording the delivery first and applying the effect second felt like the more natural order to write. It was also the less safe one. Proving it, live scripts/e2e-narrative.ts runs one task against one real Kinde test user, twice: once with the seam in naive mode, once enforced, suspending that same user for real, mid-run, both times. Nothing in this script is simulated. It drives a real agent loop, fires a real suspend call at Kinde, and waits on the actual webhook to arrive over a tunnel before it checks what actually happened. naive enforced actions allowed after offboarding 2 0 where the run stopped it didn't, ran to completion step 2, reason user_offboarded The same story shows up in the operator console, so I ran it once more while writing this, offboarding the signed-in user on purpose partway through a task: Step one lands while the user's still active. The offboard request goes out. Step two refuses, user_offboarded, with a cutoff latency of 2697ms measured from when the offboarding itself landed in the database, not from when the run started. Every one of those decisions lands in an audit log under a shared correlation id, so a run's full timeline can be pulled back up after the fact, not just watched live: The numbers that matter more than the demo Across every live webhook delivery in this build, latency ran from 652ms to 2058ms, across suspends, restores, deletes, and role changes alike. That's not instant. Most people assume it is. The enforcement check itself doesn't belong anywhere in that number. It's one indexed read against Convex, and next to webhook delivery it's close enough to free that it doesn't move the total. So the more honest way to describe total revocation speed is this: it's webhook delivery latency, plus however long until the agent gets around to its next real action, and that second part isn't a fixed system number at all. An agent moving faster, with no artificial pacing between steps or several tool calls requested in the same turn, gets caught just as fast on its very next call, without any extra mechanism needed to catch it sooner. The floor here is roughly one model round trip, not the enforcement check sitting underneath it. A webhook can also be missed or delayed, because that's what "best-effort delivery" actually means in practice, so I built a reconciliation sweep on top: a cron job checking every active user's live status directly against Kinde every five minutes. I tested it against a forced scenario: suspended a real user, then manually pushed the app's own record back to active, simulating a webhook that never arrived at all. The cron caught the drift and corrected it on its own, before I ever triggered a manual run. Here's what I think "Revocation" is the wrong word for what any of this does, and I think that matters more than it sounds like it should. Nothing here revokes anything. What actually happens is a live check gets bolted on top of a credential that was never revocable to begin with, and I'd rather describe it that way than pretend otherwise, because that's closer to what every system in this space is doing under the hood, whatever the marketing copy on top of it calls it. I'd also argue this check shouldn't be the thing you bolt on during a hardening pass once the demo already works, which is exactly the order it happened in during this build. The seam existed early on, but the reconciliation backstop and the timestamp check both came later, as production polish, and I think that ordering is backwards for anything that runs unattended. A browser session gets re-validated more or less by accident, on every page load, because there's a human sitting there generating new requests the whole time. An agent loop doesn't get that for free. It holds one credential, validated once at the start, and then it acts on that credential in a loop nobody's watching in real time. If the check isn't built into that loop from the first line, there's no accident later that adds it back in. The usual objection to this is cost: a database read on every single tool call sounds expensive once an agent's task means dozens of calls instead of one. But this build's own numbers say that objection doesn't hold up, at least not with an indexed lookup like the one here. The check disappears next to webhook latency, and webhook latency itself disappears next to how long a person actually takes to notice someone's gone and go click suspend. The genuinely expensive part of this whole system is the five to fifteen minutes between someone walking out the door and someone else noticing. Nobody optimizes for that number, even though it's the one that actually decides how exposed you are. What this doesn't solve The token-still-valid gap from the top of this piece is real, and nothing here closes it. This build works around it, by checking liveness on every call, instead of trying to make the token itself stop working. A system that actually needs the token revoked needs a different mechanism entirely, like short-lived tokens or an introspection endpoint hit on every use, and both of those trade away the exact stateless-verification benefit that made a signed token worth using in the first place. The reconciliation sweep is a five-minute backstop, not the primary path, and if the webhook's doing its job, the sweep never finds anything to correct. runs.timeline, runs.get, and the audit queries in this build are also unauthenticated Convex reads. A run id or a correlation id is enough on its own to read that run's whole timeline, which is fine for a single-user demo console and not fine at all for anything with untrusted users in it. And the action registry here covers exactly three read and write actions on a demo resource. That's enough to prove the pattern holds. It's nowhere near enough to prove the pattern scales to a real authorization model with real permission boundaries between real resources. Where this leaves the agent Back to the opening scene: someone gets offboarded, and their agent is mid-task. Whether its next few tool calls go through was never actually a question about the token. The token was always going to keep working right up until it expired on its own, offboarding or not, because that's just what it is. The real question was always whether anything standing between the agent and the action it's about to take bothered to check, right then, whether the person behind it was still around. Code and sources The full build, the enforcement seam, the webhook handler, the reconciliation cron, and scripts/e2e-narrative.ts, is on GitHub: sholajegede/offboarding-revocation-demo. The webhook signing and delivery model comes from Kinde's own webhooks documentation, and the suspend and restore actions used throughout the demo follow Kinde's Management API and its notes on suspending and deleting users. The reconciliation sweep runs on Convex's cron jobs, and the agent loop follows Anthropic's tool use documentation for the Messages API. Every number in this piece came from runs.timeline and auditLog in that same Convex deployment, read straight off the live runs. Clone it, wire it up against your own Kinde tenant, and offboard a test user mid-run. Your webhook latency might not match mine, your reconciliation sweep could catch different drift, and that's the point really. Drop your numbers in the comments and let me know what you find.

来源报道 · 1 篇 · 以下链接均前往原始媒体

← 返回精选动态 原文

更多内容