Skip to content

Commit 6434774

Browse files
authored
feat(agents-server): add permission enforcement (#4475)
## Motivation Agents Server needs authorization at every entity access path so tenants can limit which principals can spawn types and access individual entity instances. The default model should keep entity instances owner-private while still allowing common sharing flows through explicit grants, principal-kind grants, spawn-time inheritance, and Electric-visible effective permissions. fixes #4464 ## What changed - Added permission storage for entity type spawn grants, entity instance grants, effective permissions, lineage, and shared-state links. - Added owner-default permission checks plus middleware enforcement for entity routes, spawn, durable streams, attachments, schedules, event-source subscriptions, tags, inbox edits, and shared-state streams. - Made new entity type registration available to any authenticated principal by default. Runtime entity definitions can publish their default spawn grants during `registerTypes()`. - Added default built-in `spawn` grants for `principal_kind=user` on Horton and Worker. - Materialized descendant and copy-at-spawn entity grants into `entity_effective_permissions` for point decisions and Electric visibility. - Scoped entity list, entity type list, Electric shape predicates, and `entities(tags)` observation streams by the authenticated principal. Entity `manage` grants are included in read visibility, and entity-type `manage` grants are included in spawn/type visibility. - Added spawn-time initial grants for the new entity. Direct root-spawn grants are allowed because the caller becomes the owner of the new entity; broad parented-spawn grants require `manage` on the parent before they can be delegated. - Added an optional authorization hook for deployments that need custom point decisions; webhook decisions are not injected into Electric predicates and should be materialized into effective rows for shape visibility. - Enabled Electric subqueries in `packages/agents-server/docker-compose.dev.yml` with `ELECTRIC_FEATURE_FLAGS=allow_subqueries`. ## Permissions model Principals are still defined outside Agents Server. The server consumes the authenticated principal from the request context/header and stores grants against either a concrete principal URL, such as `/principal/user%3Aalice`, or a principal kind, such as `user`, `agent`, `service`, or `system`. Native group management is intentionally out of scope for this version; principal-kind grants cover the common broad-access cases. Type grants control type-level actions only. A type-level `spawn` grant decides whether a principal may create an instance of a registered entity type; type-level `manage` is used for type mutation and is treated as a superset for type visibility/spawn checks. Type grants never grant access to existing entity instances. New type creation is open to authenticated principals so developers can register new entity definitions, while mutation of an existing type definition remains guarded by `manage`. Entity grants control access to individual entity instances. Instance access defaults to `created_by` ownership, plus explicit/effective grants for verbs such as `read`, `write`, `delete`, `signal`, `fork`, `schedule`, and parent `spawn`. Entity `manage` is treated as a superset for point checks and visibility. The permission verbs are intentionally granular. `write` covers content mutation paths such as send, tags, inbox edits, attachments, and event-source subscription edits. It does not imply `delete`, `signal`, `fork`, `schedule`, parent `spawn`, or `manage`; those require their own grants or `manage`. UI sharing presets like “read-only”, “read/write”, and “full control” should expand into the appropriate set of granular grants. Spawn links the new entity into the permission graph: - The caller must have a type-level `spawn` grant for the target type. - If the spawn has a parent, the caller must also have `spawn` on that parent entity. - The new entity records `created_by` as the caller principal. - Parent/child lineage is recorded so descendant grants can be materialized for current and future descendants. - Grants marked `copy_to_children` are copied into the child as direct grants during spawn. - The spawn body may include initial direct grants for the new entity. - For parented spawns, broad or propagating initial grants require `manage` on the parent. This covers `principal_kind` grants, `manage` grants, `propagation=descendants`, and `copy_to_children=true`. - Manifest-discovered shared-state links connect shared-state streams back to the linked entity permissions. ## Key decisions - Electric predicates use only `IN (SELECT ...)` subqueries with no correlated outer references. - Entity instance access defaults to `created_by` ownership plus explicit/effective grants. - Type grants are not used for instance visibility or instance access; entity grants are the source of truth there. - Workspace-wide sharing is represented as `subject_kind=principal_kind` and `subject_value=user`. This assumes an Agents Server tenant/service maps to a workspace boundary, and only workspace members can authenticate as `user` principals in that tenant. - Groups are not modeled in this version; deployments that need richer policy can use the authorization hook and/or materialize decisions into effective permission rows. - Public or “anyone with the URL” sharing is a follow-up. The model leaves room for a later `public`/`link` subject kind, synthetic anonymous/link principals, or Cloud share-link records that resolve into an authorization context with matching Electric visibility behavior. - Expired grants are pruned/deactivated server-side rather than using `now()` in Electric predicates. - Pre-permission entity observation bridges are rebuilt after upgrade because old bridge rows do not include principal attribution. ## Coverage Added focused coverage for permission service behavior, route middleware, durable stream access, shared-state authorization, Electric SQL predicate generation, principal-scoped observation streams, runtime registration grant payloads, spawn-time grant materialization and parented-spawn delegation checks, and built-in Horton/Worker spawn grants. A local smoke run also registered a linked `/tmp` runtime, spawned an entity as `user:test`, confirmed `user:other` was denied list/send/view before grants, then confirmed `user:other` could list/send/view after explicit `read` and `write` grants using `ELECTRIC_AGENTS_SERVER_HEADERS` with `Electric-Principal`.
1 parent 74d2341 commit 6434774

41 files changed

Lines changed: 3117 additions & 94 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@electric-ax/agents-server": patch
3+
"@electric-ax/agents-runtime": patch
4+
"@electric-ax/agents": patch
5+
---
6+
7+
Add owner-default agents-server permissions with type-level spawn grants, entity grants, effective permission materialization, principal-scoped entity observation streams, shared-state access links, runtime registration permission grants, and default user spawn grants for built-in Horton and Worker types.
8+
9+
Existing entity observation bridges are rebuilt after upgrade because pre-permission bridge rows do not include principal attribution.
10+
11+
Entity `manage` grants participate in read visibility, entity-type `manage` grants participate in spawn visibility, and broad parented spawn-time grants require `manage` on the parent.

packages/agents-runtime/src/agents-client.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,14 @@ export function createAgentsClient(config: AgentsClientConfig): AgentsClient {
6363
}
6464

6565
if (source.sourceType === `entities`) {
66-
await serverClient.ensureEntitiesMembershipStream(
66+
const ensured = await serverClient.ensureEntitiesMembershipStream(
6767
(source as EntitiesObservationSource).tags
6868
)
69+
source = {
70+
...source,
71+
sourceRef: ensured.sourceRef,
72+
streamUrl: ensured.streamUrl,
73+
}
6974
}
7075

7176
if (!source.streamUrl || !source.schema) {

packages/agents-runtime/src/create-handler.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,9 @@ export function createRuntimeRouter(
510510
? mapSchemas(definition.stateSchemas)
511511
: {}),
512512
},
513+
...(definition.permissionGrants && {
514+
permission_grants: definition.permissionGrants,
515+
}),
513516
}
514517

515518
const defaultDispatchPolicy = defaultDispatchPolicyForType?.(name)

packages/agents-runtime/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export type {
2626
AgentConfig,
2727
AgentModel,
2828
EntityDefinition,
29+
EntityTypePermissionGrantDefinition,
2930
EntityActionsFactory,
3031
EntityActionMap,
3132
EntityArgs,

packages/agents-runtime/src/process-wake.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1396,7 +1396,7 @@ export async function processWake(
13961396
): Promise<EntityStreamDBWithActions> => {
13971397
const ssStreamPath = serverClient.getSharedStateStreamPath(ssId)
13981398
if (mode === `create`) {
1399-
await serverClient.ensureSharedStateStream(ssId)
1399+
await serverClient.ensureSharedStateStream(ssId, entityUrl)
14001400
}
14011401
const ssStreamUrl = appendPathToUrl(baseUrl, ssStreamPath)
14021402
const ssCollections: Record<string, CollectionDefinition> = {}
@@ -1607,6 +1607,7 @@ export async function processWake(
16071607
source: ObservationSource,
16081608
wake?: Wake
16091609
): Promise<ObservationHandle> => {
1610+
let observedSource = source
16101611
// Self-observation
16111612
if (
16121613
source.sourceType === `entity` &&
@@ -1647,24 +1648,44 @@ export async function processWake(
16471648
}
16481649

16491650
if (source.sourceType === `entities`) {
1650-
await serverClient.ensureEntitiesMembershipStream(
1651+
const ensured = await serverClient.ensureEntitiesMembershipStream(
16511652
(source as EntitiesObservationSource).tags
16521653
)
1654+
const originalEntry = source.toManifestEntry() as Record<
1655+
string,
1656+
unknown
1657+
>
1658+
observedSource = {
1659+
...source,
1660+
sourceRef: ensured.sourceRef,
1661+
streamUrl: ensured.streamUrl,
1662+
toManifestEntry() {
1663+
return {
1664+
...originalEntry,
1665+
key: `source:entities:${ensured.sourceRef}`,
1666+
sourceRef: ensured.sourceRef,
1667+
config: {
1668+
...((originalEntry.config as Record<string, unknown>) ?? {}),
1669+
streamUrl: ensured.streamUrl,
1670+
},
1671+
} as unknown as ReturnType<ObservationSource[`toManifestEntry`]>
1672+
},
1673+
}
16531674
}
16541675

16551676
if (effectiveWake) {
1656-
const observeHandle = await setupCtx.observe(source, {
1677+
const observeHandle = await setupCtx.observe(observedSource, {
16571678
wake: effectiveWake,
16581679
})
16591680

16601681
const sourceUrl =
16611682
sourceWakeConfig?.sourceUrl ??
1662-
(source.sourceType === `entity`
1663-
? (source as EntityObservationSource).entityUrl
1664-
: source.streamUrl)
1683+
(observedSource.sourceType === `entity`
1684+
? (observedSource as EntityObservationSource).entityUrl
1685+
: observedSource.streamUrl)
16651686
if (!sourceUrl) {
16661687
throw new Error(
1667-
`[agent-runtime] Cannot register wake for source '${source.sourceType}:${source.sourceRef}' without a source URL`
1688+
`[agent-runtime] Cannot register wake for source '${observedSource.sourceType}:${observedSource.sourceRef}' without a source URL`
16681689
)
16691690
}
16701691

@@ -1695,7 +1716,7 @@ export async function processWake(
16951716
? wake.includeResponse
16961717
: undefined
16971718
: sourceWakeConfig?.includeResponse,
1698-
manifestKey: source.toManifestEntry().key,
1719+
manifestKey: observedSource.toManifestEntry().key,
16991720
})
17001721

17011722
if (source.sourceType === `db`) {
@@ -1706,7 +1727,7 @@ export async function processWake(
17061727
return observeHandle
17071728
}
17081729

1709-
const observeHandle = await setupCtx.observe(source)
1730+
const observeHandle = await setupCtx.observe(observedSource)
17101731
if (source.sourceType === `db`) {
17111732
scheduleSharedStateWiring()
17121733
await waitForSharedStateWiring()

packages/agents-runtime/src/runtime-server-client.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,10 @@ export interface RuntimeServerClient {
122122
}) => Promise<Uint8Array>
123123
spawnEntity: (options: SpawnEntityOptions) => Promise<RuntimeEntityInfo>
124124
getEntity: (entityUrl: string) => Promise<RuntimeEntityInfo>
125-
ensureSharedStateStream: (sharedStateId: string) => Promise<string>
125+
ensureSharedStateStream: (
126+
sharedStateId: string,
127+
ownerEntityUrl?: string
128+
) => Promise<string>
126129
signalEntity: (options: SignalEntityOptions) => Promise<{ txid: number }>
127130
ensureStream: (streamPath: string, contentType?: string) => Promise<string>
128131
deleteEntity: (entityUrl: string) => Promise<void>
@@ -447,19 +450,24 @@ export function createRuntimeServerClient(
447450
}
448451

449452
const ensureSharedStateStream = async (
450-
sharedStateId: string
453+
sharedStateId: string,
454+
ownerEntityUrl?: string
451455
): Promise<string> => {
452456
const streamPath = getSharedStateStreamPath(sharedStateId)
453-
return await ensureStream(streamPath, `application/json`)
457+
return await ensureStream(streamPath, `application/json`, ownerEntityUrl)
454458
}
455459

456460
const ensureStream = async (
457461
streamPath: string,
458-
contentType = `application/json`
462+
contentType = `application/json`,
463+
ownerEntityUrl?: string
459464
): Promise<string> => {
460465
const response = await request(streamPath, {
461466
method: `PUT`,
462-
headers: { 'content-type': contentType },
467+
headers: {
468+
'content-type': contentType,
469+
...(ownerEntityUrl ? { 'electric-owner-entity': ownerEntityUrl } : {}),
470+
},
463471
})
464472

465473
if (!response.ok && response.status !== 409) {

packages/agents-runtime/src/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,13 @@ export interface EntityCreated {
413413
parent_url?: string
414414
}
415415

416+
export type EntityTypePermissionGrantDefinition = {
417+
subject_kind: `principal` | `principal_kind`
418+
subject_value: string
419+
permission: `spawn` | `manage`
420+
expires_at?: string
421+
}
422+
416423
export interface PendingSend {
417424
targetUrl: string
418425
payload: unknown
@@ -1047,6 +1054,7 @@ export interface EntityDefinition<
10471054
creationSchema?: TCreationSchema
10481055
inboxSchemas?: Record<string, StandardJSONSchemaV1>
10491056
stateSchemas?: Record<string, StandardJSONSchemaV1>
1057+
permissionGrants?: ReadonlyArray<EntityTypePermissionGrantDefinition>
10501058

10511059
handler: (
10521060
ctx: HandlerContext<

packages/agents-runtime/test/create-handler.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,13 @@ describe(`createRuntimeHandler`, () => {
659659
defineEntity(`schema-agent`, {
660660
description: `Schema agent`,
661661
stateSchemas: { custom: makeStandardSchema({ type: `object` }) },
662+
permissionGrants: [
663+
{
664+
subject_kind: `principal_kind`,
665+
subject_value: `user`,
666+
permission: `spawn`,
667+
},
668+
],
662669
handler: async () => {},
663670
})
664671

@@ -698,6 +705,13 @@ describe(`createRuntimeHandler`, () => {
698705
},
699706
],
700707
},
708+
permission_grants: [
709+
{
710+
subject_kind: `principal_kind`,
711+
subject_value: `user`,
712+
permission: `spawn`,
713+
},
714+
],
701715
state_schemas: expect.objectContaining({
702716
custom: { type: `object` },
703717
run: expect.any(Object),

packages/agents-runtime/test/electric-agents-client.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ describe(`createAgentsClient`, () => {
9393
})
9494
expect(mockState.createStreamDB).toHaveBeenCalledWith({
9595
streamOptions: {
96-
url: `http://electric-agents.test${source.streamUrl}`,
96+
url: `http://electric-agents.test/_entities/source-1`,
9797
contentType: `application/json`,
9898
},
9999
state: source.schema,
@@ -117,7 +117,7 @@ describe(`createAgentsClient`, () => {
117117

118118
expect(mockState.createStreamDB).toHaveBeenCalledWith({
119119
streamOptions: {
120-
url: `http://electric-agents.test/t/tenant-a/v1${source.streamUrl}`,
120+
url: `http://electric-agents.test/t/tenant-a/v1/_entities/source-1`,
121121
contentType: `application/json`,
122122
},
123123
state: source.schema,

packages/agents-runtime/test/runtime-server-client-update-metadata.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,26 @@ describe(`runtime-server-client.setTag`, () => {
4444
)
4545
})
4646

47+
it(`ensureSharedStateStream sends the owner entity header`, async () => {
48+
const calls: Array<{ url: string; init?: RequestInit }> = []
49+
const fakeFetch = vi.fn(async (url: string, init?: RequestInit) => {
50+
calls.push({ url, init })
51+
return new Response(null, { status: 201 })
52+
}) as unknown as typeof fetch
53+
const client = createRuntimeServerClient({
54+
baseUrl: `http://test.example`,
55+
fetch: fakeFetch,
56+
})
57+
58+
await expect(
59+
client.ensureSharedStateStream(`board-1`, `/task/owner`)
60+
).resolves.toBe(`/_electric/shared-state/board-1`)
61+
62+
const headers = new Headers(calls[0]!.init?.headers)
63+
expect(headers.get(`content-type`)).toBe(`application/json`)
64+
expect(headers.get(`electric-owner-entity`)).toBe(`/task/owner`)
65+
})
66+
4767
it(`sends POST with bearer token and tag body`, async () => {
4868
const calls: Array<{ url: string; init?: RequestInit }> = []
4969
const fakeFetch = vi.fn(async (url: string, init?: RequestInit) => {

0 commit comments

Comments
 (0)