Access Control
FabrCore 2.0 · Release and package availability
These guides track the current 2.0 source. Stable 2.0.0 publication is pending; package commands show the release target. Until it is published, follow the source quick start or use a matching available prerelease set. Release migration · Runtime modes
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.
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 for evaluated subjects. Disabled enforcement and the system principal bypass evaluation.
| 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 overrides ordinary allows)
{ "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. SQL bootstrap has no user-defined deny grants.
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 FabrCore:Acl:Mode in host configuration, 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
appsettings.json(FabrCore: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.
SQL configuration and ACL migration
Enable SQL mode with ConnectionStrings:FabrCore. ACL entities and their version live in relational acl tables, independent of Orleans storage. The registry serializes transactional writes; each silo evaluates immutable cached snapshots with notifications and TTL refresh.
Configure policy under FabrCore:Acl: Mode, SystemPrincipal, CacheTtlSeconds and SeedDefaultSystemAgentAccess. SQL startup initializes built-in entities and configured System-agent grants. Manage user principals, roles, groups and grants through the API.
Normal startup rejects FabrCore:Acl:Seed, legacy Acl:Seed and FabrCore:Acl:Rules. Export the old ACL before upgrading and import into a built-in-only SQL target. The import preserves IDs, validates references and refuses to overwrite existing user-defined data. See the migration guide and utility.
Without a feature database, standalone bypasses cross-principal ACL and ACL administration is unavailable. Authentication and storage/session ownership checks remain. The grant/evaluation rules on this page describe SQL mode.
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/*. The host consumes the x-user-handle header as caller identity. Your authenticated boundary must derive it from trusted identity and reject or replace caller-supplied values; the header is not authentication. 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. SQL mode selects SqlAuditProvider in fabrOps; standalone uses a bounded in-memory buffer. Custom providers remain supported. SQL write failures are logged/counted without a retry spool or in-memory fallback; retention is explicit.
| 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.
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.
Complete remote ACL management
Use the 2.0 administration principal directory to combine registered and runtime-discovered principals without dropping inactive registrations. Paged collection and individual APIs cover principals, roles, groups, grants and membership.
Conditional writes send the numeric registry version from /access/metadata in If-Match; it is checked inside the serialized mutation. Protected built-ins, effective grant origins and enforcement reset are supported. The principal directory has its own revision hash. Cached message authorization remains local to the cluster; existing audit functionality is unchanged.