Row, Field, and Type Security for AI Agents
Per-principal row, field, and type security on the entity model — enforced centrally so it covers dashboards, the API, find_records, and every agent tool.
The moment an AI agent can read your business data, "the agent can see everything" stops being a convenience and becomes a liability.
A support agent that can pull up any customer's record. A recruiter agent that can read every candidate's salary expectation. An autonomous task that runs as an admin and quietly bypasses the row filters your dashboard so carefully enforces. None of these are hypothetical — they're the default behaviour of almost every agent framework, because the framework's job ends at "call the model" and authorization is left as an exercise for the reader.
So you wire it yourself. You add a WHERE org_id = ? here, a field redaction there, a per-tool permission check somewhere else. Then you add a second channel — voice — and discover the checks only lived in the chat path. Then an agent calls a tool off a background thread and the caller context is gone. AI agent access control done per-call-site is access control you will eventually get wrong.
Matrix takes the opposite stance: authorization is a property of the data layer, not the agent. You author a policy once, against the entity type, and it holds for every reader — a human on the dashboard, a script hitting the REST API, the agent's find_records tool, and any other tool that touches the same data.
Four dimensions, per principal, per type
Every actor in Matrix — human or agent — is a principal. For each entity type, a principal's access is described along four independent dimensions:
- Type visibility — can this principal see this type at all? A
nullgrant means the type is invisible: it never appears in lists, and a direct lookup is denied. - Row filter — a mandatory
WHEREclause that is always AND-ed onto reads. This is the "parent filter": the principal can never read a row that fails it, no matter which endpoint or tool does the reading. - Field masks — which fields are readable and which are writable. A recruiter might see a candidate's name and stage but never their compensation field.
- Op rights —
create/update/delete, independently. Read-only access is just all three turned off.
These compose. A grant can say "you may see Lead rows assigned to your team (row filter), read everything but the internalNotes field (field mask), and update but not delete them (op rights)." That single statement constrains the dashboard list, the API response, and the agent tool identically.
Enforced centrally, so there's no per-caller wiring
The reason this is trustworthy is where it runs. Every read and write in Matrix flows through one component — EntityManager — and that's where enforcement lives. There is no second place to forget.
Concretely, central enforcement means a single authored policy covers all of:
- the admin dashboards (the operator UI lists),
- the generic
/api/entitiesREST surface, find_records— the agent tool that lets a model query your data,- and every other agent tool that reads or writes entities.
find_records is the one worth dwelling on. It's the tool that turns an agent into something that can answer "what's the status of order 4471?" — which is exactly the tool you'd lose sleep over. It needs zero access-control code of its own. It runs against the same scoped EntityManager as everything else, so it inherits the acting principal's row filter and field masks for free. The agent literally cannot query its way around the policy, because the policy is compiled into the query.
That last point is mechanical, not aspirational. Each row-filter condition compiles to both a Cypher WHERE fragment (spliced into read queries, including raw-Cypher aggregate reads) and a Java predicate (for single-row checks and mutations). Reads, existence checks, and writes all enforce against the same condition — there's no path where the SQL-equivalent says one thing and the in-memory check says another.
The policy object: a GrantSpec
A principal's grant for a type is a small JSON object stored in Membership.grantsJson. Here's a recruiter restricted to their team's leads, with a masked compensation field and no delete rights:
{
"Lead": {
"rowFilter": [
{ "field": "assignedAgent", "op": "in", "value": "$selfAndTeam", "ref": true },
{ "field": "stage", "op": "in", "value": ["NEW", "CONTACTED", "QUALIFIED"] }
],
"readFields": ["*"],
"writeFields": ["stage", "notes"],
"canCreate": true,
"canUpdate": true,
"canDelete": false
}
}
A few things to notice:
rowFilterconditions are all AND-ed. The condition language supportseq,in,contains(case-insensitive substring),range({min,max}),isNull, andself(the row's own node id equals the principal — "my own record").- Bindings resolve per principal at query time.
$selfis the principal's id;$selfAndTeamis the principal plus its teammates. The same GrantSpec, handed to two different recruiters, scopes each to their own team automatically. ref: truemarks an entity reference (a relationship) rather than a scalar property, soassignedAgentmatches by referenced node id.- A
rowFilterofnullmeans the type is not accessible at all — that's how you hide a type entirely.
You don't write Cypher. You author conditions, and the engine turns them into the query.
Defaults that fail safe — and stay out of your way
Policy is only useful if the defaults are sane. Matrix's are tuned so that you only ever author grants for what you want to restrict — everything else has a sensible default:
| Principal | Behaviour on an un-configured type |
|---|---|
| Operator (org member / admin / owner) | Open — match-all. Your existing dashboard users keep working. |
| Contact (an end-user on chat or voice) | Closed — except a built-in self-grant to their own User record, so an agent can still run update_contact_profile and lookup_contact_details for the person it's talking to. |
| Agent | Open until you author grants. |
So a contact can never enumerate other contacts, but the agent talking to them can still personalize the conversation. And you never have to write a grant for the ninety types you don't care about — you write grants for the two or three that hold sensitive business data.
A future per-org strict mode that flips operators to deny-by-default is reserved but not yet wired — today, operators default open. If your threat model needs operators locked down too, that's a known gap, not a claim we're making.
Business data is the surface; plumbing is exempt
There's a category of types you absolutely do not want a row filter on — the ones that keep auth, tool-loading, chat, voice, and campaigns running. If enforcement applied to Session, Message, Memory, Tool, Agent, or LlmProvider, you'd break the platform trying to secure it.
So those are exempt — always allowed. The full exempt set is config/infra and operational plumbing: Tool, McpServer, Skill, SkillFile, Agent, LlmProvider, TelephonyProvider, EntityType, Membership, Team, Organization, Place, Knowledge, KnowledgeChunk, Session, Message, Memory, Task, TaskRun, AuditEvent, and a few more.
Enforcement therefore focuses where it belongs: business data — User, Lead, and any org-defined custom type like Candidate, Order, or Course. Platform-admin and system/seed contexts always bypass, so boot, migrations, and the schema admin are never blocked by a policy.
It applies to agents because agents are principals
Two paragraphs of this post would be hollow if agents could route around the policy. They can't, because an agent is a principal, and its scope depends on Agent.mode:
AUTONOMOUS— the agent runs under its own grant. A background task can't quietly inherit admin and read everything; it sees exactly what its grant allows.INTERACTIVE(the default) — the agent's grant is intersected with the on-behalf-of caller's grant. The consequence is the rule you actually want: an agent can never surface, through the model, data the human in the conversation couldn't have seen themselves.
This holds across channels — the chat path, the browser-voice tool proxy, and telephony all run tools under the proper contact-plus-agent context rather than a platform-admin bypass. The hierarchical composition that makes this safe (a child principal can never exceed its parent) is its own topic; see Hierarchical Grants: A Child Can Never Exceed Its Parent for how effective access AND-composes up the reporting chain. And the agent-as-principal model — why "INTERACTIVE = agent ∩ caller" is the right default — gets the full treatment in Agents as Principals.
Two switches, and a rollout you can reverse in one call
This is real authorization, so it ships off and turns on deliberately. There are two switches, and both must be true for enforcement to apply to an org:
| Switch | Where | Default |
|---|---|---|
| Master kill-switch | env MATRIX_ACCESS_ENABLED | platform-level |
| Per-org opt-in | Organization.accessEnabled | false |
A tenant behaves exactly as before — operators see and edit everything — until an owner opts in. The safe path:
- Deploy. Nothing changes; no org has opted in.
- Enable one org:
PUT /api/orgs/{slug}/access { "enabled": true }(owner-gated). - Add a restricted member and log in as them. Their lists,
/api/entitiesresults, andfind_recordscalls return only in-scope rows with masked fields; out-of-scope lookups, updates, and deletes are denied. The owner still sees everything. - Confirm contact chat, voice, and campaigns are unaffected — those types are exempt, and contacts keep their own record.
- If anything looks wrong, flip it back instantly:
PUT … { "enabled": false }, or pull the master env flag to disable enforcement everywhere at once.
You enable access control on a single tenant, watch it, and roll it back without a deploy if it surprises you.
The takeaway
"The agent can see everything" is a design choice you didn't know you were making. Matrix lets you un-make it with policy instead of plumbing: four dimensions — type, row, field, op — authored once per type, stored as a GrantSpec, and enforced in one central place that every reader funnels through. The dashboards, the API, find_records, and every agent tool obey the same rule, and an interactive agent is automatically capped at what its caller could see. It's opt-in per org, default off, and reversible in a single API call.
Authorization that lives at the data layer is authorization you only have to get right once.
Ready to give your agents only the access they should have? Create a workspace, enable access control on a single org, and read the full guide in docs/ACCESS_CONTROL.md before you author your first restricted member.
Build your first agent on Matrix
Spin up a workspace, wire up tools and knowledge, give your agent a voice, and talk to it in real time — no agent code required.