Home / Docs / Access Control

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.

Zero-config works: with an empty 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?

EntityWhat it is
AclPrincipalAn 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.
AclRoleA named set of permission grants. Assign to principals directly or to every member of a group.
AclGroupA 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.
PermissionGrantTies 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.

PermissionGoverns
agent.message.allow / .denySending messages and events to an agent (principal-initiated and agent-to-agent)
agent.create.allow / .denyCreating agents — the resource's principal segment expresses for whom
agent.reconfigure.allow / .denyResetting/reconfiguring an existing agent
agent.destroy.allow / .denyUntracking/evicting an agent
agent.read.allow / .denyReading monitor data (messages, events, LLM calls) for an agent
acl.manage.allow / .denyAdministering ACL entities via the management API
acl.read.allow / .denyReading 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:

PatternMatches
p2:agent3Exactly that agent
p2:*All agents of principal p2
*:agent5The 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:

JSON — PermissionGrant shapes
// 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

  1. Disabled mode — everything allowed (no evaluation)
  2. System principal — the built-in System identity bypasses all checks
  3. Explicit deny — any matching .deny grant denies (deny overrides allow)
  4. Same principal — subject and target share a principal → implicitly allowed
  5. Explicit allow — any matching .allow grant allows
  6. Default deny — no match → denied
Deny sits above the implicit self-allow on purpose.

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

BoundaryPermissionWhere
Principal → agent messaging & eventsagent.messagePrincipalGrain (WebSocket and grain clients)
Agent → agent, cross-principalagent.messageAgentGrain — checked sender-side on every cross-principal send
Agent creation / reset / untrackagent.create / agent.reconfigure / agent.destroyPrincipalGrain
Monitor data readsagent.readMonitor REST + SSE endpoints (filtering)
ACL administrationacl.manage / acl.readACL & 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

ModeBehavior
Enforce (default)Denials block the operation and are audited
AuditOnlyWould-be denials log a warning and an audit event, then proceed — ideal while rolling policy out. Monitor read filtering still filters.
DisabledNo 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 increments CrossPrincipalHops.
  • 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 BoundaryCrossing audit event naming origin, sender, target, and hop count.
Warn, don't break.

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 granting acl.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

JSON — fabrcore.json
{
  "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.

C# — SDK (IFabrCoreHostApiClient)
// 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:

C# — Addon feature gating
// 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).

CategoryRecordsDefault level
AclDecisionMessage/read authorization outcomesFailures
AgentCreationAgent-creation checksFailures
AclManagementEvery management API call (who changed what)All
BoundaryCrossingCross-principal chain movement and fan-outAll
BootstrapRegistry bootstrap activityAll

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.

Compliance note: the default in-memory audit buffer is bounded and lost on restart. For compliance-grade trails, register a durable IAuditProvider.

Custom Providers

C# — Provider registration
builder.AddFabrCoreServer(new FabrCoreServerOptions
{
    AdditionalAssemblies = [typeof(MyAgent).Assembly]
}
.UseAclEvaluator<MyAclEvaluator>()          // custom decision engine
.UseAuditProvider<MySiemAuditProvider>());  // durable audit sink
Hot-path contract: custom 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 AuditOnly to roll policy out against live traffic, watch the would-be denials in the audit feed, then flip to Enforce.
  • Protect the System handle like a root credential — authentication in front of the API is the hosting layer's job.
Documentation