FabrCore's New ACL: Principals, Roles, Groups, and Real Agent-to-Agent Security

Eric Brasher July 2, 2026 at 10:05 AM 8 min read

FabrCore's access control just grew up. The early rule-based ACL — pattern strings and a permission flags enum, checked only when a client called across an owner boundary — has been replaced by a full platform: principals, roles, groups, and permission grants, enforced on every cross-principal boundary including agent-to-agent messaging, persisted through standard storage, administered over a management API, and observed end-to-end by a pluggable security audit provider.

The Big One: Agent-to-Agent Is No Longer a Blind Spot

The old ACL had one honest but uncomfortable line in its docs: "agent-to-agent communication within the cluster is trusted and bypasses ACL." Once any message entered the cluster, agents could talk to any other agent, under any principal, unchecked.

That's gone. Every cross-principal agent-to-agent send is now authorized sender-side, deny-by-default. The acting identity derives from the sending grain's own key — never from the message's FromHandle, which agent code can set to anything. An unauthorized send throws AclDeniedException. Traffic within a principal stays implicitly allowed, so single-principal systems feel exactly as frictionless as before — the check that matters kicks in precisely where trust actually changes hands.

Permissions You Can Read Out Loud

Permissions are three dot-separated segments — entity.behavior.effect — and the effect is only ever allow or deny, with deny overriding allow everywhere:

Permission names
agent.message.allow      // send messages/events to an agent
agent.create.deny        // creating agents: forbidden
agent.read.allow         // view monitor data
acl.manage.allow         // administer the policy itself
surface.adminview.allow  // yours! apps define their own vocabulary

A grant ties a subject (a principal, a specific agent, a role, or a group) to a permission over a resource scope. The three cross-talk questions we kept getting asked map to three one-line grants:

JSON — the three cross-talk scopes
// "Let P1's agent1 talk to P2's agent3" — exactly that pair
{ "Subject": "agent:p1:agent1", "Permission": "agent.message.allow", "Resource": "p2:agent3" }

// "Let P1 reach all of P2's agents"
{ "Subject": "principal:p1", "Permission": "agent.message.allow", "Resource": "p2:*" }

// "Let P1 reach any principal's agent5"
{ "Subject": "principal:p1", "Permission": "agent.message.allow", "Resource": "*:agent5" }

Agent creation gets the same treatment, with a twist: the resource's principal segment expresses for whom agents may be created. And because deny is evaluated before the same-principal implicit allow, agent.create.deny on *:* switches off agent creation for a subject entirely — even under its own principal.

Roles, Groups, and Two Groups That Manage Themselves

Roles are named grant sets; groups are named member sets that can also carry roles. Two built-in dynamic groups never store membership at all: all-principals contains every principal and all-agents every agent, computed at evaluation time for free. "Everyone may message the system agents" is one grant on all-principals — not a grant per principal. That single idea is what keeps policy small: entity counts should track policy concepts, not population, and the model is built to make the small version the natural one.

There's also a built-in unrestricted System principal (handle configurable in fabrcore.json) that bypasses all checks — the bootstrap identity you treat like a root credential — and a protected acl-admin role for delegating administration to humans.

The Fan-Out Problem: Warn, Don't Break

Here's the subtle risk with cross-principal grants: you allow P1 to reach P2's agent3, and while handling that request, agent3 calls other agents P1 was never granted. Those transitive hops run under P2's identity — blocking them would break P2's own internals mid-flight.

So FabrCore doesn't block them; it makes them visible. The first cross-principal hop stamps a breadcrumb on the message (CrossPrincipalOrigin and a hop counter) that the host propagates automatically through the chain — agent authors do nothing. If a tagged chain crosses a second principal boundary, the host logs a warning and emits a BoundaryCrossing audit event naming the origin, the forwarder, the target, and the hop count. You get a live map of exactly where cross-principal chains travel, without ever severing an authorized workflow.

Fast Where It Counts

Authorization runs on every message, so the evaluator was built for the hot path: a synchronous check against an immutable in-memory snapshot — no I/O, no locks, no grain calls. Group membership and effective roles are precomputed into reverse indexes when the snapshot builds, so a 20,000-member group costs the same at check time as a 10-member one. Same-principal traffic short-circuits on a string compare.

Writes take the opposite trade: a single-activation registry grain serializes all policy mutations, persists them through the same storage abstraction as everything else in FabrCore (any configured backend works), and pushes change notifications to every silo, with a TTL fallback. The consequence to know about: a revoked grant converges cluster-wide within CacheTtlSeconds (30 seconds by default, tunable). That's the price of a zero-I/O read path, and for message-flow authorization it's almost always the right trade.

Three Enforcement Modes, One Rollout Story

Enforce (the default) blocks denials. AuditOnly is the rollout gear: would-be denials are logged and audited but allowed through, so you can turn the new ACL on against live traffic, watch what would break, add the missing grants, then flip to Enforce with confidence. Disabled is the escape hatch. The mode can be changed at runtime through the management API and the override persists.

An Audit Trail, Built In

Every decision point reports to a pluggable IAuditProvider: ACL decisions, agent-creation checks, boundary crossings, bootstrap activity, and every management API call — who changed what, when, and whether a denial was actually enforced or just would have been (so AuditOnly data is first-class). Levels are configurable per category (None/Failures/All); the default keeps allow-decisions quiet and everything consequential loud. Events carry the W3C trace id, so they join up with OpenTelemetry traces and the agent monitor, and link into verifiable execution evidence when that's enabled.

The default provider is a bounded in-memory buffer — denials are visible out of the box with zero setup — and production systems swap in a durable sink with one line: options.UseAuditProvider<MySiemAuditProvider>().

Your App's Permissions, Too

Maybe our favorite part: the platform isn't just for FabrCore's own checks. Applications built on FabrCore define permissions in their own entity space, create app-prefixed roles and groups through the same API, and gate features with one call:

C# — addon feature gating
var check = await client.CheckPermissionAsync("surface-svc", "alice", "surface.adminview");
if (check.Allowed) { /* render the admin view */ }

No parallel permission system to build, no second store to operate — your app's "is this user an admin?" rides the same evaluator, the same audit trail, and the same management API as FabrCore's own security.

Old vs. New at a Glance

Old ACLNew ACL
ModelPattern rules + flags enumPrincipals, roles, groups, grants in entity.behavior.effect
Agent-to-agentTrusted, bypassed entirelyEnforced cross-principal, deny-by-default, spoof-proof identity
Transitive fan-outInvisibleBreadcrumb + BoundaryCrossing audit events
PersistenceIn-memory; runtime changes lostDurable via standard storage; cluster-consistent registry
AdministrationConfig file + in-process callsFull REST management API + typed SDK, itself ACL-gated and audited
AuditingLog linesPluggable provider, levels, categories, trace correlation
App extensibilityNoneOpen permission vocabulary + check/role query APIs
RolloutOn or offDisabled / AuditOnly / Enforce, switchable at runtime

The legacy Acl:Rules configuration shape no longer binds — hosts carrying it get a loud startup warning with the mapping (Messageagent.message, Configureagent.create/reconfigure/destroy, Readagent.read, Adminacl.manage).

The full model, configuration reference, management API, and best practices are on the new Access Control docs page, with the messaging-specific summary in the Communication docs.