Access Control
Access Control (ACL)
FabrCore's access-control platform governs everything that crosses a principal boundary: who may message whose agents, who may create agents (and for whom), who may read monitor data, and who may administer the policy itself. It is built on four entities — principals, roles, groups, and permission grants — evaluated by a synchronous, snapshot-backed engine that adds no I/O to the message hot path, and observed end-to-end by a pluggable security audit provider.
fabrcore.json you get secure defaults — principals have full access to their own agents, cross-principal traffic is denied until granted, the unrestricted System principal is auto-created, and denials are audited in memory. A single-principal demo needs no setup at all.The Model
Every decision answers one question: may this subject perform this action on this resource?
| Entity | What it is |
|---|---|
AclPrincipal | An identity (user, tenant, service, system). The handle is the same string that keys PrincipalGrain. Only needs an ACL record when it carries direct role assignments. |
AclRole | A named set of permission grants. Assign to principals directly or to every member of a group. |
AclGroup | A named set of members (principals and/or agents). Groups may also carry roles inherited by all members. Two built-in dynamic groups have computed membership: all-principals and all-agents. |
PermissionGrant | Ties a subject + permission + resource scope together, with an allow or deny effect. |
Permission Notation
Permissions are three lowercase dot-separated segments: entity.behavior.effect. The effect is always allow or deny — and deny overrides allow, everywhere.
| Permission | Governs |
|---|---|
agent.message.allow / .deny | Sending messages and events to an agent (principal-initiated and agent-to-agent) |
agent.create.allow / .deny | Creating agents — the resource's principal segment expresses for whom |
agent.reconfigure.allow / .deny | Resetting/reconfiguring an existing agent |
agent.destroy.allow / .deny | Untracking/evicting an agent |
agent.read.allow / .deny | Reading monitor data (messages, events, LLM calls) for an agent |
acl.manage.allow / .deny | Administering ACL entities via the management API |
acl.read.allow / .deny | Reading ACL entities and running evaluate/check queries |
The vocabulary is open: applications define their own permissions in non-reserved entity space (e.g. surface.adminview.allow) — see application-defined permissions. Reserved entities: agent, principal, acl, system, fabrcore.
Resource Patterns
Grant resources match a full "principal:agent" handle:
| Pattern | Matches |
|---|---|
p2:agent3 | Exactly that agent |
p2:* | All agents of principal p2 |
*:agent5 | The agent named agent5 under any principal |
*:* | Everything (also the convention for resource-less app permissions) |
tenant-*:* | Prefix wildcard, per segment |
group:tenants:* | Agents of any principal in group tenants (group refs valid on the principal segment) |
Cross-Talk Grants
The three canonical shapes for letting agents talk across principal boundaries:
// P1's agent1 may message exactly P2's agent3
{ "Subject": { "Kind": "Agent", "Selector": "p1:agent1" },
"Permission": "agent.message.allow", "Resource": "p2:agent3" }
// P1 (any of its agents) may message all of P2's agents
{ "Subject": { "Kind": "Principal", "Selector": "p1" },
"Permission": "agent.message.allow", "Resource": "p2:*" }
// P1 may message any principal's agent named agent5
{ "Subject": { "Kind": "Principal", "Selector": "p1" },
"Permission": "agent.message.allow", "Resource": "*:agent5" }
// Ban a principal from creating agents entirely (deny beats everything)
{ "Subject": { "Kind": "Principal", "Selector": "p1" },
"Permission": "agent.create.deny", "Resource": "*:*" }
Evaluation Order
- Disabled mode — everything allowed (no evaluation)
- System principal — the built-in System identity bypasses all checks
- Explicit deny — any matching
.denygrant denies (deny overrides allow) - Same principal — subject and target share a principal → implicitly allowed
- Explicit allow — any matching
.allowgrant allows - Default deny — no match → denied
That's how agent.create.deny on *:* disables agent creation for a subject entirely — even under its own principal. Zero-config behavior is unchanged because no deny grants exist by default.
Subject matching resolves the caller's full identity set per evaluation: the principal, the acting agent handle (agent-to-agent only), stored group memberships, the dynamic groups, and effective roles (direct + via groups). Evaluation is synchronous against an immutable in-memory snapshot — no I/O, no locks, no grain calls on the message hot path.
Enforcement
| Boundary | Permission | Where |
|---|---|---|
| Principal → agent messaging & events | agent.message | PrincipalGrain (WebSocket and grain clients) |
| Agent → agent, cross-principal | agent.message | AgentGrain — checked sender-side on every cross-principal send |
| Agent creation / reset / untrack | agent.create / agent.reconfigure / agent.destroy | PrincipalGrain |
| Monitor data reads | agent.read | Monitor REST + SSE endpoints (filtering) |
| ACL administration | acl.manage / acl.read | ACL & Audit REST endpoints |
Enforcement identity always derives from the sending grain's own key — FromHandle is spoofable routing metadata and is never trusted for authorization. Unauthorized operations throw AclDeniedException (an UnauthorizedAccessException). Response-kind and system messages (_status/_error) are exempt from the agent-to-agent check so authorized request/reply round-trips can't be broken.
Enforcement Modes
| Mode | Behavior |
|---|---|
Enforce (default) | Denials block the operation and are audited |
AuditOnly | Would-be denials log a warning and an audit event, then proceed — ideal while rolling policy out. Monitor read filtering still filters. |
Disabled | No evaluation; everything allowed |
Set via Acl:Mode in fabrcore.json, or change at runtime through the management API (the override persists and wins over configuration).
Cross-Principal Fan-Out & the Breadcrumb
Granting P1 access to P2's agent means that agent may, while handling P1's request, contact other agents P1 was never granted. Those transitive hops run as P2 and are not blocked — they are made visible instead:
- The first cross-principal send stamps
AgentMessage.CrossPrincipalOrigin(the originating principal) and incrementsCrossPrincipalHops. - The receiving host propagates the breadcrumb automatically into messages the agent composes while processing — agent authors do nothing.
- If a tagged chain crosses a second principal boundary, the host logs a warning and emits a
BoundaryCrossingaudit event naming origin, sender, target, and hop count.
The first hop is permissioned; downstream fan-out is the target principal's business, surfaced through the audit trail rather than blocked mid-flight. Watch the BoundaryCrossing audit category to see exactly where cross-principal chains travel.
Built-Ins: System Principal & Dynamic Groups
- System principal — unrestricted; bypasses every check. Its handle is set in
fabrcore.json(Acl:SystemPrincipal, default"system"). Treat the handle like a root credential. It cannot be deleted. all-principals— dynamic group containing every principal, stored nowhere, computed at evaluation time. The right subject for "everyone may…" grants.all-agents— dynamic group containing every agent (matches agent subjects only).acl-admin— built-in role grantingacl.manage.allow+acl.read.allow; assigned to System at bootstrap; protected from deletion.
Dynamic group names are configurable (AllPrincipalsGroupId, AllAgentsGroupId). Membership edits on dynamic groups are rejected — it's computed.
Configuration & Bootstrap
{
"Acl": {
"SystemPrincipal": "system",
"Mode": "Enforce",
"AllPrincipalsGroupId": "all-principals",
"AllAgentsGroupId": "all-agents",
"CacheTtlSeconds": 30,
"SeedDefaultSystemAgentAccess": true,
"Seed": {
"Roles": [
{ "Name": "ops-reader",
"Grants": [ { "Permission": "agent.read.allow", "Resource": "*:*" } ] }
],
"Groups": [
{ "Name": "ops-team", "Members": [ "principal:ops-alice" ], "Roles": [ "ops-reader" ] }
],
"Grants": [
{ "Subject": "principal:p1", "Permission": "agent.message.allow", "Resource": "p2:*" }
]
}
},
"FabrCore": {
"Audit": {
"DefaultLevel": "Failures",
"MaxBufferedEvents": 10000
}
}
}
On first run the registry bootstraps automatically: System principal, dynamic groups, the acl-admin role, an optional default grant letting every principal message/read the System principal's agents (SeedDefaultSystemAgentAccess), and any Seed entities. Seeds apply on first bootstrap only — after that the management API is the source of truth.
Entities persist through the same storage abstraction as the Storage API (any configured backend works) via a single-activation registry grain that serializes all writes. Each silo caches an immutable policy snapshot, refreshed by change notifications with a TTL fallback — a revoked grant converges cluster-wide within CacheTtlSeconds (default 30s).
Management API & SDK
Full CRUD over fabrcoreapi/acl/* — principals, roles, groups (with member endpoints), grants — plus evaluate (dry-run with the deciding grant), check (simplified boolean), effective-membership queries, and runtime enforcement-mode control. Audit queries live at fabrcoreapi/audit/*. Caller identity is the x-user-handle header; mutations require acl.manage.allow, reads require acl.read.allow. Every management call is itself audited.
// Grant cross-talk (as System or an acl-admin)
await client.UpsertAclGrantAsync("system", new PermissionGrant
{
Subject = new AclSubject(SubjectKind.Agent, "p1:agent1"),
Permission = FabrPermissions.AgentMessageAllow,
Resource = "p2:agent3"
});
// Dry-run a decision
var result = await client.EvaluateAclAsync("system", new AclEvaluationRequest
{
SubjectPrincipal = "p1", SubjectAgent = "p1:agent1",
Action = "agent.message", Resource = "p2:agent3"
});
// result.Allowed, result.Outcome, result.Reason, result.DecidingGrant
Building an admin UI? The repo ships a dedicated guide covering every endpoint, screen, and pitfall: docs/acl-management.md.
Application-Defined Permissions
Consuming applications run their own authorization on the same platform. Define permissions in your own entity space, create app-prefixed roles/groups via the same API, and gate features with one call:
// Setup once: a role carrying an app permission
await client.UpsertAclRoleAsync("system", new AclRole
{
Name = "surface:admin",
Grants = { new PermissionGrant { Permission = "surface.adminview.allow", Resource = "*:*" } }
});
// At runtime: does alice get the admin view?
var check = await client.CheckPermissionAsync("surface-svc", "alice", "surface.adminview");
if (check.Allowed) { /* render admin view */ }
Role-membership checks (IsPrincipalInRoleAsync) and effective-role queries (GetPrincipalRolesAsync) are also available. Grant the addon's service principal acl.read.allow once at setup.
Security Audit
A pluggable IAuditProvider records ACL decisions, management changes, agent-creation checks, boundary crossings, and bootstrap events. The default is an in-memory bounded FIFO so denials are visible out of the box; production systems point UseAuditProvider<T>() at a durable sink (database, SIEM, event hub).
| Category | Records | Default level |
|---|---|---|
AclDecision | Message/read authorization outcomes | Failures |
AgentCreation | Agent-creation checks | Failures |
AclManagement | Every management API call (who changed what) | All |
BoundaryCrossing | Cross-principal chain movement and fan-out | All |
Bootstrap | Registry bootstrap activity | All |
Levels are None / Failures / All, configurable globally and per category under FabrCore:Audit. Events carry the caller, resource, permission, enforcement mode, whether the denial was actually enforced (distinguishing Enforce from AuditOnly), a reason including the deciding grant, and the W3C TraceId for joining OpenTelemetry traces and monitor data. When verifiable execution is enabled, events also link to the signed evidence trail.
IAuditProvider.
Custom Providers
builder.AddFabrCoreServer(new FabrCoreServerOptions
{
AdditionalAssemblies = [typeof(MyAgent).Assembly]
}
.UseAclEvaluator<MyAclEvaluator>() // custom decision engine
.UseAuditProvider<MySiemAuditProvider>()); // durable audit sink
IAclEvaluator implementations must stay synchronous and snapshot-backed — never perform I/O or grain calls inside Evaluate; it runs inside grain turns on every message.
Best Practices
- Groups, roles, and wildcards first; individual grants last. Policy entity counts should track policy concepts, not population — "everyone may message system agents" is one grant on
all-principals, not thousands of per-principal grants. - Reserve deny for hard policy statements ("this subject must never do X"); removing an allow is the normal revocation path.
- Use
AuditOnlyto roll policy out against live traffic, watch the would-be denials in the audit feed, then flip toEnforce. - Protect the System handle like a root credential — authentication in front of the API is the hosting layer's job.