@electric-ax/agents-server 0.4.15 → 0.4.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -43,6 +43,12 @@ export type {
43
43
  ElectricAgentsEntity,
44
44
  ElectricAgentsEntityRow,
45
45
  ElectricAgentsEntityType,
46
+ EntityPermission,
47
+ EntityPermissionGrant,
48
+ EntityPermissionPropagation,
49
+ EntityTypePermission,
50
+ EntityTypePermissionGrant,
51
+ EntityTypePermissionGrantInput,
46
52
  EntityStatus,
47
53
  EntitySignal,
48
54
  PublicElectricAgentsEntity,
@@ -52,6 +58,11 @@ export type {
52
58
  SignalRequest,
53
59
  SignalResponse,
54
60
  TypedSpawnRequest,
61
+ PermissionSubject,
62
+ PermissionSubjectKind,
63
+ AuthorizationDecision,
64
+ AuthorizationResource,
65
+ AuthorizeRequest,
55
66
  } from './electric-agents-types.js'
56
67
  export type {
57
68
  EventSourceBucket,
@@ -0,0 +1,239 @@
1
+ import { isBuiltInSystemPrincipalUrl } from './principal.js'
2
+ import type {
3
+ AuthorizeRequest,
4
+ ElectricAgentsEntity,
5
+ ElectricAgentsEntityType,
6
+ EntityPermission,
7
+ RegisterEntityTypeRequest,
8
+ EntityTypePermission,
9
+ } from './electric-agents-types.js'
10
+ import type { TenantContext } from './routing/context.js'
11
+ import { serverLog } from './utils/log.js'
12
+
13
+ const authzDecisionCache = new WeakMap<
14
+ AuthorizeRequest,
15
+ Map<string, { decision: `allow` | `deny`; expiresAt: number }>
16
+ >()
17
+
18
+ export function principalSubject(principal: { url: string; kind: string }): {
19
+ principalUrl: string
20
+ principalKind: string
21
+ } {
22
+ return { principalUrl: principal.url, principalKind: principal.kind }
23
+ }
24
+
25
+ export function isPermissionBypassPrincipal(ctx: TenantContext): boolean {
26
+ return isBuiltInSystemPrincipalUrl(ctx.principal.url)
27
+ }
28
+
29
+ export async function canAccessEntity(
30
+ ctx: TenantContext,
31
+ entity: ElectricAgentsEntity,
32
+ permission: EntityPermission,
33
+ request?: Request
34
+ ): Promise<boolean> {
35
+ if (isPermissionBypassPrincipal(ctx)) return true
36
+ await ctx.entityManager.registry.pruneExpiredPermissionGrants?.()
37
+
38
+ const builtInAllowed =
39
+ entity.created_by === ctx.principal.url ||
40
+ (await ctx.entityManager.registry.hasEntityPermission(
41
+ entity.url,
42
+ permission,
43
+ principalSubject(ctx.principal)
44
+ ))
45
+
46
+ return await applyAuthorizationHook(ctx, {
47
+ verb: permission,
48
+ resourceKey: `entity:${entity.url}`,
49
+ resource: { kind: `entity`, entity },
50
+ builtInAllowed,
51
+ request,
52
+ })
53
+ }
54
+
55
+ export async function canAccessEntityType(
56
+ ctx: TenantContext,
57
+ entityType: ElectricAgentsEntityType,
58
+ permission: EntityTypePermission,
59
+ request?: Request
60
+ ): Promise<boolean> {
61
+ if (isPermissionBypassPrincipal(ctx)) return true
62
+ await ctx.entityManager.registry.pruneExpiredPermissionGrants?.()
63
+
64
+ const builtInAllowed =
65
+ await ctx.entityManager.registry.hasEntityTypePermission(
66
+ entityType.name,
67
+ permission,
68
+ principalSubject(ctx.principal)
69
+ )
70
+
71
+ return await applyAuthorizationHook(ctx, {
72
+ verb: permission,
73
+ resourceKey: `entity_type:${entityType.name}`,
74
+ resource: { kind: `entity_type`, entityType },
75
+ builtInAllowed,
76
+ request,
77
+ })
78
+ }
79
+
80
+ export async function canRegisterEntityType(
81
+ ctx: TenantContext,
82
+ input: Pick<RegisterEntityTypeRequest, `name`>,
83
+ request?: Request
84
+ ): Promise<boolean> {
85
+ if (isPermissionBypassPrincipal(ctx)) return true
86
+
87
+ return await applyAuthorizationHook(ctx, {
88
+ verb: `manage`,
89
+ resourceKey: `entity_type_registration:${input.name}`,
90
+ resource: {
91
+ kind: `entity_type_registration`,
92
+ entityTypeName: input.name,
93
+ },
94
+ builtInAllowed: true,
95
+ request,
96
+ })
97
+ }
98
+
99
+ export async function canAccessSharedState(
100
+ ctx: TenantContext,
101
+ sharedStateId: string,
102
+ permission: `read` | `write`,
103
+ request?: Request,
104
+ ownerEntityUrl?: string
105
+ ): Promise<boolean> {
106
+ if (isPermissionBypassPrincipal(ctx)) return true
107
+ await ctx.entityManager.registry.pruneExpiredPermissionGrants?.()
108
+
109
+ const storedLinkedEntityUrls =
110
+ await ctx.entityManager.registry.listSharedStateLinkedEntityUrls(
111
+ sharedStateId
112
+ )
113
+ const bootstrapEntityUrls =
114
+ storedLinkedEntityUrls.length === 0 && ownerEntityUrl
115
+ ? [ownerEntityUrl]
116
+ : []
117
+ const linkedEntityUrls = [
118
+ ...new Set([...storedLinkedEntityUrls, ...bootstrapEntityUrls]),
119
+ ]
120
+ for (const entityUrl of linkedEntityUrls) {
121
+ const entity = await ctx.entityManager.registry.getEntity(entityUrl)
122
+ if (!entity) continue
123
+ if (
124
+ entity.created_by === ctx.principal.url ||
125
+ (await ctx.entityManager.registry.hasEntityPermission(
126
+ entity.url,
127
+ permission,
128
+ principalSubject(ctx.principal)
129
+ ))
130
+ ) {
131
+ return await applyAuthorizationHook(ctx, {
132
+ verb: permission,
133
+ resourceKey: `shared_state:${sharedStateId}`,
134
+ resource: {
135
+ kind: `shared_state`,
136
+ sharedStateId,
137
+ linkedEntityUrls,
138
+ },
139
+ builtInAllowed: true,
140
+ request,
141
+ })
142
+ }
143
+ }
144
+
145
+ return await applyAuthorizationHook(ctx, {
146
+ verb: permission,
147
+ resourceKey: `shared_state:${sharedStateId}`,
148
+ resource: {
149
+ kind: `shared_state`,
150
+ sharedStateId,
151
+ linkedEntityUrls,
152
+ },
153
+ builtInAllowed: false,
154
+ request,
155
+ })
156
+ }
157
+
158
+ async function applyAuthorizationHook(
159
+ ctx: TenantContext,
160
+ input: {
161
+ verb: EntityPermission | EntityTypePermission
162
+ resourceKey: string
163
+ resource: Parameters<AuthorizeRequest>[0][`resource`]
164
+ builtInAllowed: boolean
165
+ request?: Request
166
+ }
167
+ ): Promise<boolean> {
168
+ const hook = ctx.authorizeRequest
169
+ if (!hook) return input.builtInAllowed
170
+
171
+ const cacheKey = [
172
+ ctx.service,
173
+ ctx.principal.url,
174
+ input.verb,
175
+ input.resourceKey,
176
+ ].join(`|`)
177
+ const cached = getCachedDecision(hook, cacheKey)
178
+ if (cached) return cached.decision === `allow`
179
+
180
+ let decision: Awaited<ReturnType<AuthorizeRequest>>
181
+ try {
182
+ decision = await hook({
183
+ tenant: ctx.service,
184
+ principal: ctx.principal,
185
+ verb: input.verb,
186
+ resource: input.resource,
187
+ request: input.request ? requestMetadata(input.request) : undefined,
188
+ builtInAllowed: input.builtInAllowed,
189
+ })
190
+ } catch (error) {
191
+ serverLog.warn(`[agent-server] authorization hook failed:`, error)
192
+ return false
193
+ }
194
+
195
+ cacheDecision(hook, cacheKey, decision)
196
+ return decision.decision === `allow`
197
+ }
198
+
199
+ function getCachedDecision(
200
+ hook: AuthorizeRequest,
201
+ cacheKey: string
202
+ ): { decision: `allow` | `deny` } | null {
203
+ const cache = authzDecisionCache.get(hook)
204
+ const entry = cache?.get(cacheKey)
205
+ if (!entry) return null
206
+ if (entry.expiresAt <= Date.now()) {
207
+ cache?.delete(cacheKey)
208
+ return null
209
+ }
210
+ return { decision: entry.decision }
211
+ }
212
+
213
+ function cacheDecision(
214
+ hook: AuthorizeRequest,
215
+ cacheKey: string,
216
+ decision: Awaited<ReturnType<AuthorizeRequest>>
217
+ ): void {
218
+ if (!decision.expires_at) return
219
+ const expiresAt = Date.parse(decision.expires_at)
220
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return
221
+ let cache = authzDecisionCache.get(hook)
222
+ if (!cache) {
223
+ cache = new Map()
224
+ authzDecisionCache.set(hook, cache)
225
+ }
226
+ cache.set(cacheKey, { decision: decision.decision, expiresAt })
227
+ }
228
+
229
+ function requestMetadata(request: Request): {
230
+ method: string
231
+ url: string
232
+ headers: Record<string, string>
233
+ } {
234
+ const headers: Record<string, string> = {}
235
+ request.headers.forEach((value, key) => {
236
+ headers[key] = value
237
+ })
238
+ return { method: request.method, url: request.url, headers }
239
+ }
@@ -12,6 +12,7 @@ import type { DurableStreamsRoutingAdapter } from './durable-streams-routing-ada
12
12
  import type { Principal } from '../principal.js'
13
13
  import type { DurableStreamsBearerProvider } from '../stream-client.js'
14
14
  import type { WebhookSigner } from '../webhook-signing.js'
15
+ import type { AuthorizeRequest } from '../electric-agents-types.js'
15
16
 
16
17
  export interface EventSourceCatalog {
17
18
  listEventSources: () =>
@@ -55,5 +56,6 @@ export interface TenantContext {
55
56
  entityBridgeManager: EntityBridgeCoordinator
56
57
  eventSources?: EventSourceCatalog
57
58
  ensureEventSourceWakeSource?: (sourceUrl: string) => Promise<void> | void
59
+ authorizeRequest?: AuthorizeRequest
58
60
  isShuttingDown: () => boolean
59
61
  }
@@ -6,8 +6,16 @@ import { appendPathToUrl } from '@electric-ax/agents-runtime'
6
6
  import { Type, type Static } from '@sinclair/typebox'
7
7
  import { and, eq } from 'drizzle-orm'
8
8
  import { Router } from 'itty-router'
9
- import { readRequestBody, responseHeaders } from '../electric-agents-http.js'
9
+ import {
10
+ apiError,
11
+ readRequestBody,
12
+ responseHeaders,
13
+ } from '../electric-agents-http.js'
10
14
  import { subscriptionWebhooks } from '../db/schema.js'
15
+ import {
16
+ ErrCodeNotFound,
17
+ ErrCodeUnauthorized,
18
+ } from '../electric-agents-types.js'
11
19
  import {
12
20
  createStreamAppendRouteRequest,
13
21
  electricAgentsStreamAppendRouter,
@@ -15,6 +23,7 @@ import {
15
23
  import { validateBody } from './schema.js'
16
24
  import { rewriteLoopbackWebhookUrl } from '../utils/webhook-url.js'
17
25
  import { forwardFetchRequest } from '../utils/server-utils.js'
26
+ import { canAccessEntity, canAccessSharedState } from '../permissions.js'
18
27
  import {
19
28
  getDefaultWebhookSigner,
20
29
  webhookSigningMetadata,
@@ -48,6 +57,8 @@ const subscriptionControlActions = [
48
57
  `release`,
49
58
  ] as const
50
59
 
60
+ const SHARED_STATE_OWNER_ENTITY_HEADER = `electric-owner-entity`
61
+
51
62
  export type DurableStreamsRoutes = RouterType<
52
63
  IRequest,
53
64
  [TenantContext],
@@ -583,6 +594,8 @@ async function streamAppend(
583
594
  request: IRequest,
584
595
  ctx: TenantContext
585
596
  ): Promise<Response | undefined> {
597
+ const auth = await authorizeDurableStreamAccess(request, ctx)
598
+ if (auth) return auth
586
599
  return await electricAgentsStreamAppendRouter.fetch(
587
600
  createStreamAppendRouteRequest(request as Request),
588
601
  ctx.runtime,
@@ -608,10 +621,9 @@ async function proxyPassThrough(
608
621
  request: IRequest,
609
622
  ctx: TenantContext
610
623
  ): Promise<Response> {
624
+ const auth = await authorizeDurableStreamAccess(request, ctx)
625
+ if (auth) return auth
611
626
  const streamPath = new URL(request.url).pathname
612
- if (ctx.entityManager?.isAttachmentStreamPath(streamPath)) {
613
- return new Response(null, { status: 404 })
614
- }
615
627
  const upstream = await forwardToDurableStreams(ctx, request)
616
628
  const method = request.method.toUpperCase()
617
629
  const endTrackedRead =
@@ -627,3 +639,112 @@ async function proxyPassThrough(
627
639
  await endTrackedRead?.()
628
640
  }
629
641
  }
642
+
643
+ async function authorizeDurableStreamAccess(
644
+ request: IRequest,
645
+ ctx: TenantContext
646
+ ): Promise<Response | undefined> {
647
+ const method = request.method.toUpperCase()
648
+ const streamPath = new URL(request.url).pathname
649
+
650
+ if (method === `GET` || method === `HEAD`) {
651
+ const registry = ctx.entityManager?.registry
652
+ const entity = registry?.getEntityByStream
653
+ ? await registry.getEntityByStream(streamPath)
654
+ : null
655
+ if (entity) {
656
+ if (await canAccessEntity(ctx, entity, `read`, request as Request)) {
657
+ return undefined
658
+ }
659
+ return apiError(
660
+ 401,
661
+ ErrCodeUnauthorized,
662
+ `Principal is not allowed to read ${entity.url}`
663
+ )
664
+ }
665
+
666
+ const attachmentEntityUrl = entityUrlFromAttachmentStreamPath(streamPath)
667
+ if (attachmentEntityUrl) {
668
+ const attachmentEntity = registry?.getEntity
669
+ ? await registry.getEntity(attachmentEntityUrl)
670
+ : null
671
+ if (!attachmentEntity) {
672
+ return apiError(404, ErrCodeNotFound, `Entity not found`)
673
+ }
674
+ if (
675
+ await canAccessEntity(ctx, attachmentEntity, `read`, request as Request)
676
+ ) {
677
+ return undefined
678
+ }
679
+ return apiError(
680
+ 401,
681
+ ErrCodeUnauthorized,
682
+ `Principal is not allowed to read ${attachmentEntity.url}`
683
+ )
684
+ }
685
+ }
686
+
687
+ const sharedStateId = sharedStateIdFromPath(streamPath)
688
+ if (!sharedStateId) {
689
+ // Durable Streams also hosts non-Agents utility streams. Entity streams,
690
+ // attachment streams, and shared-state streams are guarded above; paths that
691
+ // do not match those resource classes are intentionally passed through.
692
+ return undefined
693
+ }
694
+
695
+ if (method === `GET` || method === `HEAD`) {
696
+ if (
697
+ await canAccessSharedState(ctx, sharedStateId, `read`, request as Request)
698
+ ) {
699
+ return undefined
700
+ }
701
+ return apiError(
702
+ 401,
703
+ ErrCodeUnauthorized,
704
+ `Principal is not allowed to read shared state`
705
+ )
706
+ }
707
+
708
+ if (method === `PUT` || method === `POST`) {
709
+ const ownerEntityUrl =
710
+ request.headers.get(SHARED_STATE_OWNER_ENTITY_HEADER)?.trim() || undefined
711
+ if (
712
+ await canAccessSharedState(
713
+ ctx,
714
+ sharedStateId,
715
+ `write`,
716
+ request as Request,
717
+ ownerEntityUrl
718
+ )
719
+ ) {
720
+ return undefined
721
+ }
722
+ return apiError(
723
+ 401,
724
+ ErrCodeUnauthorized,
725
+ `Principal is not allowed to write shared state`
726
+ )
727
+ }
728
+
729
+ return apiError(
730
+ 401,
731
+ ErrCodeUnauthorized,
732
+ `Principal is not allowed to access shared state`
733
+ )
734
+ }
735
+
736
+ function entityUrlFromAttachmentStreamPath(path: string): string | null {
737
+ const match = path.match(/^\/([^/]+)\/([^/]+)\/attachments\/[^/]+$/)
738
+ if (!match) return null
739
+ return `/${match[1]}/${match[2]}`
740
+ }
741
+
742
+ function sharedStateIdFromPath(path: string): string | null {
743
+ const match = path.match(/^\/_electric\/shared-state\/([^/]+)$/)
744
+ if (!match) return null
745
+ try {
746
+ return decodeURIComponent(match[1]!)
747
+ } catch {
748
+ return match[1]!
749
+ }
750
+ }
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { Router } from 'itty-router'
7
7
  import { apiError, responseHeaders } from '../electric-agents-http.js'
8
+ import { isPermissionBypassPrincipal } from '../permissions.js'
8
9
  import { buildElectricProxyTarget } from '../utils/server-utils.js'
9
10
  import type { IRequest, RouterType } from 'itty-router'
10
11
  import type { TenantContext } from './context.js'
@@ -33,12 +34,15 @@ async function proxyElectric(
33
34
  return apiError(500, `ELECTRIC_PROXY_FAILED`, `Electric URL not configured`)
34
35
  }
35
36
 
37
+ await ctx.entityManager.registry.pruneExpiredPermissionGrants?.()
36
38
  const target = buildElectricProxyTarget({
37
39
  incomingUrl: new URL(request.url),
38
40
  electricUrl: ctx.electricUrl,
39
41
  electricSecret: ctx.electricSecret,
40
42
  tenantId: ctx.service,
41
43
  principalUrl: ctx.principal.url,
44
+ principalKind: ctx.principal.kind,
45
+ permissionBypass: isPermissionBypassPrincipal(ctx),
42
46
  })
43
47
  const headers = new Headers(request.headers)
44
48
  headers.delete(`host`)