Field-level authorization for AI agents, enforced in-process

4 min#claude-code#agents#architecture

I built an internal MCP server at KnowBe4 that gives Claude read access to curated business data: accounts, tickets, product usage, retention signals, twenty entity kinds in all. The interesting design problem wasn't retrieval. It was authorization, because the callers aren't all the same person with the same clearance, and the wrong answer here isn't "access denied," it's a model confidently reasoning from data it should never have seen.

Why row-level access control was the wrong shape

The obvious pattern is row-level: a caller either can or can't see an entity. I didn't build that. Every entity is section-gated instead: a customer account, a support ticket, a usage record, each one is grouped into named sections (basic profile data, PII, financial figures, internal enrichment notes) and authorization decides which sections come back, not whether the entity comes back at all. An unprivileged caller asking about an account still gets an account. They get fewer fields.

That distinction matters more than it sounds like it should. A model that gets a flat "not found" has no way to reason about what it's missing or ask a better follow-up question. A model that gets a real entity with a financial section absent knows the account exists and knows exactly where the boundary is. It can tell a person "I can see this account but not its revenue figures" instead of "I found nothing," which is a materially different and more honest answer.

The tradeoff is a real one and worth stating plainly: a typo in a section name creates a section authorization has no rule for, and an unruled section defaults to denied. That's the safe failure mode, but it means a naming mistake silently hides data rather than throwing an error you'd notice. Worth a test that walks every declared section against every declared policy and fails loud on an orphan.

Why the policy engine runs in-process

I looked at three ways to make the authorization decision. A hand-rolled Rust RBAC layer, which is fast but unauditable by anyone who isn't reading the source. A policy engine running as a sidecar, which adds real latency to every call. AWS Verified Permissions, which is a network call and a control-plane dependency on every single field-level decision an agent makes.

I went with Cedar, in-process. Same binary, same call stack, no network hop, and the policies themselves are declarative and readable by someone who isn't a Rust engineer. One consequence I didn't fully appreciate going in: a malformed policy file is a startup-time panic, not a runtime surprise. That's deliberate. I'd rather the service refuse to start on a bad policy than serve partial or wrong authorization decisions while someone debugs it live in production.

Roles, not scopes

The first version of this used scope-based tiers, four of them, and it didn't hold up. Scopes tell you what a caller is allowed to do in the abstract. They don't map cleanly onto how a real organization actually classifies its data, which in this case turned out to need eleven distinct classification tiers keyed to actual org roles, not to an API scope someone picked when they wired up the integration. Replacing scope-based tiers with role-based classification was a rewrite, not a patch, and it's the version that's held.

One thing worth naming from the transport side: this server sits behind a private, internal-only network path. A caller reaching it from outside the network simply cannot connect, full stop, before authorization is ever evaluated. I still gate tools/list and tools/call at the application layer anyway. Belt and suspenders isn't redundant when the two layers can fail independently, and a misconfigured network boundary is exactly the kind of thing you want a second, unrelated layer to catch.

What I'd tell someone building this today

Decide your gating granularity before you decide your policy engine. Row-level versus section-level isn't an implementation detail, it changes what kind of question an agent can ask and what kind of honest partial answer it can give back. Run the policy check in the same process as everything else if you can afford to; a network hop on every field-level decision is a cost you will pay on every single call an agent makes, and agents make a lot of calls. And build the test that catches an unruled section before it ships, because the safe default (deny) is also the silent one.

More in AI

All in AI