feat: attribute files and downloads to actors (#559)

* feat: attribute files and downloads to actors

* chore: refresh preview after staging migration

* fix: use a dash for missing actor identity

* fix: complete actor attribution and file creator UI

* fix: refine file creator identity UI
This commit is contained in:
Jasper Van
2026-08-08 11:04:16 -04:00
committed by GitHub
parent 1b7d8d55f5
commit fa19323eda
68 changed files with 7978 additions and 196 deletions
+1
View File
@@ -75,6 +75,7 @@ export function createArchiveJobsGateway(platform: Platform): ArchiveJobsGateway
await processArchiveJob(deps, {
orgId: message.orgId,
userId: message.userId,
createdBy: message.createdBy,
request: message.request,
jobId: message.jobId,
})
@@ -1,10 +1,41 @@
import { describe, expect, it } from 'vitest'
import { apikey, oauthClient } from '../../db/auth-schema'
import { apikey, oauthClient, user } 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 user display profiles in one local lookup', async () => {
const { db } = await createTestApp()
await db.insert(user).values({
id: 'user-profile-1',
name: 'Amber',
email: 'amber-profile@example.com',
image: 'https://example.com/amber.png',
})
const directory = createAuditActorDirectoryRepo(db)
await expect(directory.findUserProfiles(['user-profile-1', 'missing'])).resolves.toEqual(
new Map([['user-profile-1', { name: 'Amber', image: 'https://example.com/amber.png', resolved: true }]]),
)
})
it('falls back to the username when a user has no profile name', async () => {
const { db } = await createTestApp()
await db.insert(user).values({
id: 'username-only-profile',
name: '',
username: 'amber',
displayUsername: 'Amber',
email: 'username-only@example.com',
})
const directory = createAuditActorDirectoryRepo(db)
await expect(directory.findUserProfiles(['username-only-profile'])).resolves.toEqual(
new Map([['username-only-profile', { name: 'Amber', image: null, resolved: true }]]),
)
})
it('resolves API key names in one local lookup', async () => {
const { db } = await createTestApp()
await db.insert(apikey).values({
@@ -89,5 +120,6 @@ describe('audit actor directory repository', () => {
await expect(directory.findApiKeyNames([])).resolves.toEqual(new Map())
await expect(directory.findDeviceNames([])).resolves.toEqual(new Map())
await expect(directory.findUserProfiles([])).resolves.toEqual(new Map())
})
})
+29 -1
View File
@@ -1,11 +1,39 @@
import { inArray } from 'drizzle-orm'
import { apikey, oauthClient } from '../../db/auth-schema'
import { apikey, oauthClient, user } 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 findUserProfiles(userIds) {
const uniqueIds = [...new Set(userIds)]
if (uniqueIds.length === 0) return new Map()
const rows = await db
.select({
id: user.id,
name: user.name,
username: user.username,
displayUsername: user.displayUsername,
image: user.image,
})
.from(user)
.where(inArray(user.id, uniqueIds))
return new Map(
rows.map(
(row) =>
[
row.id,
{
name: row.name || row.displayUsername || row.username || row.id,
image: row.image,
resolved: true,
},
] as const,
),
)
},
async findApiKeyNames(keyIds) {
const uniqueIds = [...new Set(keyIds)]
if (uniqueIds.length === 0) return new Map()
+22 -2
View File
@@ -1,6 +1,6 @@
import { toDownloadTaskListItem } from '@shared/download-task'
import { generateId } from '@shared/ids'
import { downloadTaskRuntimeSchema } from '@shared/schemas'
import { type ActorType, downloadTaskRuntimeSchema } from '@shared/schemas'
import type { DownloadTask, DownloadTaskRuntime } from '@shared/types'
import {
and,
@@ -21,6 +21,7 @@ import {
} from 'drizzle-orm'
import { downloaders, downloadTasks } from '../../db/schema'
import { executeWriteTransaction, executeWriteTransactionWithResults } from '../../db/transaction'
import { fallbackActorAttribution } from '../../domain/actor-attribution'
import { parseDownloadTaskEvents } from '../../domain/download-task-events'
import type { Database } from '../../platform/interface'
import {
@@ -203,6 +204,9 @@ function toRecord(row: DownloadTaskRow): DownloadTaskRecord {
id: row.id,
orgId: row.orgId,
createdByUserId: row.createdByUserId,
requestedByActorType: row.requestedByActorType as DownloadTaskRecord['requestedByActorType'],
requestedByActorRef: row.requestedByActorRef,
requestedByActorIssuer: row.requestedByActorIssuer,
sourceType: row.sourceType,
sourceUri: row.sourceUri,
displayName: row.displayName,
@@ -239,10 +243,19 @@ function toRecord(row: DownloadTaskRow): DownloadTaskRecord {
function toDownloadTask(row: DownloadTaskRow, control?: { action: 'delete'; requestedAt: string }): DownloadTask {
const runtime = parseTaskRuntime(row.runtime)
const requestedBy =
row.requestedByActorType && row.requestedByActorRef
? fallbackActorAttribution({
type: row.requestedByActorType as ActorType,
ref: row.requestedByActorRef,
issuer: row.requestedByActorIssuer,
})
: null
return {
id: row.id,
orgId: row.orgId,
createdBy: row.createdByUserId,
requestedBy,
spec: {
source: {
type: row.sourceType as DownloadTask['spec']['source']['type'],
@@ -261,7 +274,11 @@ function toDownloadTask(row: DownloadTaskRow, control?: { action: 'delete'; requ
state: row.status as DownloadTask['status']['state'],
attempt: row.attempt,
assignment: row.assignedDownloaderId
? { downloaderId: row.assignedDownloaderId, assignedAt: row.assignedAt?.toISOString() ?? null }
? {
downloaderId: row.assignedDownloaderId,
assignedAt: row.assignedAt?.toISOString() ?? null,
executor: fallbackActorAttribution({ type: 'device', ref: row.assignedDownloaderId, issuer: null }),
}
: null,
progress: runtime?.progress ?? emptyTaskProgress(),
billing: {
@@ -361,6 +378,9 @@ export function createDownloadTaskRepo(db: Database): DownloadTaskRepo {
id: input.id,
orgId: input.orgId,
createdByUserId: input.createdByUserId,
requestedByActorType: input.requestedByActorType ?? 'user',
requestedByActorRef: input.requestedByActorRef ?? input.createdByUserId,
requestedByActorIssuer: input.requestedByActorIssuer ?? null,
sourceType: input.sourceType,
sourceUri: input.sourceUri,
displayName: input.displayName,
+6
View File
@@ -310,6 +310,9 @@ export function createMatterRepo(db: Database): MatterRepo {
status: input.status,
trashedAt: null,
purgedAt: null,
createdByActorType: input.createdByActorType ?? null,
createdByActorRef: input.createdByActorRef ?? null,
createdByActorIssuer: input.createdByActorIssuer ?? null,
createdAt: now,
updatedAt: now,
}
@@ -521,6 +524,9 @@ export function createMatterRepo(db: Database): MatterRepo {
status: 'active',
trashedAt: null,
purgedAt: null,
createdByActorType: opts.createdByActorType ?? null,
createdByActorRef: opts.createdByActorRef ?? null,
createdByActorIssuer: opts.createdByActorIssuer ?? null,
createdAt: now,
updatedAt: now,
}
+6
View File
@@ -18,6 +18,9 @@ export const matters = sqliteTable(
status: text('status').notNull().default('draft'), // draft, active
trashedAt: integer('trashed_at'), // null = live, epoch ms = in trash (soft delete)
purgedAt: integer('purged_at'), // null = retained/billable, epoch ms = content permanently removed
createdByActorType: text('created_by_actor_type'),
createdByActorRef: text('created_by_actor_ref'),
createdByActorIssuer: text('created_by_actor_issuer'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
},
@@ -412,6 +415,9 @@ export const downloadTasks = sqliteTable(
id: text('id').primaryKey(),
orgId: text('org_id').notNull(),
createdByUserId: text('created_by_user_id').notNull(),
requestedByActorType: text('requested_by_actor_type'),
requestedByActorRef: text('requested_by_actor_ref'),
requestedByActorIssuer: text('requested_by_actor_issuer'),
sourceType: text('source_type').notNull(),
sourceUri: text('source_uri').notNull(),
displayName: text('display_name'),
+23
View File
@@ -0,0 +1,23 @@
import type { ActorAttribution } from '@shared/schemas'
type ActorIdentity = Pick<ActorAttribution, 'type' | 'ref' | 'issuer'>
type ActorProfile = Pick<ActorAttribution, 'name' | 'image' | 'resolved'>
export function fallbackActorProfile(identity: ActorIdentity): ActorProfile {
const ref = identity.ref ?? 'unknown'
const labels: Partial<Record<ActorIdentity['type'], string>> = {
user: 'User',
api_key: 'API key',
oauth: 'Agent',
agent: 'Agent',
device: 'Device',
system: 'System',
anonymous: 'Anonymous',
'task-upload': 'Task upload',
}
return { name: `${labels[identity.type] ?? 'Actor'} · ${ref}`, image: null, resolved: false }
}
export function fallbackActorAttribution(identity: ActorIdentity): ActorAttribution {
return { ...identity, ...fallbackActorProfile(identity) }
}
@@ -143,6 +143,7 @@ describe('background jobs API', () => {
const created = (await res.json()) as { id: string; status: string }
expect(created.status).toBe('queued')
expect(messages).toHaveLength(1)
expect(messages[0].createdBy).toMatchObject({ type: 'user', issuer: null })
await expect(createBackgroundJobRepo(db).get(orgId, created.id)).resolves.toMatchObject({ status: 'queued' })
await createArchiveJobsGateway(platform).runMessage(messages[0])
+12 -5
View File
@@ -6,7 +6,7 @@ import {
listBackgroundJobsQuerySchema,
opaqueIdSchema,
} from '../../shared/schemas'
import type { Env } from '../middleware/platform'
import { authzActorIdentity, type Env } from '../middleware/platform'
import {
cancelBackgroundJob,
createBackgroundJob,
@@ -203,7 +203,12 @@ const backgroundJobs = app
const orgId = requireOrg(c)
const userId = c.get('userId')
if (!userId) throw new BackgroundJobError('not_found')
return c.json(await createBackgroundJob(c.get('deps'), { orgId, userId, request: c.req.valid('json') }), 201)
const createdBy = authzActorIdentity(c.get('authzContext'))
if (!createdBy) throw new Error('authenticated_actor_missing')
return c.json(
await createBackgroundJob(c.get('deps'), { orgId, userId, createdBy, request: c.req.valid('json') }),
201,
)
})
.openapi(getJobRoute, async (c) =>
c.json(await getBackgroundJob(c.get('deps'), requireOrg(c), c.req.valid('param').id), 200),
@@ -211,8 +216,10 @@ const backgroundJobs = app
.openapi(cancelJobRoute, async (c) =>
c.json(await cancelBackgroundJob(c.get('deps'), requireOrg(c), c.req.valid('param').id), 200),
)
.openapi(retryJobRoute, async (c) =>
c.json(await retryBackgroundJob(c.get('deps'), requireOrg(c), c.req.valid('param').id), 201),
)
.openapi(retryJobRoute, async (c) => {
const createdBy = authzActorIdentity(c.get('authzContext'))
if (!createdBy) throw new Error('authenticated_actor_missing')
return c.json(await retryBackgroundJob(c.get('deps'), requireOrg(c), c.req.valid('param').id, createdBy), 201)
})
export default backgroundJobs
@@ -319,7 +319,7 @@ describe('Download tasks API integration', () => {
expect(new Set(ids)).toHaveLength(3)
})
it('uses the API key owner UID in the object storage key', async () => {
it('uses the API key owner UID in the object storage key [spec: download-tasks/actor-attribution]', async () => {
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
await insertStorage(db)
const admin = await adminHeaders(app)
@@ -359,9 +359,18 @@ describe('Download tasks API integration', () => {
expect(createTaskRes.status).toBe(201)
const task = (await createTaskRes.json()) as DownloadTask
expect(task.createdBy).toBe(identity.userId)
expect(task.requestedBy).toMatchObject({ type: 'api_key', name: 'API key · storage-key-owner', resolved: true })
const downloader = await registerDownloaderThroughDeviceLogin(app, 'api-key-storage-downloader', admin)
const assigned = await claimTaskForDownloader(app, downloader.token, task.id)
const detailRes = await app.request(`/api/downloads/tasks/${task.id}`, { headers: userHeaders })
expect(detailRes.status).toBe(200)
const detail = (await detailRes.json()) as DownloadTask
expect(detail.status.assignment?.executor).toMatchObject({
type: 'device',
name: 'Device · api-key-storage-downloader',
resolved: true,
})
const uploadToken = assigned.status.assignment?.uploadToken
expect(uploadToken).toBeTruthy()
+10 -1
View File
@@ -283,7 +283,16 @@ const downloadTasksRoute = new OpenAPIHono<Env>()
if (!orgId) throw unauthorized()
// API keys carry their owning user's UID in userId, so downloader uploads
// build the same org/uid object path as browser-created tasks.
return c.json(await createDownloadTask(c.get('deps'), orgId, c.get('userId') as string, c.req.valid('json')), 201)
const actor = c.get('authzContext').actor
if (!actor) throw unauthorized()
return c.json(
await createDownloadTask(c.get('deps'), orgId, c.get('userId') as string, c.req.valid('json'), {
type: actor.type,
ref: actor.ref,
issuer: 'issuer' in actor ? actor.issuer : null,
}),
201,
)
})
.openapi(getRoute, async (c) => {
const orgId = c.get('orgId')
+21 -2
View File
@@ -2689,6 +2689,7 @@ async function createUserApiKey(
const result = (await (auth.api as any).createApiKey({
body: {
configId: opts.orgId ? 'ihost' : 'webdav',
name: 'test-api-key',
userId,
...(opts.orgId ? { organizationId: opts.orgId } : {}),
permissions: opts.permissions,
@@ -2803,10 +2804,11 @@ describe('Objects API — error branches', () => {
await expect(res.json()).resolves.toMatchObject({
name: '藏.mp3',
parent: targetFolder,
createdBy: { type: 'device', resolved: true },
})
})
it('creates a folder with a workspace API key that has objects:create', async () => {
it('creates a folder with a workspace API key that has objects:create [spec: objects/creator-attribution]', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
@@ -2820,7 +2822,24 @@ describe('Objects API — error branches', () => {
body: JSON.stringify({ name: 'api-key-folder', type: 'folder', dirtype: 1, parent: '' }),
})
expect(res.status).toBe(201)
await expect(res.json()).resolves.toMatchObject({ name: 'api-key-folder', orgId })
await expect(res.json()).resolves.toMatchObject({
name: 'api-key-folder',
orgId,
createdBy: { type: 'api_key', name: 'API key · test-api-key', resolved: true },
})
})
it('returns null instead of attributing a legacy object to the workspace owner [spec: objects/legacy-creator]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'legacy-creator-file', name: 'legacy.txt' })
const res = await app.request('/api/objects/legacy-creator-file', { headers })
expect(res.status).toBe(200)
await expect(res.json()).resolves.toMatchObject({ id: 'legacy-creator-file', createdBy: null })
})
it('returns 403 when an object API key is missing the route scope', async () => {
+34 -14
View File
@@ -1,6 +1,7 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import {
actorAttributionSchema,
completeObjectUploadSchema,
copyObjectBodySchema,
createMatterSchema,
@@ -29,11 +30,12 @@ import {
type ObjectActor,
ObjectUploadSessionError,
presignUploadSessionParts,
resolveMatterCreators,
transferObject,
trashObject,
updateObject,
} from '../usecases/object'
import { badRequest, forbidden, type Matter, type MatterListItem, quotaExceeded } from '../usecases/ports'
import { badRequest, forbidden, type Matter, type MatterListItem, quotaExceeded, unauthorized } from '../usecases/ports'
import { describeCapacityRequirement } from '../usecases/store/store'
import { recordDownloadIssued } from '../usecases/transfer-activity'
import { authRoute, errorResponse, jsonBody, jsonContent } from './openapi'
@@ -60,6 +62,7 @@ const matterSchema = z
storageId: opaqueIdSchema,
status: z.string(),
trashedAt: z.number().int().nullable(),
createdBy: actorAttributionSchema.nullable(),
createdAt: z.string(),
updatedAt: z.string(),
})
@@ -71,7 +74,7 @@ type MatterDTO = z.infer<typeof matterSchema>
// timestamps to ISO strings, pass everything else through. Its return type is the
// schema's inferred type, so a drift between `Matter` and `matterSchema` is a
// compile error — not a silent lie in the document.
function toMatterDTO(m: Matter): MatterDTO {
function toMatterDTO(m: Matter, createdBy: MatterDTO['createdBy']): MatterDTO {
return {
id: m.id,
orgId: m.orgId,
@@ -85,6 +88,7 @@ function toMatterDTO(m: Matter): MatterDTO {
storageId: m.storageId,
status: m.status,
trashedAt: m.trashedAt,
createdBy,
createdAt: m.createdAt.toISOString(),
updatedAt: m.updatedAt.toISOString(),
}
@@ -101,8 +105,13 @@ const objectListItemSchema = matterSchema
.openapi('ObjectListItem')
type ObjectListItemDTO = z.infer<typeof objectListItemSchema>
function toObjectListItemDTO(item: MatterListItem): ObjectListItemDTO {
return { ...toMatterDTO(item), hasChildren: item.hasChildren }
function toObjectListItemDTO(item: MatterListItem, createdBy: MatterDTO['createdBy']): ObjectListItemDTO {
return { ...toMatterDTO(item, createdBy), hasChildren: item.hasChildren }
}
async function matterDTO(deps: Env['Variables']['deps'], matter: Matter): Promise<MatterDTO> {
const creators = await resolveMatterCreators(deps, [matter])
return toMatterDTO(matter, creators.get(matter.id) ?? null)
}
const objectPageSchema = cursorPageSchema(objectListItemSchema, 'ObjectPage')
@@ -177,9 +186,16 @@ function objectActor(c: Context<Env>): ObjectActor {
taskId: principal.taskId,
targetFolder: principal.targetFolder,
createdByUserId: principal.createdByUserId,
identity: { type: 'device', ref: principal.downloaderId, issuer: null },
}
}
return { kind: 'user', userId: c.get('userId') as string }
const actor = c.get('authzContext').actor
if (!actor) throw unauthorized()
return {
kind: 'user',
userId: c.get('userId') as string,
identity: { type: actor.type, ref: actor.ref, issuer: 'issuer' in actor ? actor.issuer : null },
}
}
// The id recorded in matter/activity logs.
@@ -456,9 +472,10 @@ const objects = app
},
})
if (!result.ok) throw result.error
const creators = await resolveMatterCreators(c.get('deps'), result.result.items)
return c.json(
{
items: result.result.items.map(toObjectListItemDTO),
items: result.result.items.map((item) => toObjectListItemDTO(item, creators.get(item.id) ?? null)),
nextPageToken: await encodeNextPageToken(c.get('platform'), result.result.nextBoundary, {
query: fingerprint,
codec: directoryCursorCodec,
@@ -503,8 +520,9 @@ const objects = app
402,
)
}
if ('upload' in result) return c.json({ ...toMatterDTO(result.matter), upload: result.upload }, 201)
return c.json(toMatterDTO(result.matter), 201)
const matter = await matterDTO(c.get('deps'), result.matter)
if ('upload' in result) return c.json({ ...matter, upload: result.upload }, 201)
return c.json(matter, 201)
})
.openapi(presignPartsRoute, async (c) => {
const orgId = c.get('orgId')
@@ -542,7 +560,7 @@ const objects = app
if ('error' in result) throw result.error // quota exceeded
throw new ObjectUploadSessionError('not_found') // draft gone
}
return c.json(toMatterDTO(result.matter), 200)
return c.json(await matterDTO(c.get('deps'), result.matter), 200)
})
.openapi(abortUploadRoute, async (c) => {
const orgId = c.get('orgId')
@@ -585,9 +603,9 @@ const objects = app
},
result.receipt.trafficEventId,
)
return c.json({ ...toMatterDTO(result.matter), downloadUrl: result.downloadUrl }, 200)
return c.json({ ...(await matterDTO(c.get('deps'), result.matter)), downloadUrl: result.downloadUrl }, 200)
}
return c.json(toMatterDTO(result.matter), 200)
return c.json(await matterDTO(c.get('deps'), result.matter), 200)
}
throw result.error
})
@@ -600,7 +618,7 @@ const objects = app
input: c.req.valid('json'),
})
if (!result.ok) throw result.error
return c.json(toMatterDTO(result.matter), 200)
return c.json(await matterDTO(c.get('deps'), result.matter), 200)
})
// Soft delete: move a live object to trash. Permanent removal is
// DELETE /trash/objects/{id}; discarding a draft is DELETE /{id}/uploads/{sid}.
@@ -622,10 +640,11 @@ const objects = app
const result = await copyObject(c.get('deps'), {
orgId,
userId: c.get('userId')!,
actor: objectActor(c),
input: { copyFrom: c.req.valid('param').id, parent: body.parent, onConflict: body.onConflict },
})
if (!result.ok) throw result.error
return c.json(toMatterDTO(result.matter), 201)
return c.json(await matterDTO(c.get('deps'), result.matter), 201)
})
.openapi(transferObjectRoute, async (c) => {
const orgId = c.get('orgId')
@@ -635,13 +654,14 @@ const objects = app
const result = await transferObject(c.get('deps'), {
orgId,
userId: c.get('userId')!,
actor: objectActor(c),
objectId: c.req.valid('param').id,
input: c.req.valid('json'),
})
if (!result.ok) throw result.error
return c.json(
{
saved: result.result.saved.map(toMatterDTO),
saved: await Promise.all(result.result.saved.map((matter) => matterDTO(c.get('deps'), matter))),
skipped: result.result.skipped,
sourceDeleted: result.result.sourceDeleted,
},
+4 -1
View File
@@ -13,7 +13,7 @@ import {
shareRecipientViewSchema,
} from '../../shared/schemas/share'
import { transferAuditActor } from '../middleware/audit-transfers'
import { boundWorkspaceOrgId, type Env } from '../middleware/platform'
import { authzActorIdentity, boundWorkspaceOrgId, type Env } from '../middleware/platform'
import type { Matter, ShareListItem } from '../usecases/ports'
import { notFound } from '../usecases/ports'
import {
@@ -577,6 +577,8 @@ export const authedShares = authedApp
.openapi(saveShareRoute, async (c) => {
const token = c.req.valid('param').token
const { targetOrgId, targetParent } = c.req.valid('json')
const createdBy = authzActorIdentity(c.get('authzContext'))
if (!createdBy) throw new Error('authenticated_actor_missing')
const out = await saveShare(c.get('deps'), {
token,
currentUserId: c.get('userId')!,
@@ -584,6 +586,7 @@ export const authedShares = authedApp
boundTargetOrgId: boundWorkspaceOrgId(c.get('authzContext')),
targetParent,
accessCookie: getCookie(c, cookieName(token)),
createdBy,
})
if (out.ok) return c.json({ saved: out.result.saved.map(toSavedMatterDTO), skipped: out.result.skipped }, 201)
throw out.error
+9
View File
@@ -99,6 +99,10 @@ type DavAuth = {
permissions: Record<string, string[]> | null
}
function webDavActor(auth: DavAuth) {
return { type: 'api_key' as const, ref: auth.keyId, issuer: null }
}
interface NativeRateLimiter {
limit(options: { key: string }): Promise<{ success: boolean }>
}
@@ -1278,6 +1282,7 @@ async function putFile(c: DavContext, auth: DavAuth): Promise<Response> {
contentType,
contentLength,
body,
createdBy: webDavActor(auth),
onTiming: (phase, durationMs) => c.get('webDavTrace').push(`${phase}:${Math.round(durationMs)}`),
})
c.get('webDavTrace').push(`upload:${Math.round(performance.now() - startedAt)}`)
@@ -1322,6 +1327,7 @@ async function makeCollection(c: DavContext, auth: DavAuth): Promise<Response> {
userId: auth.userId,
name: target.name,
parent: target.parent,
createdBy: webDavActor(auth),
})
return new Response(null, { status: 201 })
} catch (e) {
@@ -1441,6 +1447,7 @@ async function copyMatterRoute(c: DavContext, auth: DavAuth): Promise<Response>
targetMatter: target.matter,
replacingTarget,
depth,
createdBy: webDavActor(auth),
})
if (!result.ok) return c.text('Storage not found', 404)
c.header('Location', matterLocation(c, targetWorkspace.pathSegment, result.location))
@@ -1463,6 +1470,7 @@ async function copyMatterRoute(c: DavContext, auth: DavAuth): Promise<Response>
targetResourcePath: resourcePath(target),
replacedMatterId: target.matter?.id ?? null,
replacingTarget,
createdBy: webDavActor(auth),
})
if (!result.ok) return c.text('Storage not found', 404)
c.header('Location', matterLocation(c, targetWorkspace.pathSegment, result.location))
@@ -1520,6 +1528,7 @@ async function lockMatter(c: DavContext, auth: DavAuth): Promise<Response> {
owner: lockInfo.owner,
depth,
timeoutSeconds: parseTimeout(c.req.header('Timeout')),
createdBy: webDavActor(auth),
})
c.get('webDavTrace').push(`lock:${Math.round(performance.now() - startedAt)}`)
if (!acquired) return xmlResponse(errorXml('no-conflicting-lock'), 423)
+10 -1
View File
@@ -5,7 +5,7 @@ import type { DavLock } from '../domain/webdav'
import type { WebDavMountPath } from '../domain/webdav-public-url'
import type { Platform } from '../platform/interface'
import type { Deps } from '../usecases/deps'
import type { WebDavTarget } from '../usecases/ports'
import type { ActorIdentity, WebDavTarget } from '../usecases/ports'
import type { TransferAuditTarget } from '../usecases/transfer-activity'
export type Env = {
@@ -157,6 +157,15 @@ export function boundWorkspaceOrgId(context: AuthzContext): string | null {
return context.workspace.mode === 'bound' ? context.workspace.orgId : null
}
export function authzActorIdentity(context: AuthzContext): ActorIdentity | null {
if (!context.actor) return null
return {
type: context.actor.type,
ref: context.actor.ref,
issuer: 'issuer' in context.actor ? context.actor.issuer : null,
}
}
export const anonymousAuthzContext = (): AuthzContext => ({
credential: 'anonymous',
userId: null,
+6
View File
@@ -318,6 +318,9 @@ const APP_SCHEMA_SQL = `
status TEXT NOT NULL DEFAULT 'draft',
trashed_at INTEGER,
purged_at INTEGER,
created_by_actor_type TEXT,
created_by_actor_ref TEXT,
created_by_actor_issuer TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
@@ -698,6 +701,9 @@ const APP_SCHEMA_SQL = `
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
created_by_user_id TEXT NOT NULL,
requested_by_actor_type TEXT,
requested_by_actor_ref TEXT,
requested_by_actor_issuer TEXT,
source_type TEXT NOT NULL,
source_uri TEXT NOT NULL,
display_name TEXT,
@@ -222,21 +222,51 @@ describe('archive processing', () => {
const job = await createArchiveJob(archiveDeps(db), {
orgId: ORG_ID,
userId: USER_ID,
createdBy: { type: 'oauth', ref: 'agent-1', issuer: 'https://realm.example.com' },
request: { type: 'archive_extract', matterId: 'zip-matter' },
s3: s3 as unknown as S3Gateway,
})
expect(job).toMatchObject({ status: 'completed', type: 'archive_extract' })
expect(job.progress).toMatchObject({ outputBytes: 5, fileCount: 1 })
const rows = await db.all<{ name: string; parent: string; dirtype: number; size: number; object: string }>(sql`
SELECT name, parent, dirtype, size, object FROM matters
const rows = await db.all<{
name: string
parent: string
dirtype: number
size: number
object: string
createdByActorType: string | null
createdByActorRef: string | null
createdByActorIssuer: string | null
}>(sql`
SELECT name, parent, dirtype, size, object,
created_by_actor_type AS createdByActorType,
created_by_actor_ref AS createdByActorRef,
created_by_actor_issuer AS createdByActorIssuer
FROM matters
WHERE org_id = ${ORG_ID} AND status = 'active'
ORDER BY dirtype DESC, name ASC
`)
expect(rows).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'docs', parent: '', dirtype: 1, size: 0 }),
expect.objectContaining({ name: 'hello.txt', parent: 'docs', dirtype: 0, size: 5 }),
expect.objectContaining({
name: 'docs',
parent: '',
dirtype: 1,
size: 0,
createdByActorType: 'oauth',
createdByActorRef: 'agent-1',
createdByActorIssuer: 'https://realm.example.com',
}),
expect.objectContaining({
name: 'hello.txt',
parent: 'docs',
dirtype: 0,
size: 5,
createdByActorType: 'oauth',
createdByActorRef: 'agent-1',
createdByActorIssuer: 'https://realm.example.com',
}),
]),
)
expect(s3.putKeys).toHaveLength(1)
+16 -2
View File
@@ -3,6 +3,7 @@ import type { CreateBackgroundJobRequest } from '@shared/schemas'
import type { BackgroundJob } from '@shared/types'
import { buildObjectKey } from '../lib/path-template'
import type {
ActorIdentity,
ArchiveTargetFolderRepo,
BackgroundJobRepo,
MatterRepo,
@@ -36,6 +37,7 @@ export type ArchiveProcessingDeps = {
export interface CreateArchiveJobInput {
orgId: string
userId: string
createdBy?: ActorIdentity
request: CreateBackgroundJobRequest
// Overrides deps.s3 for tests; production paths use the wired gateway.
s3?: S3Gateway
@@ -74,12 +76,13 @@ export async function processArchiveJob(
input: CreateArchiveJobInput & { jobId: string },
): Promise<BackgroundJob> {
const s3 = input.s3 ?? deps.s3
const createdBy = input.createdBy ?? { type: 'user', ref: input.userId, issuer: null }
try {
await deps.backgroundJobs.update(input.orgId, input.jobId, { status: 'running', startedAt: new Date() })
const finished =
input.request.type === 'archive_compress'
? await runCompressionJob(deps, s3, input.jobId, input.orgId, input.userId, input.request)
: await runExtractionJob(deps, s3, input.jobId, input.orgId, input.userId, input.request)
? await runCompressionJob(deps, s3, input.jobId, input.orgId, input.userId, createdBy, input.request)
: await runExtractionJob(deps, s3, input.jobId, input.orgId, input.userId, createdBy, input.request)
await notifyArchiveJobFinished(deps, finished)
return finished
} catch (error) {
@@ -100,6 +103,7 @@ async function runCompressionJob(
jobId: string,
orgId: string,
userId: string,
createdBy: ActorIdentity,
request: Extract<CreateBackgroundJobRequest, { type: 'archive_compress' }>,
): Promise<BackgroundJob> {
if (request.targetFolder !== undefined)
@@ -153,6 +157,9 @@ async function runCompressionJob(
storageId: targetStorage.id,
status: 'active',
onConflict: 'rename',
createdByActorType: createdBy.type,
createdByActorRef: createdBy.ref,
createdByActorIssuer: createdBy.issuer,
})
return deps.backgroundJobs.update(orgId, jobId, {
@@ -184,6 +191,7 @@ async function runExtractionJob(
jobId: string,
orgId: string,
userId: string,
createdBy: ActorIdentity,
request: Extract<CreateBackgroundJobRequest, { type: 'archive_extract' }>,
): Promise<BackgroundJob> {
const zipMatter = await deps.matter.get(request.matterId, orgId)
@@ -243,6 +251,9 @@ async function runExtractionJob(
storageId: targetStorage.id,
status: 'active',
onConflict: 'rename',
createdByActorType: createdBy.type,
createdByActorRef: createdBy.ref,
createdByActorIssuer: createdBy.issuer,
})
createdMatterIds.push(matter.id)
})
@@ -284,6 +295,9 @@ async function runExtractionJob(
storageId: targetStorage.id,
status: 'active',
onConflict: 'rename',
createdByActorType: createdBy.type,
createdByActorRef: createdBy.ref,
createdByActorIssuer: createdBy.issuer,
})
createdMatterIds.push(folder.id)
const matterPath = buildMatterPath(folder.parent, folder.name)
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it, vi } from 'vitest'
import { resolveActorAttributions } from './audit-actors'
import { type ActorIdentity, actorIdentityKey } from './ports'
describe('resolveActorAttributions', () => {
it('resolves user, API key, device, and agent identities through the shared directory', async () => {
const identities: ActorIdentity[] = [
{ type: 'user', ref: 'user-1', issuer: null },
{ type: 'api_key', ref: 'key-1', issuer: null },
{ type: 'device', ref: 'device-1', issuer: null },
{ type: 'agent', ref: 'agent-1', issuer: 'https://realm.example.com' },
]
const resolve = vi.fn(
async () =>
new Map([
[
actorIdentityKey(identities[3]!),
{ name: 'Media Agent', image: 'https://realm.example.com/avatar.png', resolved: true },
],
]),
)
const actors = await resolveActorAttributions(
{
auditActorDirectory: {
findUserProfiles: async () =>
new Map([['user-1', { name: 'Amber', image: 'https://example.com/amber.png', resolved: true }]]),
findApiKeyNames: async () => new Map([['key-1', 'zme']]),
findDeviceNames: async () => new Map([['device-1', 'zpan-downloader']]),
listTrustedAgentIssuerOrigins: async () => new Set(['https://realm.example.com']),
},
agentInfo: { resolve },
},
identities,
)
expect(actors.get(actorIdentityKey(identities[0]!))).toMatchObject({ name: 'Amber', image: expect.any(String) })
expect(actors.get(actorIdentityKey(identities[1]!))).toMatchObject({ name: 'API key · zme', resolved: true })
expect(actors.get(actorIdentityKey(identities[2]!))).toMatchObject({
name: 'Device · zpan-downloader',
resolved: true,
})
expect(actors.get(actorIdentityKey(identities[3]!))).toMatchObject({ name: 'Media Agent', resolved: true })
})
it('keeps a stable identity fallback when display metadata is unavailable', async () => {
const identity: ActorIdentity = { type: 'agent', ref: 'agent-missing', issuer: 'https://realm.example.com' }
const actors = await resolveActorAttributions(
{
auditActorDirectory: {
findUserProfiles: async () => new Map(),
findApiKeyNames: async () => new Map(),
findDeviceNames: async () => new Map(),
listTrustedAgentIssuerOrigins: async () => new Set(),
},
agentInfo: { resolve: async () => new Map() },
},
[identity],
)
expect(actors.get(actorIdentityKey(identity))).toEqual({
...identity,
name: 'Agent · agent-missing',
image: null,
resolved: false,
})
})
})
+30 -3
View File
@@ -1,4 +1,7 @@
import { fallbackActorProfile } from '../domain/actor-attribution'
import {
type ActorAttribution,
type ActorIdentity,
type AgentInfoGateway,
type AuditActorDirectory,
type AuditActorIdentity,
@@ -14,7 +17,7 @@ export async function resolveAuditActorProfiles<T extends AuditEventWithUser>(
const identities = uniqueResolvableIdentities(events)
if (identities.length === 0) return events
const profiles = await resolveProfiles(deps, identities)
const profiles = await resolveActorProfiles(deps, identities)
return events.map((event) => {
const profile = profiles.get(
auditActorIdentityKey({ type: event.actorType, ref: event.actorRef, issuer: event.actorIssuer }),
@@ -23,11 +26,21 @@ export async function resolveAuditActorProfiles<T extends AuditEventWithUser>(
})
}
async function resolveProfiles(
export async function resolveActorProfiles(
deps: { auditActorDirectory: AuditActorDirectory; agentInfo: AgentInfoGateway },
identities: readonly AuditActorIdentity[],
identities: readonly ActorIdentity[],
): Promise<ReadonlyMap<string, AuditActorProfile>> {
const profiles = new Map<string, AuditActorProfile>()
const userActors = identities.flatMap((identity) =>
identity.type === 'user' && identity.ref ? [{ identity, ref: identity.ref }] : [],
)
if (userActors.length > 0) {
const users = await deps.auditActorDirectory.findUserProfiles(userActors.map((actor) => actor.ref))
for (const actor of userActors) {
const profile = users.get(actor.ref)
if (profile) profiles.set(auditActorIdentityKey(actor.identity), profile)
}
}
const apiKeyActors = identities.flatMap((identity) =>
identity.type === 'api_key' && identity.ref ? [{ identity, ref: identity.ref }] : [],
)
@@ -71,6 +84,20 @@ async function resolveProfiles(
return profiles
}
export async function resolveActorAttributions(
deps: { auditActorDirectory: AuditActorDirectory; agentInfo: AgentInfoGateway },
identities: readonly ActorIdentity[],
): Promise<ReadonlyMap<string, ActorAttribution>> {
const unique = new Map(identities.map((identity) => [auditActorIdentityKey(identity), identity]))
const profiles = await resolveActorProfiles(deps, [...unique.values()])
return new Map(
[...unique].map(([key, identity]) => {
const profile = profiles.get(key) ?? fallbackActorProfile(identity)
return [key, { ...identity, ...profile }] as const
}),
)
}
function uniqueResolvableIdentities(events: readonly AuditEventWithUser[]): AuditActorIdentity[] {
const identities = new Map<string, AuditActorIdentity>()
for (const event of events) {
+20 -7
View File
@@ -22,6 +22,7 @@ const sampleJob = {
} as BackgroundJob
const extractRequest: CreateBackgroundJobRequest = { type: 'archive_extract', matterId: 'm-1' }
const agent = { type: 'oauth' as const, ref: 'agent-1', issuer: 'https://realm.example.com' }
// Fake ports as plain objects. backgroundJobs.create is what enqueueArchiveJob
// (the real composed function) drives on create; everything else is forwarded.
@@ -105,7 +106,12 @@ describe('background-job usecase', () => {
it('enqueues the request then dispatches the job, and returns it', async () => {
const create = vi.fn(async () => sampleJob)
const { deps, dispatch } = makeDeps({ backgroundJobs: { create } })
const job = await createBackgroundJob(deps, { orgId: 'org-1', userId: 'user-1', request: extractRequest })
const job = await createBackgroundJob(deps, {
orgId: 'org-1',
userId: 'user-1',
createdBy: agent,
request: extractRequest,
})
expect(job).toBe(sampleJob)
// enqueueArchiveJob maps the request onto the create input.
@@ -120,6 +126,7 @@ describe('background-job usecase', () => {
expect(dispatch).toHaveBeenCalledWith({
orgId: 'org-1',
userId: 'user-1',
createdBy: agent,
request: extractRequest,
jobId: 'job-1',
} satisfies ArchiveJobMessage)
@@ -135,7 +142,12 @@ describe('background-job usecase', () => {
calls.push('dispatch')
})
const { deps } = makeDeps({ backgroundJobs: { create }, dispatch })
await createBackgroundJob(deps, { orgId: 'org-1', userId: 'user-1', request: extractRequest })
await createBackgroundJob(deps, {
orgId: 'org-1',
userId: 'user-1',
createdBy: agent,
request: extractRequest,
})
expect(calls).toEqual(['create', 'dispatch'])
})
@@ -143,7 +155,7 @@ describe('background-job usecase', () => {
const create = vi.fn(async () => sampleJob)
const { deps } = makeDeps({ backgroundJobs: { create } })
const request: CreateBackgroundJobRequest = { type: 'archive_extract', matterId: 'm-1', targetFolder: 'dest' }
await createBackgroundJob(deps, { orgId: 'org-1', userId: 'user-1', request })
await createBackgroundJob(deps, { orgId: 'org-1', userId: 'user-1', createdBy: agent, request })
expect(create).toHaveBeenCalledWith(expect.objectContaining({ targetFolder: 'dest', metadata: request }))
})
})
@@ -154,7 +166,7 @@ describe('background-job usecase', () => {
const retry = vi.fn(async () => retriedJob)
const { deps, dispatch } = makeDeps({ backgroundJobs: { retry } })
const out = await retryBackgroundJob(deps, 'org-1', 'job-1')
const out = await retryBackgroundJob(deps, 'org-1', 'job-1', agent)
expect(out).toBe(retriedJob)
expect(retry).toHaveBeenCalledWith('org-1', 'job-1')
@@ -162,6 +174,7 @@ describe('background-job usecase', () => {
expect(dispatch).toHaveBeenCalledWith({
orgId: 'org-1',
userId: 'owner-9',
createdBy: agent,
request: extractRequest,
jobId: 'job-2',
} satisfies ArchiveJobMessage)
@@ -172,7 +185,7 @@ describe('background-job usecase', () => {
const retry = vi.fn(async () => retriedJob)
const { deps, dispatch } = makeDeps({ backgroundJobs: { retry } })
const out = await retryBackgroundJob(deps, 'org-1', 'job-1')
const out = await retryBackgroundJob(deps, 'org-1', 'job-1', agent)
expect(out).toBe(retriedJob)
expect(dispatch).not.toHaveBeenCalled()
@@ -181,7 +194,7 @@ describe('background-job usecase', () => {
it('does not dispatch when the stored metadata is null', async () => {
const retry = vi.fn(async () => ({ ...sampleJob, metadata: null }) as BackgroundJob)
const { deps, dispatch } = makeDeps({ backgroundJobs: { retry } })
await retryBackgroundJob(deps, 'org-1', 'job-1')
await retryBackgroundJob(deps, 'org-1', 'job-1', agent)
expect(dispatch).not.toHaveBeenCalled()
})
@@ -190,7 +203,7 @@ describe('background-job usecase', () => {
throw new BackgroundJobError('not_retryable')
})
const { deps, dispatch } = makeDeps({ backgroundJobs: { retry } })
await expect(retryBackgroundJob(deps, 'org-1', 'job-1')).rejects.toMatchObject({ code: 'not_retryable' })
await expect(retryBackgroundJob(deps, 'org-1', 'job-1', agent)).rejects.toMatchObject({ code: 'not_retryable' })
expect(dispatch).not.toHaveBeenCalled()
})
})
+6 -5
View File
@@ -15,7 +15,7 @@ import { enqueueArchiveJob } from './archive-processing'
// create dispatches on top of enqueueArchiveJob, which reaches across the archive
// ports, so it forwards the whole Deps; the reads need only the narrow ports.
import type { Deps } from './deps'
import type { ListBackgroundJobsOptions } from './ports'
import type { ActorIdentity, ListBackgroundJobsOptions } from './ports'
export function listBackgroundJobs(deps: Pick<Deps, 'backgroundJobs'>, orgId: string, opts: ListBackgroundJobsOptions) {
return deps.backgroundJobs.list(orgId, opts)
@@ -43,11 +43,11 @@ export function cancelBackgroundJob(
export async function createBackgroundJob(
deps: Deps,
params: { orgId: string; userId: string; request: CreateBackgroundJobRequest },
params: { orgId: string; userId: string; createdBy: ActorIdentity; request: CreateBackgroundJobRequest },
): Promise<BackgroundJob> {
const { orgId, userId, request } = params
const { orgId, userId, createdBy, request } = params
const job = await enqueueArchiveJob(deps, { orgId, userId, request })
await deps.archiveJobs.dispatch({ orgId, userId, request, jobId: job.id })
await deps.archiveJobs.dispatch({ orgId, userId, createdBy, request, jobId: job.id })
return job
}
@@ -55,11 +55,12 @@ export async function retryBackgroundJob(
deps: Pick<Deps, 'backgroundJobs' | 'archiveJobs'>,
orgId: string,
id: string,
createdBy: ActorIdentity,
): Promise<BackgroundJob> {
const job = await deps.backgroundJobs.retry(orgId, id)
const request = createBackgroundJobRequestSchema.safeParse(job.metadata)
if (request.success) {
await deps.archiveJobs.dispatch({ orgId, userId: job.userId, request: request.data, jobId: job.id })
await deps.archiveJobs.dispatch({ orgId, userId: job.userId, createdBy, request: request.data, jobId: job.id })
}
return job
}
@@ -1,6 +1,7 @@
import { DirType, ObjectStatus } from '@shared/constants'
import { formatError } from '../../lib/errors'
import type { Deps } from '../deps'
import type { ActorIdentity } from '../ports'
import { AppError, type Matter, NameConflictError } from '../ports'
function joinPath(parent: string, name: string): string {
@@ -16,7 +17,7 @@ function targetIsFile(path: string): AppError {
export async function ensureDownloadFolderPath(
deps: Pick<Deps, 'matter' | 'storages'>,
params: { orgId: string; folderPath: string },
params: { orgId: string; folderPath: string; createdBy?: ActorIdentity },
): Promise<string> {
const parts = params.folderPath.split('/').filter(Boolean)
if (parts.length === 0) return ''
@@ -39,6 +40,9 @@ export async function ensureDownloadFolderPath(
object: '',
storageId,
status: ObjectStatus.ACTIVE,
createdByActorType: params.createdBy?.type,
createdByActorRef: params.createdBy?.ref,
createdByActorIssuer: params.createdBy?.issuer,
})
} catch (error) {
if (!(error instanceof NameConflictError) && !formatError(error).includes('UNIQUE constraint failed'))
+77 -1
View File
@@ -1,6 +1,7 @@
import type { CreateDownloaderInput } from '@shared/schemas'
import type { BindingState, Downloader } from '@shared/types'
import type { BindingState, Downloader, DownloadTask } from '@shared/types'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Platform } from '../../platform/interface'
import type { DownloaderRecord, DownloaderRepo } from '../ports'
import { type AppError, DownloadError } from '../ports'
import { loadBindingState } from '../site/licensing'
@@ -8,6 +9,7 @@ import {
createDownloaderWithBootstrapCredential,
type DownloadsDeps,
downloaderHeartbeatPersistence,
listDownloadTasks,
updateDownloaderCreditBilling,
} from './downloads'
@@ -205,6 +207,80 @@ describe('updateDownloaderCreditBilling', () => {
})
})
describe('listDownloadTasks', () => {
it('resolves every requester and executor in one batch for the page', async () => {
const requestedBy = (ref: string) => ({
type: 'api_key' as const,
ref,
issuer: null,
name: `API key · ${ref}`,
image: null,
resolved: false,
})
const tasks = ['key-1', 'key-2'].map(
(ref, index) =>
({
id: `task-${index + 1}`,
requestedBy: requestedBy(ref),
status: {
assignment: {
downloaderId: `device-${index + 1}`,
assignedAt: null,
executor: {
type: 'device',
ref: `device-${index + 1}`,
issuer: null,
name: `Device · device-${index + 1}`,
image: null,
resolved: false,
},
},
},
}) as DownloadTask,
)
const findApiKeyNames = vi.fn(
async () =>
new Map([
['key-1', 'zme'],
['key-2', 'automation'],
]),
)
const findDeviceNames = vi.fn(
async () =>
new Map([
['device-1', 'Living room'],
['device-2', 'Office'],
]),
)
const listTrustedAgentIssuerOrigins = vi.fn(async () => new Set<string>())
const agentResolve = vi.fn(async () => new Map())
const deps = {
downloadTasks: { list: vi.fn(async () => ({ items: tasks, rows: [], nextBoundary: null })) },
auditActorDirectory: {
findUserProfiles: vi.fn(async () => new Map()),
findApiKeyNames,
findDeviceNames,
listTrustedAgentIssuerOrigins,
},
agentInfo: { resolve: agentResolve },
} as unknown as DownloadsDeps
const result = await listDownloadTasks(deps, {} as Platform, { pageSize: 20 })
expect(findApiKeyNames).toHaveBeenCalledTimes(1)
expect(findApiKeyNames).toHaveBeenCalledWith(['key-1', 'key-2'])
expect(findDeviceNames).toHaveBeenCalledTimes(1)
expect(findDeviceNames).toHaveBeenCalledWith(['device-1', 'device-2'])
expect(listTrustedAgentIssuerOrigins).not.toHaveBeenCalled()
expect(agentResolve).not.toHaveBeenCalled()
expect(result.items.map((task) => task.requestedBy?.name)).toEqual(['API key · zme', 'API key · automation'])
expect(result.items.map((task) => task.status.assignment?.executor?.name)).toEqual([
'Device · Living room',
'Device · Office',
])
})
})
describe('createDownloaderWithBootstrapCredential', () => {
const input = {
name: 'Bootstrap downloader',
+67 -7
View File
@@ -1,4 +1,5 @@
import type {
ActorAttribution,
CreateDownloaderInput,
CreateDownloadTaskInput,
DownloaderHeartbeatInput,
@@ -19,10 +20,15 @@ import type {
} from '@shared/types'
import { ZPAN_CLOUD_URL_DEFAULT } from '../../../shared/constants'
import { generateId } from '../../../shared/ids'
import { fallbackActorAttribution } from '../../domain/actor-attribution'
import { parseDownloadTaskEvents } from '../../domain/download-task-events'
import { hasFeature } from '../../domain/licensing'
import type { Platform } from '../../platform/interface'
import { resolveActorAttributions } from '../audit-actors'
import type {
ActorIdentity,
AgentInfoGateway,
AuditActorDirectory,
AuditEvent,
AuditRepo,
DownloaderBootstrapCredentialRepo,
@@ -39,7 +45,7 @@ import type {
StorageRepo,
UpdateDownloadTaskFields,
} from '../ports'
import { DownloadError, featureBlocked, unauthorized } from '../ports'
import { actorIdentityKey, DownloadError, featureBlocked, unauthorized } from '../ports'
import { loadBindingState } from '../site/licensing'
import { ensureDownloadFolderPath } from './download-folders'
import { RemoteDownloadBillingBlockedError, reportRemoteDownloadUnit } from './remote-download-usage'
@@ -51,6 +57,8 @@ import { RemoteDownloadBillingBlockedError, reportRemoteDownloadUnit } from './r
// is platform-per-call, mirroring auth.ts).
export type DownloadsDeps = {
auditActorDirectory: AuditActorDirectory
agentInfo: AgentInfoGateway
downloaders: DownloaderRepo
downloaderBootstrapCredentials: DownloaderBootstrapCredentialRepo
downloadTasks: DownloadTaskRepo
@@ -311,10 +319,12 @@ export async function createDownloadTask(
orgId: string,
userId: string,
input: CreateDownloadTaskInput,
requestedBy: ActorIdentity = { type: 'user', ref: userId, issuer: null },
): Promise<DownloadTask> {
const targetFolder = await ensureDownloadFolderPath(deps, {
orgId,
folderPath: input.targetFolder,
createdBy: requestedBy,
})
const now = new Date()
const id = generateId()
@@ -322,6 +332,9 @@ export async function createDownloadTask(
id,
orgId,
createdByUserId: userId,
requestedByActorType: requestedBy.type,
requestedByActorRef: requestedBy.ref ?? userId,
requestedByActorIssuer: requestedBy.issuer,
sourceType: input.source.type,
sourceUri: input.source.uri,
displayName: input.name ?? null,
@@ -333,7 +346,7 @@ export async function createDownloadTask(
assignedAt: null,
now,
})
return deps.downloadTasks.get(orgId, id)
return decorateDownloadTaskActors(deps, await deps.downloadTasks.get(orgId, id))
}
export async function listDownloadTasks(
@@ -345,10 +358,13 @@ export async function listDownloadTasks(
nextBoundary: { createdAt: Date; id: string } | null
}> {
const { items, rows, nextBoundary } = await deps.downloadTasks.list(opts)
if (!opts.includeUploadToken) return { items, nextBoundary }
const decorated = await Promise.all(
if (!opts.includeUploadToken) {
return { items: await decorateDownloadTasksActors(deps, items), nextBoundary }
}
const tasksWithUploadTokens = await Promise.all(
items.map((task, index) => decorateWithUploadToken(deps, platform, task, rows[index])),
)
const decorated = await decorateDownloadTasksActors(deps, tasksWithUploadTokens)
return { items: decorated, nextBoundary }
}
@@ -362,8 +378,39 @@ export async function listDownloadTaskItems(
return deps.downloadTasks.listItems(opts)
}
export function getDownloadTask(deps: DownloadsDeps, orgId: string, id: string): Promise<DownloadTask> {
return deps.downloadTasks.get(orgId, id)
export async function getDownloadTask(deps: DownloadsDeps, orgId: string, id: string): Promise<DownloadTask> {
return decorateDownloadTaskActors(deps, await deps.downloadTasks.get(orgId, id))
}
async function decorateDownloadTaskActors(deps: DownloadsDeps, task: DownloadTask): Promise<DownloadTask> {
return (await decorateDownloadTasksActors(deps, [task]))[0]
}
async function decorateDownloadTasksActors(
deps: DownloadsDeps,
tasks: readonly DownloadTask[],
): Promise<DownloadTask[]> {
const identities = tasks
.flatMap((task) => [
task.requestedBy && { type: task.requestedBy.type, ref: task.requestedBy.ref, issuer: task.requestedBy.issuer },
task.status.assignment && { type: 'device' as const, ref: task.status.assignment.downloaderId, issuer: null },
])
.filter((identity): identity is ActorIdentity => identity !== null)
const actors = await resolveActorAttributions(deps, identities)
return tasks.map((task) => decorateDownloadTaskActor(task, actors))
}
function decorateDownloadTaskActor(task: DownloadTask, actors: ReadonlyMap<string, ActorAttribution>): DownloadTask {
const requestedBy = task.requestedBy ? (actors.get(actorIdentityKey(task.requestedBy)) ?? task.requestedBy) : null
const assignment = task.status.assignment
? {
...task.status.assignment,
executor:
actors.get(actorIdentityKey({ type: 'device', ref: task.status.assignment.downloaderId, issuer: null })) ??
task.status.assignment.executor,
}
: null
return { ...task, requestedBy, status: { ...task.status, assignment } }
}
export async function getDownloadTaskTimeline(
@@ -1038,10 +1085,19 @@ function actionSeverity(action: string): DownloadTaskTimelineItem['severity'] {
function downloadTaskFromRecord(row: DownloadTaskRecord): DownloadTask {
const runtime = parseTaskRuntime(row.runtime)
const requestedBy =
row.requestedByActorType && row.requestedByActorRef
? fallbackActorAttribution({
type: row.requestedByActorType,
ref: row.requestedByActorRef,
issuer: row.requestedByActorIssuer ?? null,
})
: null
return {
id: row.id,
orgId: row.orgId,
createdBy: row.createdByUserId,
requestedBy,
spec: {
source: {
type: row.sourceType as DownloadTask['spec']['source']['type'],
@@ -1060,7 +1116,11 @@ function downloadTaskFromRecord(row: DownloadTaskRecord): DownloadTask {
state: row.status as DownloadTask['status']['state'],
attempt: row.attempt,
assignment: row.assignedDownloaderId
? { downloaderId: row.assignedDownloaderId, assignedAt: row.assignedAt?.toISOString() ?? null }
? {
downloaderId: row.assignedDownloaderId,
assignedAt: row.assignedAt?.toISOString() ?? null,
executor: fallbackActorAttribution({ type: 'device', ref: row.assignedDownloaderId, issuer: null }),
}
: null,
progress: runtime?.progress ?? {
download: { bytes: 0, totalBytes: null, bytesPerSecond: 0 },
+29 -1
View File
@@ -397,6 +397,29 @@ describe('object usecase', () => {
expect(presignUpload).not.toHaveBeenCalled()
})
it('persists the stable OAuth agent identity on a created object', async () => {
const create = vi.fn(async () => folder('agent-folder', { name: 'Agent Folder' }))
const { deps } = makeDeps({ matter: { create } })
await createObject(deps, {
orgId: 'o1',
actor: {
kind: 'user',
userId: 'owner-1',
identity: { type: 'oauth', ref: 'agent-1', issuer: 'https://realm.example.com' },
},
input: { name: 'Agent Folder', type: 'folder', dirtype: DirType.USER_FOLDER, parent: '' },
})
expect(create).toHaveBeenCalledWith(
expect.objectContaining({
createdByActorType: 'oauth',
createdByActorRef: 'agent-1',
createdByActorIssuer: 'https://realm.example.com',
}),
)
})
it('creates a small file draft and returns single-PUT upload instructions', async () => {
const create = vi.fn(async (input: Parameters<MatterRepo['create']>[0]) => file('d1', input as Partial<Matter>))
const presignUpload = vi.fn(async () => 'https://up')
@@ -1664,7 +1687,12 @@ describe('object usecase', () => {
expect(source.object).toBe('legacy_/source-file-.txt')
expect(destinationKey).toMatch(/^o1\/u1\/\d{8}\/[A-Za-z0-9]{17}\.txt$/)
expect(copyObjectS3).toHaveBeenCalledWith(storage, source.object, storage, destinationKey)
expect(copy).toHaveBeenCalledWith(source, 'Dest', destinationKey, { onConflict: undefined })
expect(copy).toHaveBeenCalledWith(source, 'Dest', destinationKey, {
onConflict: undefined,
createdByActorType: 'user',
createdByActorRef: 'u1',
createdByActorIssuer: null,
})
})
it('returns not_found for a missing source', async () => {
+75 -5
View File
@@ -12,6 +12,7 @@
import { DirType } from '@shared/constants'
import type {
ActorAttribution,
CompleteObjectUploadInput,
ConflictStrategy,
CreateMatterInput,
@@ -21,11 +22,14 @@ import type {
} from '@shared/schemas'
import type { ObjectUploadInstructions } from '@shared/types'
import { buildObjectKey, fileExt } from '../lib/path-template'
import { resolveActorAttributions } from './audit-actors'
import type { Deps } from './deps'
import { assertFolderNotUsedByDownload, ensureDownloadFolderPath } from './downloads/download-folders'
import { assertTaskUploadAllowed } from './downloads/downloads'
import {
type ActorIdentity,
type AppError,
actorIdentityKey,
badRequest,
forbidden,
insufficientCredits,
@@ -61,13 +65,14 @@ export interface CopyObjectInput {
// upload token acts on behalf of the task creator but is logged as the
// downloader and is constrained to its authorized target folder.
export type ObjectActor =
| { kind: 'user'; userId: string }
| { kind: 'user'; userId: string; identity?: ActorIdentity }
| {
kind: 'download-task-upload'
downloaderId: string
taskId: string
targetFolder: string
createdByUserId: string
identity?: ActorIdentity
}
// The user id used to build object keys (whose owner is the task creator for
@@ -81,6 +86,13 @@ function actorLogId(actor: ObjectActor): string {
return actor.kind === 'download-task-upload' ? `downloader:${actor.downloaderId}` : actor.userId
}
function creatorIdentity(actor: ObjectActor): ActorIdentity {
if (actor.identity) return actor.identity
return actor.kind === 'download-task-upload'
? { type: 'device', ref: actor.downloaderId, issuer: null }
: { type: 'user', ref: actor.userId, issuer: null }
}
const ROLE_LEVELS: Record<string, number> = { owner: 3, admin: 3, editor: 2, viewer: 1, member: 1 }
// Whether the user may write (editor+) in the org. Personal orgs grant full
@@ -164,6 +176,7 @@ export async function createObject(
params: { orgId: string; actor: ObjectActor; input: CreateMatterInput },
): Promise<CreateObjectOutcome> {
const { orgId, actor, input } = params
const createdBy = creatorIdentity(actor)
const { name, type, dirtype, onConflict } = input
let { parent } = input
const isFolder = dirtype !== DirType.FILE
@@ -180,6 +193,7 @@ export async function createObject(
parent = await ensureDownloadFolderPath(deps, {
orgId,
folderPath: parent,
createdBy,
})
}
@@ -224,6 +238,9 @@ export async function createObject(
object: objectKey,
storageId: storage.id,
status: isFolder ? 'active' : 'draft',
createdByActorType: createdBy.type,
createdByActorRef: createdBy.ref,
createdByActorIssuer: createdBy.issuer,
onConflict,
})
@@ -242,6 +259,36 @@ export async function createObject(
return { ok: true, matter, upload }
}
export async function resolveMatterCreators(
deps: Pick<Deps, 'auditActorDirectory' | 'agentInfo'>,
matters: readonly Matter[],
): Promise<ReadonlyMap<string, ActorAttribution>> {
const identities = matters.flatMap((matter) =>
matter.createdByActorType && matter.createdByActorRef
? [
{
type: matter.createdByActorType as ActorIdentity['type'],
ref: matter.createdByActorRef,
issuer: matter.createdByActorIssuer ?? null,
} satisfies ActorIdentity,
]
: [],
)
const actors = await resolveActorAttributions(deps, identities)
return new Map(
matters.flatMap((matter) => {
if (!matter.createdByActorType || !matter.createdByActorRef) return []
const identity = {
type: matter.createdByActorType as ActorIdentity['type'],
ref: matter.createdByActorRef,
issuer: matter.createdByActorIssuer ?? null,
}
const actor = actors.get(actorIdentityKey(identity))
return actor ? [[matter.id, actor] as const] : []
}),
)
}
// Decides the S3 mechanism, presigns every URL up front, and records the upload
// session. The chosen onConflict is stored on the session so completion can apply
// a deferred 'replace' (createMatter keeps the incumbent until bytes land).
@@ -860,9 +907,10 @@ export type CopyObjectOutcome = { ok: true; matter: Matter } | { ok: false; erro
export async function copyObject(
deps: Pick<Deps, 'matter' | 'storages' | 's3' | 'quota' | 'storageUsage'>,
params: { orgId: string; userId: string; input: CopyObjectInput },
params: { orgId: string; userId: string; actor?: ObjectActor; input: CopyObjectInput },
): Promise<CopyObjectOutcome> {
const { orgId, userId, input } = params
const identity = params.actor ? creatorIdentity(params.actor) : { type: 'user' as const, ref: userId, issuer: null }
const { copyFrom, parent, onConflict } = input
const source = await deps.matter.get(copyFrom, orgId)
if (!source || source.trashedAt != null) return { ok: false, error: notFound() }
@@ -884,7 +932,12 @@ export async function copyObject(
await deps.s3.copyObject(objectStorage, source.object, objectStorage, newObject)
ctx.onRollback(() => deps.s3.deleteObject(objectStorage, newObject))
}
return deps.matter.copy(source, parent, newObject, { onConflict })
return deps.matter.copy(source, parent, newObject, {
onConflict,
createdByActorType: identity.type,
createdByActorRef: identity.ref,
createdByActorIssuer: identity.issuer,
})
},
)
return { ok: true, matter: copy }
@@ -898,7 +951,7 @@ export type TransferObjectOutcome = { ok: true; result: TransferObjectResult } |
export async function transferObject(
deps: Pick<Deps, 'matter' | 'storages' | 's3' | 'quota' | 'storageUsage' | 'share' | 'org' | 'downloadTasks'>,
params: { orgId: string; userId: string; objectId: string; input: TransferMatterInput },
params: { orgId: string; userId: string; actor?: ObjectActor; objectId: string; input: TransferMatterInput },
): Promise<TransferObjectOutcome> {
const { orgId, userId, objectId, input } = params
const { targetOrgId, targetParent, mode } = input
@@ -927,6 +980,7 @@ export async function transferObject(
currentUserId: userId,
targetOrgId,
targetParent,
createdBy: params.actor ? creatorIdentity(params.actor) : undefined,
})
// Move = copy + delete source. Only delete when every file copied — a partial
@@ -1130,6 +1184,7 @@ export interface SaveShareInput {
targetOrgId: string
targetParent: string
teamQuotaEnabled?: boolean
createdBy?: ActorIdentity
}
export interface SaveShareResult {
@@ -1143,6 +1198,7 @@ export interface CopyMatterToOrgInput {
targetOrgId: string
targetParent: string
teamQuotaEnabled?: boolean
createdBy?: ActorIdentity
}
function buildPath(parent: string, name: string): string {
@@ -1158,6 +1214,7 @@ async function saveFile(
targetOrgId: string,
targetParent: string,
teamQuotaEnabled = true,
createdBy: ActorIdentity = { type: 'user', ref: currentUserId, issuer: null },
): Promise<Matter> {
const bytes = sourceMatter.size ?? 0
const dstKey = buildObjectKey({ uid: currentUserId, orgId: targetOrgId, rawExt: fileExt(sourceMatter.name) })
@@ -1184,6 +1241,9 @@ async function saveFile(
storageId: targetStorage.id,
status: 'active',
onConflict: 'rename',
createdByActorType: createdBy.type,
createdByActorRef: createdBy.ref,
createdByActorIssuer: createdBy.issuer,
})
return newMatter
@@ -1200,6 +1260,7 @@ async function saveFolderRecursive(
targetOrgId: string,
targetParent: string,
teamQuotaEnabled = true,
createdBy: ActorIdentity = { type: 'user', ref: currentUserId, issuer: null },
): Promise<SaveShareResult> {
const saved: Matter[] = []
const skipped: Array<{ name: string; reason: string }> = []
@@ -1215,6 +1276,9 @@ async function saveFolderRecursive(
storageId: targetStorage.id,
status: 'active',
onConflict: 'rename',
createdByActorType: createdBy.type,
createdByActorRef: createdBy.ref,
createdByActorIssuer: createdBy.issuer,
})
saved.push(rootFolder)
@@ -1241,6 +1305,7 @@ async function saveFolderRecursive(
targetOrgId,
targetPath,
teamQuotaEnabled,
createdBy,
)
saved.push(newFile)
} catch (e) {
@@ -1258,6 +1323,9 @@ async function saveFolderRecursive(
storageId: targetStorage.id,
status: 'active',
onConflict: 'rename',
createdByActorType: createdBy.type,
createdByActorRef: createdBy.ref,
createdByActorIssuer: createdBy.issuer,
})
saved.push(newFolder)
queue.push({
@@ -1275,7 +1343,7 @@ async function saveFolderRecursive(
// target org per file; files that fail (e.g. quota) are reported in `skipped`
// rather than failing the whole operation.
export async function copyMatterToOrg(deps: SaveToDriveDeps, input: CopyMatterToOrgInput): Promise<SaveShareResult> {
const { sourceMatter, currentUserId, targetOrgId, targetParent, teamQuotaEnabled = true } = input
const { sourceMatter, currentUserId, targetOrgId, targetParent, teamQuotaEnabled = true, createdBy } = input
const sourceStorage = await deps.storages.get(sourceMatter.storageId)
if (!sourceStorage) throw new Error('Source storage not found')
@@ -1292,6 +1360,7 @@ export async function copyMatterToOrg(deps: SaveToDriveDeps, input: CopyMatterTo
targetOrgId,
targetParent,
teamQuotaEnabled,
createdBy,
)
return { saved: [newMatter], skipped: [] }
}
@@ -1305,6 +1374,7 @@ export async function copyMatterToOrg(deps: SaveToDriveDeps, input: CopyMatterTo
targetOrgId,
targetParent,
teamQuotaEnabled,
createdBy,
)
}
+2
View File
@@ -1,9 +1,11 @@
import type { CreateBackgroundJobRequest } from '@shared/schemas'
import type { ActorIdentity } from './audit'
export interface ArchiveJobMessage {
jobId: string
orgId: string
userId: string
createdBy: ActorIdentity
request: CreateBackgroundJobRequest
}
+18 -8
View File
@@ -1,6 +1,8 @@
// Plain, framework-free DTOs and the repository port for audit events.
export type AuditActorType = 'user' | 'api_key' | 'oauth' | 'agent' | 'anonymous' | 'system' | 'device' | 'task-upload'
import type { ActorAttribution, ActorType } from '@shared/schemas'
export type AuditActorType = ActorType
export interface RecordAuditEventInput {
orgId: string
@@ -73,19 +75,20 @@ export interface AuditRepo {
): Promise<{ items: AuditEvent[]; total: number; page: number; pageSize: number }>
}
export interface AuditActorIdentity {
type: AuditActorType
export interface ActorIdentity {
type: ActorType
ref: string | null
issuer: string | null
}
export interface AuditActorProfile {
export interface ActorProfile {
name: string
image: string | null
resolved: boolean
}
export interface AuditActorDirectory {
export interface ActorDirectory {
findUserProfiles(userIds: readonly string[]): Promise<ReadonlyMap<string, ActorProfile>>
findApiKeyNames(keyIds: readonly string[]): Promise<ReadonlyMap<string, string>>
findDeviceNames(deviceIds: readonly string[]): Promise<ReadonlyMap<string, string>>
listTrustedAgentIssuerOrigins(): Promise<ReadonlySet<string>>
@@ -95,11 +98,18 @@ 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[],
actors: readonly ActorIdentity[],
trustedIssuerOrigins: ReadonlySet<string>,
): Promise<ReadonlyMap<string, AuditActorProfile>>
): Promise<ReadonlyMap<string, ActorProfile>>
}
export function auditActorIdentityKey(actor: AuditActorIdentity): string {
export type AuditActorIdentity = ActorIdentity
export type AuditActorProfile = ActorProfile
export type AuditActorDirectory = ActorDirectory
export type { ActorAttribution }
export function actorIdentityKey(actor: ActorIdentity): string {
return JSON.stringify([actor.type, actor.issuer, actor.ref])
}
export const auditActorIdentityKey = actorIdentityKey
+7
View File
@@ -1,3 +1,4 @@
import type { ActorType } from '@shared/schemas'
import type { Downloader, DownloadTask, DownloadTaskListItem } from '@shared/types'
// ─── Errors ──────────────────────────────────────────────────────────────────
@@ -51,6 +52,9 @@ export interface DownloadTaskRecord {
id: string
orgId: string
createdByUserId: string
requestedByActorType?: ActorType | null
requestedByActorRef?: string | null
requestedByActorIssuer?: string | null
sourceType: string
sourceUri: string
displayName: string | null
@@ -145,6 +149,9 @@ export interface CreateDownloadTaskRecordInput {
id: string
orgId: string
createdByUserId: string
requestedByActorType?: ActorType
requestedByActorRef?: string
requestedByActorIssuer?: string | null
sourceType: string
sourceUri: string
displayName: string | null
+10 -1
View File
@@ -1,4 +1,4 @@
import type { ConflictStrategy } from '@shared/schemas'
import type { ActorType, ConflictStrategy } from '@shared/schemas'
export type { ConflictStrategy } from '@shared/schemas'
@@ -20,6 +20,9 @@ export interface Matter {
status: string
trashedAt: number | null
purgedAt: number | null
createdByActorType?: string | null
createdByActorRef?: string | null
createdByActorIssuer?: string | null
createdAt: Date
updatedAt: Date
}
@@ -34,6 +37,9 @@ export interface CreateMatterInput {
object: string
storageId: string
status: string
createdByActorType?: ActorType | null
createdByActorRef?: string | null
createdByActorIssuer?: string | null
/** How to handle name collision with an existing active sibling. Default 'fail'. */
onConflict?: ConflictStrategy
}
@@ -69,6 +75,9 @@ export interface UpdateMatterInput {
export interface CopyMatterOptions {
onConflict?: ConflictStrategy
createdByActorType?: ActorType | null
createdByActorRef?: string | null
createdByActorIssuer?: string | null
}
export interface ConflictResolveOptions {
+2
View File
@@ -1110,6 +1110,7 @@ describe('saveShare', () => {
targetOrgId: 'o-2',
targetParent: '',
accessCookie: undefined,
createdBy: { type: 'oauth' as const, ref: 'agent-1', issuer: 'https://realm.example.com' },
}
it('returns matter_trashed when the share target was trashed', async () => {
@@ -1183,6 +1184,7 @@ describe('saveShare', () => {
currentUserId: 'u1',
targetOrgId: 'o-2',
targetParent: 'dest',
createdBy: { type: 'oauth', ref: 'agent-1', issuer: 'https://realm.example.com' },
})
})
})
+9 -1
View File
@@ -18,6 +18,7 @@ import { verifyPassword as verifyPasswordHash } from '../lib/password'
import type { Platform } from '../platform/interface'
import { type SaveToDriveDeps, saveShareToDrive } from './object'
import {
type ActorIdentity,
AppError,
badRequest,
CreateShareError,
@@ -641,6 +642,7 @@ export type SaveShareParams = {
boundTargetOrgId?: string | null
targetParent: string
accessCookie: string | undefined
createdBy: ActorIdentity
}
export type SaveShareOutcome =
@@ -674,7 +676,13 @@ export async function saveShare(deps: ShareDeps, params: SaveShareParams): Promi
const totalBytes = await deps.share.computeSourceBytes(matter)
if (!(await deps.share.hasQuotaForBytes(targetOrgId, totalBytes))) return { ok: false, error: quotaExceeded() }
const result = await saveShareToDrive(deps, { matter, currentUserId, targetOrgId, targetParent })
const result = await saveShareToDrive(deps, {
matter,
currentUserId,
targetOrgId,
targetParent,
createdBy: params.createdBy,
})
return { ok: true, result }
}
+5 -1
View File
@@ -8,12 +8,13 @@ describe('audit usecase', () => {
const listAdminAudit = vi.fn(async () => result)
const resolve = vi.fn(async () => new Map())
const findApiKeyNames = vi.fn(async () => new Map())
const findUserProfiles = 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'>,
auditActorDirectory: { findApiKeyNames, findDeviceNames, listTrustedAgentIssuerOrigins },
auditActorDirectory: { findUserProfiles, findApiKeyNames, findDeviceNames, listTrustedAgentIssuerOrigins },
agentInfo: { resolve } as AgentInfoGateway,
},
{
@@ -56,6 +57,7 @@ describe('audit usecase', () => {
{
audit: { listAdminAudit: async () => ({ items: [event], total: 1, page: 1, pageSize: 20 }) },
auditActorDirectory: {
findUserProfiles: async () => new Map(),
findApiKeyNames: async () => new Map(),
findDeviceNames: async () => new Map(),
listTrustedAgentIssuerOrigins: async () => new Set(['https://id.realmroot.dev']),
@@ -93,6 +95,7 @@ describe('audit usecase', () => {
{
audit: { listAdminAudit: async () => ({ items: [event], total: 1, page: 1, pageSize: 20 }) },
auditActorDirectory: {
findUserProfiles: async () => new Map(),
findApiKeyNames: async () => new Map([['key-1', 'CME downloader']]),
findDeviceNames: async () => new Map(),
listTrustedAgentIssuerOrigins: async () => new Set(),
@@ -132,6 +135,7 @@ describe('audit usecase', () => {
{
audit: { listAdminAudit: async () => ({ items: [event], total: 1, page: 1, pageSize: 20 }) },
auditActorDirectory: {
findUserProfiles: async () => new Map(),
findApiKeyNames: async () => new Map(),
findDeviceNames: async () => new Map([['device-1', 'Office Mac']]),
listTrustedAgentIssuerOrigins: async () => new Set(),
+1
View File
@@ -87,6 +87,7 @@ function makeDeps(
const deps: TeamDeps = {
audit: { record: async () => {}, list: async () => ({ items: [], total: 0 }) } as unknown as AuditRepo,
auditActorDirectory: {
findUserProfiles: async () => new Map(),
findApiKeyNames: async () => new Map(),
findDeviceNames: async () => new Map(),
listTrustedAgentIssuerOrigins: async () => new Set(),
+31 -1
View File
@@ -479,6 +479,7 @@ describe('webdav usecase', () => {
contentType: 'text/plain',
contentLength: 9 as number | null,
body: new Uint8Array(9),
createdBy: { type: 'api_key' as const, ref: 'key-1', issuer: null },
...overrides,
})
@@ -503,6 +504,9 @@ describe('webdav usecase', () => {
dirtype: DirType.FILE,
status: 'active',
object: storageKey,
createdByActorType: 'api_key',
createdByActorRef: 'key-1',
createdByActorIssuer: null,
}),
)
})
@@ -607,7 +611,13 @@ describe('webdav usecase', () => {
it('creates a folder matter under the selected private storage', async () => {
const create = vi.fn(async () => folder('Projects'))
const deps = makeDeps({ matter: { create } })
await createWebDavCollection(deps, { orgId: 'ws-1', userId: 'u1', name: 'Projects', parent: 'Docs' })
await createWebDavCollection(deps, {
orgId: 'ws-1',
userId: 'u1',
name: 'Projects',
parent: 'Docs',
createdBy: { type: 'api_key', ref: 'key-1', issuer: null },
})
expect(create).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Projects',
@@ -616,6 +626,8 @@ describe('webdav usecase', () => {
parent: 'Docs',
object: '',
status: 'active',
createdByActorType: 'api_key',
createdByActorRef: 'key-1',
}),
)
})
@@ -720,6 +732,7 @@ describe('webdav usecase', () => {
targetResourcePath: 'dst.txt',
replacedMatterId: null,
replacingTarget: false,
createdBy: { type: 'api_key' as const, ref: 'key-1', issuer: null },
...overrides,
})
@@ -737,6 +750,12 @@ describe('webdav usecase', () => {
expect(sourceMatter.object).toBe('legacy_/webdav-source-.txt')
expect(destinationKey).toMatch(/^ws1\/u1\/\d{8}\/[A-Za-z0-9]{17}\.txt$/)
expect(copyObject).toHaveBeenCalledWith(storage, sourceMatter.object, storage, destinationKey)
expect(copy).toHaveBeenCalledWith(expect.any(Object), '', destinationKey, {
onConflict: 'fail',
createdByActorType: 'api_key',
createdByActorRef: 'key-1',
createdByActorIssuer: null,
})
expect(copyDeadProperties).toHaveBeenCalledWith('ws1', 'src.txt', 'dst.txt')
})
@@ -794,6 +813,7 @@ describe('webdav usecase', () => {
targetMatter: null,
replacingTarget: false,
depth: 'infinity' as const,
createdBy: { type: 'api_key' as const, ref: 'key-1', issuer: null },
...overrides,
})
@@ -823,6 +843,12 @@ describe('webdav usecase', () => {
object: expect.stringMatching(/^ws1\/u1\/\d{8}\/[A-Za-z0-9]{17}\.txt$/),
},
])
expect(copy).toHaveBeenCalledWith(expect.any(Object), '', '', {
onConflict: 'fail',
createdByActorType: 'api_key',
createdByActorRef: 'key-1',
createdByActorIssuer: null,
})
})
it('copies only the root when depth=0', async () => {
@@ -881,6 +907,7 @@ describe('webdav usecase', () => {
owner: 'tester',
depth: '0',
timeoutSeconds: 600,
createdBy: { type: 'api_key', ref: 'key-1', issuer: null },
})
expect(out).toEqual({ lock, created: false })
expect(create).not.toHaveBeenCalled()
@@ -906,6 +933,7 @@ describe('webdav usecase', () => {
owner: 'tester',
depth: 'infinity',
timeoutSeconds: 3600,
createdBy: { type: 'api_key', ref: 'key-1', issuer: null },
})
expect(out).not.toBeNull()
if (!out) throw new Error('Expected lock to be created')
@@ -920,6 +948,8 @@ describe('webdav usecase', () => {
dirtype: DirType.FILE,
status: 'active',
object: storageKey,
createdByActorType: 'api_key',
createdByActorRef: 'key-1',
}),
)
})
+27 -1
View File
@@ -19,6 +19,7 @@ import type { Database } from '../platform/interface'
import type { Deps } from './deps'
import { assertFolderNotUsedByDownload } from './downloads/download-folders'
import {
type ActorIdentity,
type ApiKeyAuth,
ApiKeyRateLimitError,
type DavDeadProperty,
@@ -308,6 +309,7 @@ export async function putWebDavFile(
contentType: string
contentLength: number | null
body: ReadableStream | Uint8Array
createdBy: ActorIdentity
onTiming?: (phase: 'storage' | 's3' | 'persist', durationMs: number) => void
},
): Promise<PutWebDavOutcome> {
@@ -351,6 +353,7 @@ export async function putWebDavFile(
objectKey,
contentType,
uploadedSize,
createdBy: params.createdBy,
}),
),
)
@@ -366,6 +369,7 @@ export async function putWebDavFile(
objectKey,
contentType,
uploadedSize,
createdBy: params.createdBy,
}),
)
if (sizeDelta < 0) await deps.storageUsage.reconcile(orgId, [storage.id])
@@ -402,6 +406,7 @@ async function persistWebDavUpload(
objectKey: string
contentType: string
uploadedSize: number
createdBy: ActorIdentity
},
): Promise<{ status: 201 | 204; matterId: string; bytes: number }> {
const { orgId, target, fileName, parent, storage, objectKey, contentType, uploadedSize } = params
@@ -421,6 +426,9 @@ async function persistWebDavUpload(
object: objectKey,
storageId: storage.id,
status: ObjectStatus.ACTIVE,
createdByActorType: params.createdBy.type,
createdByActorRef: params.createdBy.ref,
createdByActorIssuer: params.createdBy.issuer,
})
return { status: 201, matterId: matter.id, bytes: uploadedSize }
}
@@ -429,7 +437,7 @@ async function persistWebDavUpload(
export async function createWebDavCollection(
deps: Pick<Deps, 'matter' | 'storages'>,
params: { orgId: string; userId: string; name: string; parent: string },
params: { orgId: string; userId: string; name: string; parent: string; createdBy: ActorIdentity },
): Promise<void> {
const storage = await deps.storages.select()
await deps.matter.create({
@@ -442,6 +450,9 @@ export async function createWebDavCollection(
object: '',
storageId: storage.id,
status: ObjectStatus.ACTIVE,
createdByActorType: params.createdBy.type,
createdByActorRef: params.createdBy.ref,
createdByActorIssuer: params.createdBy.issuer,
})
}
@@ -514,6 +525,7 @@ export async function copyWebDavFile(
targetResourcePath: string
replacedMatterId: string | null
replacingTarget: boolean
createdBy: ActorIdentity
},
): Promise<CopyWebDavFileOutcome> {
const { orgId, userId, sourceMatter } = params
@@ -540,6 +552,9 @@ export async function copyWebDavFile(
}
const copy = await deps.matter.copy({ ...sourceMatter, name: params.targetName }, params.targetParent, newObject, {
onConflict: 'fail',
createdByActorType: params.createdBy.type,
createdByActorRef: params.createdBy.ref,
createdByActorIssuer: params.createdBy.issuer,
})
const copyPath = joinMatterPath(copy.parent, copy.name)
await deps.webdavState.copyDeadProperties(orgId, params.sourceResourcePath, copyPath)
@@ -575,6 +590,7 @@ export async function copyWebDavCollection(
targetMatter: Matter | null
replacingTarget: boolean
depth: '0' | 'infinity'
createdBy: ActorIdentity
},
): Promise<CopyWebDavCollectionOutcome> {
const { orgId, userId, sourceMatter, sourceRoot, targetMatter } = params
@@ -627,6 +643,9 @@ export async function copyWebDavCollection(
const rootCopy = await deps.matter.copy({ ...sourceMatter, name: params.targetName }, params.targetParent, '', {
onConflict: 'fail',
createdByActorType: params.createdBy.type,
createdByActorRef: params.createdBy.ref,
createdByActorIssuer: params.createdBy.issuer,
})
createdIds.push(rootCopy.id)
await deps.webdavState.copyDeadProperties(orgId, sourceRoot, joinMatterPath(rootCopy.parent, rootCopy.name))
@@ -634,6 +653,9 @@ export async function copyWebDavCollection(
for (const prepared of preparedCopies) {
const copy = await deps.matter.copy(prepared.item, prepared.targetParent, prepared.objectKey, {
onConflict: 'fail',
createdByActorType: params.createdBy.type,
createdByActorRef: params.createdBy.ref,
createdByActorIssuer: params.createdBy.issuer,
})
createdIds.push(copy.id)
await deps.webdavState.copyDeadProperties(
@@ -698,6 +720,7 @@ export async function createWebDavLock(
owner: string
depth: string
timeoutSeconds: number
createdBy: ActorIdentity
},
): Promise<{ lock: DavLock; created: boolean } | null> {
const { orgId, userId, target } = params
@@ -729,6 +752,9 @@ export async function createWebDavLock(
object: objectKey,
storageId: storage.id,
status: ObjectStatus.ACTIVE,
createdByActorType: params.createdBy.type,
createdByActorRef: params.createdBy.ref,
createdByActorIssuer: params.createdBy.issuer,
})
const lock = await deps.webdavState.createLock({
orgId,