fix(audit): resolve agent api key and device actors (#557)

* fix(audit): resolve agent api key and device actors

* fix(deps): address high severity advisories

* test(audit): cover actor identity boundaries

* fix(audit): support Cloudflare agent info fetches

* docs: add audit actor preview evidence

* docs: add api key audit preview evidence
This commit is contained in:
Jasper Van
2026-08-07 23:55:22 -04:00
committed by GitHub
parent a0b3d9cad6
commit 1b7d8d55f5
36 changed files with 1314 additions and 143 deletions
@@ -0,0 +1,149 @@
import { createServer, type RequestListener } from 'node:http'
import { afterEach, describe, expect, it } from 'vitest'
import { auditActorIdentityKey } from '../../usecases/ports'
import { createAgentInfoGateway } from './agent-info'
const servers: Array<ReturnType<typeof createServer>> = []
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))))
})
describe('Agent Info gateway', () => {
it('discovers and caches an Agent profile for a trusted issuer', async () => {
let discoveryRequests = 0
let agentInfoRequests = 0
const redirects: RequestRedirect[] = []
const { origin } = await listen((request, response) => {
if (request.url === '/api/auth/.well-known/openid-configuration') {
discoveryRequests += 1
response.setHeader('content-type', 'application/json')
response.end(
JSON.stringify({ issuer: `${origin}/api/auth`, agentinfo_endpoint: `${origin}/api/auth/agentinfo` }),
)
return
}
if (request.url?.startsWith('/api/auth/agentinfo?')) {
agentInfoRequests += 1
const subject = new URL(request.url, origin).searchParams.get('sub')
response.setHeader('content-type', 'application/json')
response.setHeader('cache-control', 'public, max-age=300')
response.end(
JSON.stringify({
iss: `${origin}/api/auth`,
sub: subject,
name: subject === 'agt_1' ? 'Mac Agent' : 'Second Agent',
picture: `${origin}/agent.svg`,
updated_at: 1,
}),
)
return
}
response.statusCode = 404
response.end()
})
const gateway = createAgentInfoGateway((input, init) => {
if (init?.redirect) redirects.push(init.redirect)
return fetch(input, init)
})
const identity = { type: 'oauth', ref: 'agt_1', issuer: `${origin}/api/auth` } as const
const secondIdentity = { type: 'oauth', ref: 'agt_2', issuer: `${origin}/api/auth` } as const
const trustedOrigins = new Set([origin])
const first = await gateway.resolve([identity, secondIdentity], trustedOrigins)
const second = await gateway.resolve([identity, secondIdentity], trustedOrigins)
expect(first.get(auditActorIdentityKey(identity))).toEqual({
name: 'Mac Agent',
image: `${origin}/agent.svg`,
resolved: true,
})
expect(second).toEqual(first)
expect(discoveryRequests).toBe(1)
expect(agentInfoRequests).toBe(2)
expect(redirects).toEqual(['manual', 'manual', 'manual'])
})
it('does not contact an untrusted issuer', async () => {
let requests = 0
const { origin } = await listen((_request, response) => {
requests += 1
response.end('{}')
})
const gateway = createAgentInfoGateway()
const identity = { type: 'oauth', ref: 'agt_1', issuer: `${origin}/api/auth` } as const
const profiles = await gateway.resolve([identity], new Set())
expect(profiles.size).toBe(0)
expect(requests).toBe(0)
})
it('rejects an Agent Info response for a different subject', async () => {
const { origin } = await listen((request, response) => {
response.setHeader('content-type', 'application/json')
if (request.url === '/api/auth/.well-known/openid-configuration') {
response.end(
JSON.stringify({ issuer: `${origin}/api/auth`, agentinfo_endpoint: `${origin}/api/auth/agentinfo` }),
)
return
}
response.end(JSON.stringify({ iss: `${origin}/api/auth`, sub: 'agt_other', name: 'Wrong Agent' }))
})
const gateway = createAgentInfoGateway()
const identity = { type: 'oauth', ref: 'agt_1', issuer: `${origin}/api/auth` } as const
const profiles = await gateway.resolve([identity], new Set([origin]))
expect(profiles.size).toBe(0)
})
it('rejects invalid discovery documents and ignores incomplete actor identities', async () => {
let requests = 0
const { origin } = await listen((_request, response) => {
requests += 1
response.statusCode = 503
response.end()
})
const gateway = createAgentInfoGateway()
const profiles = await gateway.resolve(
[
{ type: 'user', ref: 'user-1', issuer: origin },
{ type: 'agent', ref: null, issuer: origin },
{ type: 'agent', ref: 'agt-1', issuer: null },
{ type: 'agent', ref: 'agt-1', issuer: `${origin}/api/auth` },
],
new Set([origin]),
)
expect(profiles.size).toBe(0)
expect(requests).toBe(1)
})
it('rejects Agent Info endpoints on a different origin', async () => {
const { origin } = await listen((_request, response) => {
response.setHeader('content-type', 'application/json')
response.end(
JSON.stringify({ issuer: `${origin}/api/auth`, agentinfo_endpoint: 'https://untrusted.example/agentinfo' }),
)
})
const gateway = createAgentInfoGateway()
const profiles = await gateway.resolve(
[{ type: 'agent', ref: 'agt-1', issuer: `${origin}/api/auth` }],
new Set([origin]),
)
expect(profiles.size).toBe(0)
})
})
async function listen(handler: RequestListener): Promise<{ origin: string }> {
const server = createServer(handler)
servers.push(server)
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (!address || typeof address === 'string') throw new Error('test_server_address_missing')
return { origin: `http://127.0.0.1:${address.port}` }
}
+178
View File
@@ -0,0 +1,178 @@
import { z } from 'zod'
import {
type AgentInfoGateway,
type AuditActorIdentity,
type AuditActorProfile,
auditActorIdentityKey,
} from '../../usecases/ports'
const DISCOVERY_TTL_MS = 5 * 60 * 1000
const PROFILE_TTL_MS = 5 * 60 * 1000
const REQUEST_TIMEOUT_MS = 3_000
const MAX_CACHE_ENTRIES = 500
const MAX_CONCURRENT_REQUESTS = 8
const discoverySchema = z.object({
issuer: z.string().url(),
agentinfo_endpoint: z.string().url(),
})
const agentInfoSchema = z.object({
iss: z.string().url(),
sub: z.string().min(1),
name: z.string().min(1),
picture: z.string().url().nullable().optional(),
updated_at: z.number().optional(),
})
type CacheEntry<T> = { value: T; expiresAt: number }
export function createAgentInfoGateway(request: typeof fetch = fetch): AgentInfoGateway {
const discoveryCache = new Map<string, CacheEntry<string>>()
const discoveryInflight = new Map<string, Promise<string | null>>()
const profileCache = new Map<string, CacheEntry<AuditActorProfile>>()
return {
async resolve(actors, trustedIssuerOrigins) {
const profiles = new Map<string, AuditActorProfile>()
const uniqueActors = uniqueAgentActors(actors)
await inBatches(uniqueActors, MAX_CONCURRENT_REQUESTS, async (actor) => {
const issuer = trustedIssuer(actor.issuer, trustedIssuerOrigins)
if (!issuer || !actor.ref) return
const key = auditActorIdentityKey(actor)
const cached = readCache(profileCache, key)
if (cached) {
profiles.set(key, cached)
return
}
const profile = await loadAgentProfile(request, issuer, actor.ref, discoveryCache, discoveryInflight)
if (!profile) return
profiles.set(key, profile)
writeCache(profileCache, key, profile, PROFILE_TTL_MS)
})
return profiles
},
}
}
function uniqueAgentActors(actors: readonly AuditActorIdentity[]): AuditActorIdentity[] {
const unique = new Map<string, AuditActorIdentity>()
for (const actor of actors) {
if ((actor.type !== 'oauth' && actor.type !== 'agent') || !actor.ref || !actor.issuer) continue
unique.set(auditActorIdentityKey(actor), actor)
}
return [...unique.values()]
}
function trustedIssuer(value: string | null, trustedOrigins: ReadonlySet<string>): URL | null {
if (!value) return null
const issuer = parseSecureUrl(value)
return issuer && trustedOrigins.has(issuer.origin) ? issuer : null
}
async function loadAgentProfile(
request: typeof fetch,
issuer: URL,
subject: string,
discoveryCache: Map<string, CacheEntry<string>>,
discoveryInflight: Map<string, Promise<string | null>>,
): Promise<AuditActorProfile | null> {
try {
const endpoint = await agentInfoEndpoint(request, issuer, discoveryCache, discoveryInflight)
if (!endpoint) return null
const url = new URL(endpoint)
url.searchParams.set('sub', subject)
const response = await request(url, {
headers: { Accept: 'application/json' },
redirect: 'manual',
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
if (!response.ok || !response.headers.get('content-type')?.includes('application/json')) return null
const parsed = agentInfoSchema.safeParse(await response.json())
if (!parsed.success || parsed.data.iss !== issuer.href.replace(/\/$/, '') || parsed.data.sub !== subject)
return null
return { name: parsed.data.name, image: parsed.data.picture ?? null, resolved: true }
} catch {
return null
}
}
async function agentInfoEndpoint(
request: typeof fetch,
issuer: URL,
cache: Map<string, CacheEntry<string>>,
inflight: Map<string, Promise<string | null>>,
): Promise<string | null> {
const issuerValue = issuer.href.replace(/\/$/, '')
const cached = readCache(cache, issuerValue)
if (cached) return cached
const existing = inflight.get(issuerValue)
if (existing) return existing
const requestPromise = loadAgentInfoEndpoint(request, issuer, issuerValue, cache)
inflight.set(issuerValue, requestPromise)
try {
return await requestPromise
} finally {
if (inflight.get(issuerValue) === requestPromise) inflight.delete(issuerValue)
}
}
async function loadAgentInfoEndpoint(
request: typeof fetch,
issuer: URL,
issuerValue: string,
cache: Map<string, CacheEntry<string>>,
): Promise<string | null> {
const discoveryUrl = new URL(`${issuerValue}/.well-known/openid-configuration`)
const response = await request(discoveryUrl, {
headers: { Accept: 'application/json' },
redirect: 'manual',
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
if (!response.ok || !response.headers.get('content-type')?.includes('application/json')) return null
const parsed = discoverySchema.safeParse(await response.json())
if (!parsed.success || parsed.data.issuer !== issuerValue) return null
const endpoint = parseSecureUrl(parsed.data.agentinfo_endpoint)
if (!endpoint || endpoint.origin !== issuer.origin) return null
writeCache(cache, issuerValue, endpoint.href, DISCOVERY_TTL_MS)
return endpoint.href
}
function parseSecureUrl(value: string): URL | null {
try {
const url = new URL(value)
if (url.protocol === 'https:') return url
if (
url.protocol === 'http:' &&
(url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]')
) {
return url
}
return null
} catch {
return null
}
}
function readCache<T>(cache: Map<string, CacheEntry<T>>, key: string): T | null {
const entry = cache.get(key)
if (!entry) return null
if (entry.expiresAt > Date.now()) return entry.value
cache.delete(key)
return null
}
function writeCache<T>(cache: Map<string, CacheEntry<T>>, key: string, value: T, ttlMs: number): void {
if (cache.size >= MAX_CACHE_ENTRIES) {
const oldest = cache.keys().next()
if (!oldest.done) cache.delete(oldest.value)
}
cache.set(key, { value, expiresAt: Date.now() + ttlMs })
}
async function inBatches<T>(items: readonly T[], size: number, operation: (item: T) => Promise<void>): Promise<void> {
for (let offset = 0; offset < items.length; offset += size) {
await Promise.all(items.slice(offset, offset + size).map(operation))
}
}
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest'
import { apikey, oauthClient } from '../../db/auth-schema'
import { downloaders } from '../../db/schema'
import { createTestApp } from '../../test/setup'
import { createAuditActorDirectoryRepo } from './audit-actor-directory'
describe('audit actor directory repository', () => {
it('resolves API key names in one local lookup', async () => {
const { db } = await createTestApp()
await db.insert(apikey).values({
id: 'key-1',
configId: 'remote-download',
name: 'CME downloader',
referenceId: 'user-1',
key: 'hashed-secret',
createdAt: new Date(0),
updatedAt: new Date(0),
})
const directory = createAuditActorDirectoryRepo(db)
await expect(directory.findApiKeyNames(['key-1', 'missing'])).resolves.toEqual(
new Map([['key-1', 'CME downloader']]),
)
})
it('resolves device names in one local lookup', async () => {
const { db } = await createTestApp()
await db.insert(downloaders).values({
id: 'device-1',
name: 'Office Mac',
tokenHash: 'hashed-token',
tokenJti: 'device-token-jti',
createdBy: 'user-1',
createdAt: new Date(0),
updatedAt: new Date(0),
})
const directory = createAuditActorDirectoryRepo(db)
await expect(directory.findDeviceNames(['device-1', 'missing'])).resolves.toEqual(
new Map([['device-1', 'Office Mac']]),
)
})
it('trusts only secure issuer origins backed by an enabled registered client', async () => {
const { db } = await createTestApp()
await db.insert(oauthClient).values([
{
id: 'oauth-client-enabled',
clientId: 'realmroot',
redirectUris: '[]',
jwksUri: 'https://id.realmroot.dev/api/auth/jwks',
},
{
id: 'oauth-client-disabled',
clientId: 'disabled',
redirectUris: '[]',
jwksUri: 'https://disabled.example/jwks',
disabled: true,
},
{
id: 'oauth-client-insecure',
clientId: 'insecure',
redirectUris: '[]',
jwksUri: 'http://issuer.example/jwks',
},
{
id: 'oauth-client-local',
clientId: 'local',
redirectUris: '[]',
jwksUri: 'http://127.0.0.1:8787/jwks',
},
{
id: 'oauth-client-invalid',
clientId: 'invalid',
redirectUris: '[]',
jwksUri: 'not a URL',
},
])
const directory = createAuditActorDirectoryRepo(db)
await expect(directory.listTrustedAgentIssuerOrigins()).resolves.toEqual(
new Set(['https://id.realmroot.dev', 'http://127.0.0.1:8787']),
)
})
it('skips database queries for empty identity lists', async () => {
const { db } = await createTestApp()
const directory = createAuditActorDirectoryRepo(db)
await expect(directory.findApiKeyNames([])).resolves.toEqual(new Map())
await expect(directory.findDeviceNames([])).resolves.toEqual(new Map())
})
})
@@ -0,0 +1,58 @@
import { inArray } from 'drizzle-orm'
import { apikey, oauthClient } from '../../db/auth-schema'
import { downloaders } from '../../db/schema'
import type { Database } from '../../platform/interface'
import type { AuditActorDirectory } from '../../usecases/ports'
export function createAuditActorDirectoryRepo(db: Database): AuditActorDirectory {
return {
async findApiKeyNames(keyIds) {
const uniqueIds = [...new Set(keyIds)]
if (uniqueIds.length === 0) return new Map()
const rows = await db
.select({ id: apikey.id, name: apikey.name })
.from(apikey)
.where(inArray(apikey.id, uniqueIds))
return new Map(rows.flatMap((row) => (row.name ? [[row.id, row.name] as const] : [])))
},
async findDeviceNames(deviceIds) {
const uniqueIds = [...new Set(deviceIds)]
if (uniqueIds.length === 0) return new Map()
const rows = await db
.select({ id: downloaders.id, name: downloaders.name })
.from(downloaders)
.where(inArray(downloaders.id, uniqueIds))
return new Map(rows.map((row) => [row.id, row.name] as const))
},
async listTrustedAgentIssuerOrigins() {
const clients = await db
.select({ disabled: oauthClient.disabled, jwksUri: oauthClient.jwksUri })
.from(oauthClient)
const origins = new Set<string>()
for (const client of clients) {
if (client.disabled === true || !client.jwksUri) continue
const url = parseSecureUrl(client.jwksUri)
if (url) origins.add(url.origin)
}
return origins
},
}
}
function parseSecureUrl(value: string): URL | null {
try {
const url = new URL(value)
if (url.protocol === 'https:') return url
if (
url.protocol === 'http:' &&
(url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]')
) {
return url
}
return null
} catch {
return null
}
}
+30 -3
View File
@@ -4,7 +4,7 @@ import { organization, user } from '../../db/auth-schema'
import { auditEvents } from '../../db/schema'
import { assertAuditEvent } from '../../domain/audit-events'
import type { Database } from '../../platform/interface'
import type { AuditActorType, AuditRepo, RecordAuditEventInput } from '../../usecases/ports'
import type { AuditActorProfile, AuditActorType, AuditRepo, RecordAuditEventInput } from '../../usecases/ports'
export function auditEventValues(event: RecordAuditEventInput): typeof auditEvents.$inferInsert {
assertAuditEvent(event)
@@ -60,13 +60,16 @@ export function idempotentSystemEventValues(input: {
function normalizeActorType(value: string | null, userId?: string | null): AuditActorType {
// Audit rows written before OAuth was generalized used the old compound actor type.
if (value === ['agent', 'oauth'].join('_')) return 'oauth'
// Downloader records represent device identities. Keep the storage vocabulary
// compatible while exposing the product-level actor consistently.
if (value === 'downloader') return 'device'
if (
value === 'api_key' ||
value === 'oauth' ||
value === 'agent' ||
value === 'anonymous' ||
value === 'system' ||
value === 'downloader' ||
value === 'device' ||
value === 'task-upload'
)
return value
@@ -80,11 +83,33 @@ function actorDisplayName(actorType: AuditActorType, actorRef: string | null): s
if (actorType === 'oauth') return actorRef ? `OAuth:${actorRef}` : 'OAuth'
if (actorType === 'agent') return actorRef ? `Agent:${actorRef}` : 'Agent'
if (actorType === 'system') return actorRef ? `System:${actorRef}` : 'System'
if (actorType === 'downloader') return actorRef ? `Downloader:${actorRef}` : 'Downloader'
if (actorType === 'device') return actorRef ? `Device · ${actorRef}` : 'Device'
if (actorType === 'task-upload') return actorRef ? `Task upload:${actorRef}` : 'Task upload'
return ''
}
function actorProfile(
actorType: AuditActorType,
actorRef: string | null,
userId: string | null,
userName: string | null,
userImage: string | null,
): AuditActorProfile {
if (actorType === 'user') {
return { name: userName ?? userId ?? 'User', image: userImage, resolved: userName !== null }
}
if (actorType === 'api_key') {
return { name: actorRef ? `API key · ${actorRef}` : 'API key', image: null, resolved: false }
}
if (actorType === 'oauth' || actorType === 'agent') {
return { name: actorRef ? `Agent · ${actorRef}` : 'Agent', image: null, resolved: false }
}
if (actorType === 'device') {
return { name: actorRef ? `Device · ${actorRef}` : 'Device', image: null, resolved: false }
}
return { name: actorDisplayName(actorType, actorRef), image: null, resolved: true }
}
export function createAuditRepo(db: Database): AuditRepo {
return {
async record(event) {
@@ -156,6 +181,7 @@ export function createAuditRepo(db: Database): AuditRepo {
name: row.userName ?? actorDisplayName(actorType, row.actorRef),
image: row.userImage ?? null,
},
actor: actorProfile(actorType, row.actorRef, row.userId, row.userName, row.userImage),
}
})
@@ -229,6 +255,7 @@ export function createAuditRepo(db: Database): AuditRepo {
name: row.userName ?? actorDisplayName(actorType, row.actorRef),
image: row.userImage ?? null,
},
actor: actorProfile(actorType, row.actorRef, row.userId, row.userName, row.userImage),
orgName: row.orgName ?? null,
}
})
+4
View File
@@ -6,6 +6,7 @@
import { type CloudflareKvNamespaceLike, createCloudflareKvBackend } from './adapters/cache/cloudflare-kv'
import { createRuntimeCache, resolveCacheMode } from './adapters/cache/runtime-cache'
import { createAgentInfoGateway } from './adapters/gateways/agent-info'
import { createArchiveJobsGateway } from './adapters/gateways/archive-jobs'
import { createEmailGateway } from './adapters/gateways/email'
import { createImageUploadGateway } from './adapters/gateways/image-upload'
@@ -19,6 +20,7 @@ import { createAnnouncementRepo } from './adapters/repos/announcement'
import { createApiKeyGateway } from './adapters/repos/api-keys'
import { createArchiveTargetFolderRepo } from './adapters/repos/archive-target-folder'
import { createAuditRepo } from './adapters/repos/audit'
import { createAuditActorDirectoryRepo } from './adapters/repos/audit-actor-directory'
import { createBackgroundJobRepo } from './adapters/repos/background-job'
import { createCloudStoreRepo } from './adapters/repos/cloud-store'
import { createCloudTrafficReportRepo } from './adapters/repos/cloud-traffic-report'
@@ -81,6 +83,8 @@ export function createDeps(platform: Platform, options: CreateDepsOptions = {}):
const downloadTokens = createDownloadTokenGateway()
return {
audit: createAuditRepo(db),
auditActorDirectory: createAuditActorDirectoryRepo(db),
agentInfo: createAgentInfoGateway(),
adminStats: createAdminStatsRepo(db),
oauth: createOAuthGateway(),
announcements: createAnnouncementRepo(db),
@@ -1065,6 +1065,18 @@ describe('Download tasks API integration', () => {
expect(confirmRes.status).toBe(200)
const confirmed = (await confirmRes.json()) as { id: string; status: string }
expect(confirmed.status).toBe('active')
const uploadAudit = await db.all<{ actorType: string; actorRef: string | null; userId: string | null }>(sql`
SELECT actor_type AS actorType, actor_ref AS actorRef, user_id AS userId
FROM audit_events
WHERE action = 'upload_confirm' AND target_id = ${object.id}
`)
expect(uploadAudit).toEqual([
{
actorType: 'device',
actorRef: createdDownloader.downloader.id,
userId: createdTask.createdBy,
},
])
const uploadingRes = await app.request(`/api/downloads/tasks/${createdTask.id}`, {
method: 'PATCH',
+150 -2
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { adminHeaders, authedHeaders, createTestApp, seedProLicense } from '../../test/setup.js'
import { auditActorIdentityKey } from '../../usecases/ports.js'
describe('GET /api/site/audit-events — auth guards', () => {
it('returns 401 without auth [spec: audit/auth-required]', async () => {
@@ -50,8 +51,153 @@ describe('GET /api/site/audit-events — licensed admin', () => {
const res = await app.request('/api/site/audit-events?action=objects_list', { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as { items: Array<{ actorType: string; user: { name: string } }> }
expect(body.items[0]).toMatchObject({ actorType: 'oauth', user: { name: 'OAuth:controller-1' } })
const body = (await res.json()) as {
items: Array<{ actorType: string; user: { name: string }; actor: { name: string; resolved: boolean } }>
}
expect(body.items[0]).toMatchObject({
actorType: 'oauth',
user: { name: 'OAuth:controller-1' },
actor: { name: 'Agent · controller-1', resolved: false },
})
})
it('returns the API key name as the actor instead of the delegated user', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await adminHeaders(app)
const { apikey } = await import('../../db/auth-schema.js')
const { auditEvents } = await import('../../db/schema.js')
await db.insert(apikey).values({
id: 'key-cme',
configId: 'remote-download',
name: 'CME downloader',
referenceId: 'admin-user',
key: 'hashed-secret',
createdAt: new Date(0),
updatedAt: new Date(0),
})
await db.insert(auditEvents).values({
id: 'evt-api-key',
orgId: 'org-cme',
userId: 'admin-user',
actorType: 'api_key',
actorRef: 'key-cme',
action: 'download_task_created',
targetType: 'download_task',
targetId: 'task-1',
targetName: 'task-1',
createdAt: new Date(),
})
const res = await app.request('/api/site/audit-events?action=download_task_created', { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as {
items: Array<{ user: { name: string }; actor: { name: string; image: string | null; resolved: boolean } }>
}
expect(body.items[0].actor).toEqual({ name: 'API key · CME downloader', image: null, resolved: true })
expect(body.items[0].user.name).not.toBe(body.items[0].actor.name)
})
it('returns the registered device name and normalizes the legacy downloader actor type', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await adminHeaders(app)
const { auditEvents, downloaders } = await import('../../db/schema.js')
await db.insert(downloaders).values({
id: 'device-office-mac',
name: 'Office Mac',
tokenHash: 'hashed-device-token',
tokenJti: 'device-token-jti',
createdBy: 'admin-user',
createdAt: new Date(0),
updatedAt: new Date(0),
})
await db.insert(auditEvents).values({
id: 'evt-device-upload',
orgId: 'org-device',
userId: 'admin-user',
actorType: 'downloader',
actorRef: 'device-office-mac',
action: 'upload_confirm',
targetType: 'file',
targetId: 'file-device-upload',
targetName: 'downloaded.txt',
createdAt: new Date(),
})
const res = await app.request('/api/site/audit-events?action=upload_confirm', { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as {
items: Array<{
actorType: string
actorRef: string
actor: { name: string; image: string | null; resolved: boolean }
}>
}
expect(body.items[0]).toMatchObject({
actorType: 'device',
actorRef: 'device-office-mac',
actor: { name: 'Device · Office Mac', image: null, resolved: true },
})
await db.delete(downloaders)
const missingDeviceRes = await app.request('/api/site/audit-events?action=upload_confirm', { headers })
const missingDeviceBody = (await missingDeviceRes.json()) as {
items: Array<{ actor: { name: string; resolved: boolean } }>
}
expect(missingDeviceBody.items[0].actor).toEqual({
name: 'Device · device-office-mac',
image: null,
resolved: false,
})
})
it('returns the resolved Agent name and image while retaining the delegated user', async () => {
const { app, db, deps } = await createTestApp()
await seedProLicense(db)
const headers = await adminHeaders(app)
const { auditEvents } = await import('../../db/schema.js')
const identity = { type: 'oauth', ref: 'agt_1', issuer: 'https://id.realmroot.dev/api/auth' } as const
deps.auditActorDirectory.listTrustedAgentIssuerOrigins = async () => new Set(['https://id.realmroot.dev'])
deps.agentInfo.resolve = async () =>
new Map([
[
auditActorIdentityKey(identity),
{
name: 'Mac Agent',
image: 'https://id.realmroot.dev/agent-picture-v1.svg',
resolved: true,
},
],
])
await db.insert(auditEvents).values({
id: 'evt-agent',
orgId: 'org-agent',
userId: 'admin-user',
actorType: 'oauth',
actorRef: identity.ref,
actorIssuer: identity.issuer,
action: 'upload',
targetType: 'file',
targetId: 'file-1',
targetName: 'agent.txt',
createdAt: new Date(),
})
const res = await app.request('/api/site/audit-events?action=upload', { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as {
items: Array<{ user: { name: string }; actor: { name: string; image: string | null; resolved: boolean } }>
}
expect(body.items[0].actor).toEqual({
name: 'Mac Agent',
image: 'https://id.realmroot.dev/agent-picture-v1.svg',
resolved: true,
})
expect(body.items[0].user.name).not.toBe(body.items[0].actor.name)
})
it('returns an empty list when no events match the filter [spec: audit/empty]', async () => {
@@ -406,6 +552,8 @@ describe('GET /api/site/audit-events — licensed admin', () => {
const item = body.items[0]
expect(item).toHaveProperty('user')
expect((item.user as Record<string, unknown>).name).toBeTruthy()
expect(item).toHaveProperty('actor')
expect(item.actor).toEqual({ name: 'Test User', image: null, resolved: true })
expect(item).toHaveProperty('orgName')
})
})
+2 -1
View File
@@ -13,7 +13,7 @@ const auditEventSchema = z
id: opaqueIdSchema,
orgId: opaqueIdSchema.or(z.literal('')),
userId: opaqueIdSchema.nullable(),
actorType: z.enum(['user', 'api_key', 'oauth', 'agent', 'anonymous', 'system', 'downloader', 'task-upload']),
actorType: z.enum(['user', 'api_key', 'oauth', 'agent', 'anonymous', 'system', 'device', 'task-upload']),
actorRef: z.string().nullable(),
actorIssuer: z.string().nullable(),
action: z.string(),
@@ -23,6 +23,7 @@ const auditEventSchema = z
metadata: z.string().nullable(),
createdAt: z.string(),
user: z.object({ id: opaqueIdSchema.nullable(), name: z.string(), image: z.string().nullable() }),
actor: z.object({ name: z.string(), image: z.string().nullable(), resolved: z.boolean() }),
orgName: z.string().nullable(),
})
.openapi('AuditEvent')
+7 -1
View File
@@ -391,7 +391,12 @@ describe('GET /api/teams/:teamId/activity — happy path', () => {
const res = await app.request(`/api/teams/${orgId}/activity`, { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as {
items: Array<{ id: string; targetName: string; user: { id: string; name: string; image: string | null } }>
items: Array<{
id: string
targetName: string
user: { id: string; name: string; image: string | null }
actor: { name: string; image: string | null; resolved: boolean }
}>
total: number
}
expect(body.total).toBe(1)
@@ -399,6 +404,7 @@ describe('GET /api/teams/:teamId/activity — happy path', () => {
expect(body.items[0].id).toBe('evt-1')
expect(body.items[0].targetName).toBe('document.pdf')
expect(body.items[0].user).toMatchObject({ id: userId, name: 'Test User' })
expect(body.items[0].actor).toEqual({ name: 'Test User', image: null, resolved: true })
})
it('includes all expected activity event fields in each item', async () => {
+2 -1
View File
@@ -77,7 +77,7 @@ const activityEventSchema = z
id: opaqueIdSchema,
orgId: opaqueIdSchema,
userId: opaqueIdSchema.nullable(),
actorType: z.enum(['user', 'api_key', 'oauth', 'agent', 'anonymous', 'system', 'downloader', 'task-upload']),
actorType: z.enum(['user', 'api_key', 'oauth', 'agent', 'anonymous', 'system', 'device', 'task-upload']),
actorRef: z.string().nullable(),
actorIssuer: z.string().nullable(),
action: z.string(),
@@ -87,6 +87,7 @@ const activityEventSchema = z
metadata: z.string().nullable(),
createdAt: z.string(),
user: z.object({ id: opaqueIdSchema.nullable(), name: z.string(), image: z.string().nullable() }),
actor: z.object({ name: z.string(), image: z.string().nullable(), resolved: z.boolean() }),
})
.openapi('AuditEvent')
+49
View File
@@ -3,6 +3,35 @@ import { auditActor } from './audit-actor'
import type { AuthPrincipal } from './platform'
describe('auditActor', () => {
it('records unauthenticated, user, API key, and device principals directly', () => {
expect(auditActor(null)).toEqual({ userId: null, actorType: 'anonymous', actorRef: null, actorIssuer: null })
expect(auditActor({ kind: 'user', userId: 'user-1', orgId: null, authMethod: 'cookie' })).toEqual({
userId: 'user-1',
actorType: 'user',
actorRef: null,
actorIssuer: null,
})
expect(
auditActor({
kind: 'api-key',
userId: 'user-1',
keyId: 'key-1',
configId: 'remote-download',
orgId: null,
scope: { mode: 'user-workspaces' },
permissions: null,
authMethod: 'api-key',
}),
).toEqual({ userId: 'user-1', actorType: 'api_key', actorRef: 'key-1', actorIssuer: null })
expect(
auditActor({
kind: 'downloader',
downloaderId: 'device-1',
authMethod: 'bearer',
}),
).toEqual({ userId: null, actorType: 'device', actorRef: 'device-1', actorIssuer: null })
})
it('records OAuth principals as delegated Agent actors', () => {
const principal: AuthPrincipal = {
kind: 'oauth',
@@ -39,4 +68,24 @@ describe('auditActor', () => {
actorIssuer: null,
})
})
it('records a device as the actor behind a task upload credential', () => {
const principal: AuthPrincipal = {
kind: 'download-task-upload',
downloaderId: 'device-1',
taskId: 'task-1',
orgId: 'org-1',
targetFolder: 'Downloads',
createdByUserId: 'user-1',
scopes: ['objects:create'],
authMethod: 'bearer',
}
expect(auditActor(principal)).toEqual({
userId: 'user-1',
actorType: 'device',
actorRef: 'device-1',
actorIssuer: null,
})
})
})
+3 -3
View File
@@ -20,15 +20,15 @@ export function auditActor(principal: AuthPrincipal | null): AuditActor {
}
}
if (principal.kind === 'downloader') {
return { userId: null, actorType: 'downloader', actorRef: principal.downloaderId, actorIssuer: null }
return { userId: null, actorType: 'device', actorRef: principal.downloaderId, actorIssuer: null }
}
if (principal.kind === 'downloader-bootstrap') {
return { userId: principal.userId, actorType: 'user', actorRef: null, actorIssuer: null }
}
return {
userId: principal.createdByUserId,
actorType: 'task-upload',
actorRef: principal.taskId,
actorType: 'device',
actorRef: principal.downloaderId,
actorIssuer: null,
}
}
+2 -2
View File
@@ -98,7 +98,7 @@ export const authMiddleware = createMiddleware<Env>(async (c, next) => {
userId: taskUpload.createdByUserId,
workspace: { mode: 'bound', orgId: taskUpload.orgId },
grantedScopes: new Set(taskUpload.scopes.filter(isAuthorizationScope)),
actor: { type: 'task-upload', ref: taskUpload.taskId },
actor: { type: 'device', ref: taskUpload.downloaderId },
state: { downloaderId: taskUpload.downloaderId, taskId: taskUpload.taskId },
})
c.set('userId', null)
@@ -119,7 +119,7 @@ export const authMiddleware = createMiddleware<Env>(async (c, next) => {
AuthorizationScope.DOWNLOAD_TASKS_CANCEL,
AuthorizationScope.DOWNLOADERS_UPDATE,
]),
actor: { type: 'downloader', ref: downloader.downloaderId },
actor: { type: 'device', ref: downloader.downloaderId },
state: {},
})
c.set('userId', null)
+2 -2
View File
@@ -460,7 +460,7 @@ describe('evaluateAuthorization', () => {
userId: null,
workspace: { mode: 'none' as const, orgId: null },
grantedScopes: new Set([AuthorizationScope.DOWNLOADERS_UPDATE]),
actor: { type: 'downloader' as const, ref: 'downloader-1' },
actor: { type: 'device' as const, ref: 'downloader-1' },
state: {},
}
const bootstrapContext = {
@@ -527,7 +527,7 @@ describe('evaluateAuthorization', () => {
userId: 'user-1',
workspace: { mode: 'bound' as const, orgId: 'org-1' },
grantedScopes: new Set([AuthorizationScope.OBJECTS_CREATE]),
actor: { type: 'task-upload' as const, ref: 'task-1' },
actor: { type: 'device' as const, ref: 'downloader-1' },
state: { downloaderId: 'downloader-1', taskId: 'task-1' },
}
+2 -2
View File
@@ -129,7 +129,7 @@ export type AuthzContext =
userId: null
workspace: { mode: 'none'; orgId: null }
grantedScopes: ReadonlySet<AuthorizationScope>
actor: { type: 'downloader'; ref: string }
actor: { type: 'device'; ref: string }
state: Record<string, unknown>
}
| {
@@ -145,7 +145,7 @@ export type AuthzContext =
userId: string
workspace: { mode: 'bound'; orgId: string }
grantedScopes: ReadonlySet<AuthorizationScope>
actor: { type: 'task-upload'; ref: string }
actor: { type: 'device'; ref: string }
state: { downloaderId: string; taskId: string }
}
+89
View File
@@ -0,0 +1,89 @@
import {
type AgentInfoGateway,
type AuditActorDirectory,
type AuditActorIdentity,
type AuditActorProfile,
type AuditEventWithUser,
auditActorIdentityKey,
} from './ports'
export async function resolveAuditActorProfiles<T extends AuditEventWithUser>(
deps: { auditActorDirectory: AuditActorDirectory; agentInfo: AgentInfoGateway },
events: T[],
): Promise<T[]> {
const identities = uniqueResolvableIdentities(events)
if (identities.length === 0) return events
const profiles = await resolveProfiles(deps, identities)
return events.map((event) => {
const profile = profiles.get(
auditActorIdentityKey({ type: event.actorType, ref: event.actorRef, issuer: event.actorIssuer }),
)
return profile ? { ...event, actor: profile } : event
})
}
async function resolveProfiles(
deps: { auditActorDirectory: AuditActorDirectory; agentInfo: AgentInfoGateway },
identities: readonly AuditActorIdentity[],
): Promise<ReadonlyMap<string, AuditActorProfile>> {
const profiles = new Map<string, AuditActorProfile>()
const apiKeyActors = identities.flatMap((identity) =>
identity.type === 'api_key' && identity.ref ? [{ identity, ref: identity.ref }] : [],
)
if (apiKeyActors.length > 0) {
const names = await deps.auditActorDirectory.findApiKeyNames(apiKeyActors.map((actor) => actor.ref))
for (const actor of apiKeyActors) {
const name = names.get(actor.ref)
if (name)
profiles.set(auditActorIdentityKey(actor.identity), {
name: `API key · ${name}`,
image: null,
resolved: true,
})
}
}
const deviceActors = identities.flatMap((identity) =>
identity.type === 'device' && identity.ref ? [{ identity, ref: identity.ref }] : [],
)
if (deviceActors.length > 0) {
const names = await deps.auditActorDirectory.findDeviceNames(deviceActors.map((actor) => actor.ref))
for (const actor of deviceActors) {
const name = names.get(actor.ref)
if (name)
profiles.set(auditActorIdentityKey(actor.identity), {
name: `Device · ${name}`,
image: null,
resolved: true,
})
}
}
const agentActors = identities.filter(
(identity) => (identity.type === 'oauth' || identity.type === 'agent') && identity.ref && identity.issuer,
)
if (agentActors.length > 0) {
const trustedOrigins = await deps.auditActorDirectory.listTrustedAgentIssuerOrigins()
const agentProfiles = await deps.agentInfo.resolve(agentActors, trustedOrigins)
for (const [key, profile] of agentProfiles) profiles.set(key, profile)
}
return profiles
}
function uniqueResolvableIdentities(events: readonly AuditEventWithUser[]): AuditActorIdentity[] {
const identities = new Map<string, AuditActorIdentity>()
for (const event of events) {
if (
event.actorType !== 'api_key' &&
event.actorType !== 'device' &&
event.actorType !== 'oauth' &&
event.actorType !== 'agent'
)
continue
if (!event.actorRef) continue
const identity = { type: event.actorType, ref: event.actorRef, issuer: event.actorIssuer }
identities.set(auditActorIdentityKey(identity), identity)
}
return [...identities.values()]
}
+4
View File
@@ -4,10 +4,12 @@
import type {
AdminStatsRepo,
AgentInfoGateway,
AnnouncementRepo,
ApiKeyGateway,
ArchiveJobsGateway,
ArchiveTargetFolderRepo,
AuditActorDirectory,
AuditRepo,
BackgroundJobRepo,
CacheService,
@@ -57,6 +59,8 @@ import type {
export interface Deps {
audit: AuditRepo
auditActorDirectory: AuditActorDirectory
agentInfo: AgentInfoGateway
adminStats: AdminStatsRepo
oauth: OAuthGateway
announcements: AnnouncementRepo
+33 -9
View File
@@ -1,14 +1,6 @@
// Plain, framework-free DTOs and the repository port for audit events.
export type AuditActorType =
| 'user'
| 'api_key'
| 'oauth'
| 'agent'
| 'anonymous'
| 'system'
| 'downloader'
| 'task-upload'
export type AuditActorType = 'user' | 'api_key' | 'oauth' | 'agent' | 'anonymous' | 'system' | 'device' | 'task-upload'
export interface RecordAuditEventInput {
orgId: string
@@ -40,6 +32,7 @@ export interface AuditEvent {
export interface AuditEventWithUser extends AuditEvent {
user: { id: string | null; name: string; image: string | null }
actor: AuditActorProfile
}
export interface AdminAuditEventWithOrg extends AuditEventWithUser {
@@ -79,3 +72,34 @@ export interface AuditRepo {
opts: ListAuditByTargetOpts,
): Promise<{ items: AuditEvent[]; total: number; page: number; pageSize: number }>
}
export interface AuditActorIdentity {
type: AuditActorType
ref: string | null
issuer: string | null
}
export interface AuditActorProfile {
name: string
image: string | null
resolved: boolean
}
export interface AuditActorDirectory {
findApiKeyNames(keyIds: readonly string[]): Promise<ReadonlyMap<string, string>>
findDeviceNames(deviceIds: readonly string[]): Promise<ReadonlyMap<string, string>>
listTrustedAgentIssuerOrigins(): Promise<ReadonlySet<string>>
}
export interface AgentInfoGateway {
// Profiles are display-only and never authoritative. An omitted identity
// tells the caller to retain the stable issuer/subject fallback.
resolve(
actors: readonly AuditActorIdentity[],
trustedIssuerOrigins: ReadonlySet<string>,
): Promise<ReadonlyMap<string, AuditActorProfile>>
}
export function auditActorIdentityKey(actor: AuditActorIdentity): string {
return JSON.stringify([actor.type, actor.issuer, actor.ref])
}
+131 -2
View File
@@ -1,13 +1,21 @@
import { describe, expect, it, vi } from 'vitest'
import type { AuditRepo } from '../ports'
import { type AdminAuditEventWithOrg, type AgentInfoGateway, type AuditRepo, auditActorIdentityKey } from '../ports'
import { listAuditEvents } from './audit'
describe('audit usecase', () => {
it('forwards the query options to listAdminAudit', async () => {
const result = { items: [], total: 0, page: 1, pageSize: 20 }
const listAdminAudit = vi.fn(async () => result)
const resolve = vi.fn(async () => new Map())
const findApiKeyNames = vi.fn(async () => new Map())
const findDeviceNames = vi.fn(async () => new Map())
const listTrustedAgentIssuerOrigins = vi.fn(async () => new Set<string>())
const out = await listAuditEvents(
{ audit: { listAdminAudit } as Pick<AuditRepo, 'listAdminAudit'> },
{
audit: { listAdminAudit } as Pick<AuditRepo, 'listAdminAudit'>,
auditActorDirectory: { findApiKeyNames, findDeviceNames, listTrustedAgentIssuerOrigins },
agentInfo: { resolve } as AgentInfoGateway,
},
{
page: 1,
pageSize: 20,
@@ -16,5 +24,126 @@ describe('audit usecase', () => {
)
expect(out).toBe(result)
expect(listAdminAudit).toHaveBeenCalledWith({ page: 1, pageSize: 20, orgId: 'o1' })
expect(resolve).not.toHaveBeenCalled()
expect(findApiKeyNames).not.toHaveBeenCalled()
expect(findDeviceNames).not.toHaveBeenCalled()
expect(listTrustedAgentIssuerOrigins).not.toHaveBeenCalled()
})
it('uses the resolved Agent profile without replacing the delegated user', async () => {
const event = {
id: 'e1',
orgId: 'o1',
orgName: 'Personal',
userId: 'u1',
actorType: 'oauth',
actorRef: 'agt_1',
actorIssuer: 'https://id.realmroot.dev/api/auth',
action: 'upload',
targetType: 'file',
targetId: 'f1',
targetName: 'agent.txt',
metadata: null,
createdAt: new Date(0),
user: { id: 'u1', name: 'Ambor', image: null },
actor: { name: 'Agent · agt_1', image: null, resolved: false },
} satisfies AdminAuditEventWithOrg
const identity = { type: 'oauth', ref: 'agt_1', issuer: 'https://id.realmroot.dev/api/auth' } as const
const resolved = { name: 'Mac Agent', image: 'https://id.realmroot.dev/agent.svg', resolved: true }
const resolve = vi.fn(async () => new Map([[auditActorIdentityKey(identity), resolved]]))
const out = await listAuditEvents(
{
audit: { listAdminAudit: async () => ({ items: [event], total: 1, page: 1, pageSize: 20 }) },
auditActorDirectory: {
findApiKeyNames: async () => new Map(),
findDeviceNames: async () => new Map(),
listTrustedAgentIssuerOrigins: async () => new Set(['https://id.realmroot.dev']),
},
agentInfo: { resolve },
},
{ page: 1, pageSize: 20 },
)
expect(out.items[0]).toMatchObject({ user: { name: 'Ambor' }, actor: resolved })
expect(resolve).toHaveBeenCalledWith([identity], new Set(['https://id.realmroot.dev']))
})
it('formats the API key name as the actor without calling Agent Info', async () => {
const event = {
id: 'e2',
orgId: 'o1',
orgName: 'Personal',
userId: 'u1',
actorType: 'api_key',
actorRef: 'key-1',
actorIssuer: null,
action: 'download_task_created',
targetType: 'download_task',
targetId: 'task-1',
targetName: 'task-1',
metadata: null,
createdAt: new Date(0),
user: { id: 'u1', name: 'Ambor', image: null },
actor: { name: 'API key · key-1', image: null, resolved: false },
} satisfies AdminAuditEventWithOrg
const resolve = vi.fn(async () => new Map())
const out = await listAuditEvents(
{
audit: { listAdminAudit: async () => ({ items: [event], total: 1, page: 1, pageSize: 20 }) },
auditActorDirectory: {
findApiKeyNames: async () => new Map([['key-1', 'CME downloader']]),
findDeviceNames: async () => new Map(),
listTrustedAgentIssuerOrigins: async () => new Set(),
},
agentInfo: { resolve },
},
{ page: 1, pageSize: 20 },
)
expect(out.items[0]).toMatchObject({
user: { name: 'Ambor' },
actor: { name: 'API key · CME downloader', image: null, resolved: true },
})
expect(resolve).not.toHaveBeenCalled()
})
it('formats the registered device name as the actor behind an upload token', async () => {
const event = {
id: 'e3',
orgId: 'o1',
orgName: 'Personal',
userId: 'u1',
actorType: 'device',
actorRef: 'device-1',
actorIssuer: null,
action: 'upload_confirm',
targetType: 'file',
targetId: 'f1',
targetName: 'downloaded.txt',
metadata: null,
createdAt: new Date(0),
user: { id: 'u1', name: 'Ambor', image: null },
actor: { name: 'Device · device-1', image: null, resolved: false },
} satisfies AdminAuditEventWithOrg
const out = await listAuditEvents(
{
audit: { listAdminAudit: async () => ({ items: [event], total: 1, page: 1, pageSize: 20 }) },
auditActorDirectory: {
findApiKeyNames: async () => new Map(),
findDeviceNames: async () => new Map([['device-1', 'Office Mac']]),
listTrustedAgentIssuerOrigins: async () => new Set(),
},
agentInfo: { resolve: async () => new Map() },
},
{ page: 1, pageSize: 20 },
)
expect(out.items[0]).toMatchObject({
user: { name: 'Ambor' },
actor: { name: 'Device · Office Mac', image: null, resolved: true },
})
})
})
+20 -6
View File
@@ -1,12 +1,26 @@
// The admin audit resource usecase (/api/admin/audit). Reads org-joined
// activity events. A single-port operation today; it lives here so the resource
// has one home and the http handler stays free of deps access.
// activity events and resolves their display-only actor projection. It lives
// here so the resource has one home and the http handler stays free of deps access.
import type { AdminAuditEventWithOrg, AuditRepo, ListAdminAuditOpts } from '../ports'
import { resolveAuditActorProfiles } from '../audit-actors'
import type {
AdminAuditEventWithOrg,
AgentInfoGateway,
AuditActorDirectory,
AuditRepo,
ListAdminAuditOpts,
} from '../ports'
export function listAuditEvents(
deps: { audit: Pick<AuditRepo, 'listAdminAudit'> },
export async function listAuditEvents(
deps: {
audit: Pick<AuditRepo, 'listAdminAudit'>
auditActorDirectory: AuditActorDirectory
agentInfo: AgentInfoGateway
},
opts: ListAdminAuditOpts,
): Promise<{ items: AdminAuditEventWithOrg[]; total: number; page: number; pageSize: number }> {
return deps.audit.listAdminAudit(opts)
const result = await deps.audit.listAdminAudit(opts)
const items = await resolveAuditActorProfiles(deps, result.items)
if (items === result.items) return result
return { ...result, items }
}
+8
View File
@@ -1,5 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
AgentInfoGateway,
AuditActorDirectory,
AuditRepo,
EntitlementResult,
ImageUpload,
@@ -84,6 +86,12 @@ function makeDeps(
) {
const deps: TeamDeps = {
audit: { record: async () => {}, list: async () => ({ items: [], total: 0 }) } as unknown as AuditRepo,
auditActorDirectory: {
findApiKeyNames: async () => new Map(),
findDeviceNames: async () => new Map(),
listTrustedAgentIssuerOrigins: async () => new Set(),
} as AuditActorDirectory,
agentInfo: { resolve: async () => new Map() } as AgentInfoGateway,
org: {
listUserOrgs: async () => [],
listUserWorkspaceCatalog: async () => [],
+10 -2
View File
@@ -16,7 +16,10 @@
// failure outward unchanged so the http layer maps {status} directly.
import type { Platform } from '../platform/interface'
import { resolveAuditActorProfiles } from './audit-actors'
import {
type AgentInfoGateway,
type AuditActorDirectory,
type AuditEventWithUser,
type AuditRepo,
type EntitlementResult,
@@ -39,6 +42,8 @@ export type TeamDeps = {
teamInvites: TeamInviteRepo
org: OrgRepo
audit: AuditRepo
auditActorDirectory: AuditActorDirectory
agentInfo: AgentInfoGateway
imageUpload: ImageUpload
userAdmin: UserAdminRepo
}
@@ -105,7 +110,7 @@ export type ListActivityOutcome =
| { ok: false; reason: 'forbidden' }
export async function listActivity(
deps: Pick<TeamDeps, 'org' | 'audit'>,
deps: Pick<TeamDeps, 'org' | 'audit' | 'auditActorDirectory' | 'agentInfo'>,
params: { teamId: string; userId: string; page: number; pageSize: number },
): Promise<ListActivityOutcome> {
const { teamId, userId, page, pageSize } = params
@@ -114,7 +119,10 @@ export async function listActivity(
return { ok: false, reason: 'forbidden' }
}
const result = await deps.audit.list(teamId, { page, pageSize })
return { ok: true, result }
return {
ok: true,
result: { ...result, items: await resolveAuditActorProfiles(deps, result.items) },
}
}
// ─── User-facing: org logo ───────────────────────────────────────────────────