mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-21 04:59:47 +08:00
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:
@@ -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}` }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' },
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 ───────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user