electric-sql / electric-sql/electric

RFC: Add principals as first-class entities

Open
#4,306 0 comments 0 reactions 0 assignees View on GitHub
feature
Dominant language
TypeScript
Stars
10.4k
Forks
375
Avg merge
3d 1h
Merged PRs (30d)
18

Description

## Summary

Add principals as a first-class entity type so that every action in the agents system traces to an owning identity. This provides the foundation for per-user agent sessions, auditing, observability, and (later) capability-based authorization.

Builds on #4305 which introduces asserted identity headers and `created_by` tagging for desktop dispatch.

## Motivation

Today the agents system has no concept of "who" — entities have `write_token` for mutation auth and a free-form `from` string on messages, but there's no structured identity that links agents to users, enables per-user observability, or supports multi-principal isolation.

Without principals you can't build basics like:
- An app showing all of a user's agent sessions
- Audit logs of which user triggered which agent actions
- Multi-tenant isolation where different users' agents are separated
- Per-user rate limiting or quota enforcement

### Three target use cases

**Personal system** — Single user, everything allowed, zero auth config. Principals exist for observability but impose no restrictions.

**Software factory** — Multiple engineers with individual accounts, automation agents, CI bots. Different principals see different things. Misbehaving agents are contained by the tools they're given.

**Customer success app** — External auth (Clerk, Better Auth, enterprise SSO) handles identity. Per-customer authorization expressed through what tools/functions the handler exposes based on the authenticated principal.

## Design

### Principals are entities

Addressed at `/principal/{type}:{id}` (e.g., `/principal/user:kyle`, `/principal/agent:ci-bot`). The `principal` entity type is built-in. The stream IS the principal — identity, inbox, state, and (eventually) capabilities are collections on the same stream.

**Principal types:**
- `user:` — human users
- `agent:` — automation agents owned by a user
- `service:` — system integrations (CI, webhooks)
- `system:` — framework-level principals (cron, default)

Type is part of the ID, follows SPIFFE/IAM convention.

### Identity via header

Inbound requests carry the principal in a header (e.g., `X-Electric-Principal: user:kyle`). The integrator's edge (reverse proxy, gateway, auth middleware) authenticates the user and sets the header. The agents server trusts the header.

**Dev mode:** Falls back to `dev:local` when the header is absent, so personal-system use cases work with zero config.

**Lazy materialization:** First sight of a principal creates its entity stream. No upfront registration required.

### Agents owned by principals

Every agent records its owning principal at spawn time (`created_by`). This enables:
- Querying all agents belonging to a user
- Cascading cleanup when a principal is removed
- Audit trail linking agent actions to the initiating user

### Authorization via handler & tools

For v1, authorization is encoded at the handler level — the integrator decides what tools and functions to expose to each agent based on the principal context. The handler can load auth rules from an external API (e.g., check a permissions service, query your database, call Clerk's API) to decide what to include in the agent's tool set.

```typescript
// Example: handler scopes tools based on principal
export default defineHandler({
async handle(ctx) {
const principal = ctx.principal; // { type: "user", id: "kyle" }

// Load permissions from your auth system
const perms = await myAuthAPI.getPermissions(principal.id);

// Only give the agent tools it's allowed to use
const tools = [];
if (perms.canReadDocs) tools.push(readDocsTool);
if (perms.canSendMessages) tools.push(sendMessageTool);
if (perms.isAdmin) tools.push(adminTool);

return runAgent({ tools, ...ctx });
}
});
```

This keeps authorization flexible and application-specific without the framework needing to own an auth model. The framework provides the principal identity; the integrator decides what that identity can do.

### Inter-principal messaging

`ctx.send("/principal/user:bob", msg)` writes to Bob's inbox collection. Same verb whether the target is a user, agent, or service. Authorization for cross-principal messaging is handler-level (the integrator decides whether to include `send` in the agent's tools).

## Future work (not in this RFC)

These are known future layers that this design should not preclude:

- **Public sharing links / signed URLs** — Serializable scoped authority for read-only (or scoped) access via URL. HMAC-signed, short-expiry. Needed fairly soon for sharing agent sessions, read-only dashboards, etc.
- **Capability expressions in entity streams** — A runtime-evaluated expression language for per-resource, per-principal authorization conditions (time-based, relationship-based, context-based). Stored in principal entity streams, materialized in Postgres for querying.
- **Named capability sets** — Conventional names like `viewer`/`editor`/`admin` defined as macros over capability expressions, providing an adoptable entry point that integrators can outgrow.
- **Monotonic capability decrease on delegation** — When an agent spawns a sub-agent, the child's authority is provably a subset of the parent's.
- **Cross-principal policy** — Framework-level rules for inter-principal communication (can agent owned by Alice message Bob?).

These can be layered on incrementally because principals + entity streams are already the substrate.

## Open questions

1. **Header name**: `X-Electric-Principal`? `X-Electric-User`? Align with #4305's `X-Electric-Asserted-*` headers?
2. **Principal entity handler**: What built-in collections does a principal stream have? (inbox, log, state, capabilities?)
3. **Trust boundary for self-hosted**: Managed Electric Cloud can enforce header trust via infrastructure. Self-hosted needs guidance — bind to localhost by default? Require explicit `trustHeaders` config?
4. **System principal bootstrap**: How do `system:cron`, `system:default` get created at startup?
5. **Principal GC**: When an upstream user is deleted, what happens to their principal stream, owned agents, and spawned entities?

## Context

This design emerged from a structured analysis of authorization approaches for the agents framework ([dialectic artifacts](https://github.com/electric-sql/electric/tree/signals/dialectic-darix-auth)). The key finding: ship principals + headers + tool-level constraints now, let capability machinery emerge from actual usage patterns rather than designing it in advance.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.