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
@@ -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)