fix(audit): enforce HTTP usecase boundary

Move transfer audit persistence and redirect audit target resolution behind usecase functions so HTTP handlers pass dependencies whole and satisfy the architectural boundary.
This commit is contained in:
saltbo
2026-07-21 23:09:39 -04:00
parent e55ee53496
commit ff78a33464
9 changed files with 180 additions and 187 deletions
+3 -2
View File
@@ -14,7 +14,7 @@ import {
import type { Context } from 'hono'
import { createMiddleware } from 'hono/factory'
import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants'
import { recordDownloadIssued, transferAuditActor } from '../middleware/audit-transfers'
import { transferAuditActor } from '../middleware/audit-transfers'
import { requireTeamRole } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import {
@@ -34,6 +34,7 @@ import {
updateObject,
} from '../usecases/object'
import { badRequest, forbidden, type Matter, unauthorized } from '../usecases/ports'
import { recordDownloadIssued } from '../usecases/transfer-activity'
import { errorResponse, jsonBody, jsonContent } from './openapi'
// The wire shape of a file/folder — exactly what the API serializes. Timestamps
@@ -443,7 +444,7 @@ const objects = app
if (result.ok) {
if ('downloadUrl' in result) {
await recordDownloadIssued(
c.get('deps').audit,
c.get('deps'),
transferAuditActor(c.get('principal')),
'object_download',
{
+7 -53
View File
@@ -1,14 +1,7 @@
import type { Context } from 'hono'
import { Hono } from 'hono'
import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants'
import {
isDownloadFailureStatus,
recordDownloadFailure,
recordDownloadIssued,
type TransferAuditTarget,
transferAuditActor,
transferFailureReason,
} from '../middleware/audit-transfers'
import { isDownloadFailureStatus, transferAuditActor, transferFailureReason } from '../middleware/audit-transfers'
import type { Env } from '../middleware/platform'
import { notFound } from '../usecases/ports'
import {
@@ -16,7 +9,9 @@ import {
type ImageHostingOutcome,
resolveDirectShareDownload,
resolveImageHostingDownload,
resolveRedirectDownloadAuditTarget,
} from '../usecases/redirect'
import { recordDownloadFailure, recordDownloadIssued } from '../usecases/transfer-activity'
// Strip optional file extension from token (e.g. "ih_aB3xK9.png" → "ih_aB3xK9")
function stripExtension(token: string): string {
@@ -39,7 +34,7 @@ async function handleDirectShare(c: Context<Env>, token: string): Promise<Respon
})
if (outcome.ok) {
await recordDownloadIssued(
c.get('deps').audit,
c.get('deps'),
transferAuditActor(c.get('principal')),
'share_download',
{
@@ -71,7 +66,7 @@ async function handleImageHosting(c: Context<Env>, token: string): Promise<Respo
})
if (outcome.ok) {
await recordDownloadIssued(
c.get('deps').audit,
c.get('deps'),
transferAuditActor(c.get('principal')),
'image_hosting_download',
{
@@ -93,15 +88,10 @@ async function handleImageHosting(c: Context<Env>, token: string): Promise<Respo
const app = new Hono<Env>()
app.use('/:token', async (c, next) => {
const target = await redirectDownloadAuditTarget(c, stripExtension(c.req.param('token')))
const target = await resolveRedirectDownloadAuditTarget(c.get('deps'), stripExtension(c.req.param('token')))
await next()
if (!target || !isDownloadFailureStatus(c.res.status)) return
await recordDownloadFailure(
c.get('deps').audit,
transferAuditActor(c.get('principal')),
target,
transferFailureReason(c),
)
await recordDownloadFailure(c.get('deps'), transferAuditActor(c.get('principal')), target, transferFailureReason(c))
})
app.get('/:token', async (c) => {
@@ -114,40 +104,4 @@ app.get('/:token', async (c) => {
throw notFound()
})
async function redirectDownloadAuditTarget(c: Context<Env>, token: string): Promise<TransferAuditTarget | null> {
if (token.startsWith('ds_')) {
const resolved = await c.get('deps').share.resolveByToken(token)
if (resolved.status !== 'ok' || resolved.share.kind !== 'direct') return null
return {
orgId: resolved.share.orgId,
targetType: 'share',
targetId: resolved.share.id,
targetName: resolved.matter.name,
bytes: resolved.matter.size ?? 0,
source: 'direct_share',
metadata: {
shareId: resolved.share.id,
matterId: resolved.matter.id,
storageId: resolved.matter.storageId,
},
}
}
if (token.startsWith('ih_')) {
const resolved = await c.get('deps').imageHosting.resolveActiveByToken(token)
if (!resolved) return null
return {
orgId: resolved.image.orgId,
targetType: 'image',
targetId: resolved.image.id,
targetName: resolved.image.path,
bytes: resolved.image.size,
source: 'image_hosting',
metadata: { imageId: resolved.image.id, storageId: resolved.image.storageId },
}
}
return null
}
export default app
+3 -2
View File
@@ -4,7 +4,7 @@ import { getCookie, setCookie } from 'hono/cookie'
import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants'
import { pageSchema } from '../../shared/schemas'
import { createShareRequestSchema, listSharesQuerySchema, saveShareRequestSchema } from '../../shared/schemas/share'
import { recordDownloadIssued, transferAuditActor } from '../middleware/audit-transfers'
import { transferAuditActor } from '../middleware/audit-transfers'
import { requireAuth, requireTeamRole } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import type { Matter, ShareListItem } from '../usecases/ports'
@@ -20,6 +20,7 @@ import {
verifySharePassword,
viewShare,
} from '../usecases/share'
import { recordDownloadIssued } from '../usecases/transfer-activity'
import { errorResponse, jsonBody, jsonContent } from './openapi'
import { cookieName, decodeChildRef, readUserId, viewCookieName } from './share-utils'
@@ -242,7 +243,7 @@ pub.get('/:token/objects/:ref', async (c) => {
})
if (out.ok) {
await recordDownloadIssued(
c.get('deps').audit,
c.get('deps'),
transferAuditActor(c.get('principal')),
'share_download',
{
+6 -5
View File
@@ -1,9 +1,9 @@
import { Hono } from 'hono'
import { recordAuditEffect } from '../../lib/audit'
import type { Env } from '../../middleware/platform'
import { requireFeature } from '../../middleware/require-feature'
import type { RecordAuditEventInput } from '../../usecases/ports'
import { processDeliveryWebhook } from '../../usecases/store/store'
import { recordAuditEventOnce } from '../../usecases/transfer-activity'
import { getCloudBaseUrl, parseJson, sha256Hex } from './helpers'
export const cloudStoreWebhooks = new Hono<Env>().use(requireFeature('quota_store')).post('/webhook', async (c) => {
@@ -37,10 +37,11 @@ export const cloudStoreWebhooks = new Hono<Env>().use(requireFeature('quota_stor
packageName: event.packageName ?? null,
},
}
await recordAuditEffect(auditEvent.action, () =>
c
.get('deps')
.audit.recordOnce(auditEvent, `cloud-store:${event.eventId}`, new Date(event.occurredAt ?? Date.now())),
await recordAuditEventOnce(
c.get('deps'),
auditEvent,
`cloud-store:${event.eventId}`,
new Date(event.occurredAt ?? Date.now()),
)
return c.json({ success: true, duplicate: outcome.duplicate, eventId: outcome.eventId })
})
+13 -15
View File
@@ -19,17 +19,8 @@ import {
workspaceEntry,
xmlResponse,
} from '../domain/webdav-xml'
import { recordAuditEffect } from '../lib/audit'
import { mapDomainError } from '../lib/http-errors'
import {
isDownloadFailureStatus,
recordDownloadFailure,
recordDownloadIssued,
recordUploadResult,
type TransferAuditTarget,
transferAuditActor,
transferFailureReason,
} from '../middleware/audit-transfers'
import { isDownloadFailureStatus, transferAuditActor, transferFailureReason } from '../middleware/audit-transfers'
import type { Env } from '../middleware/platform'
import {
ApiKeyRateLimitError,
@@ -39,6 +30,13 @@ import {
WebDavPathError,
type WebDavTarget,
} from '../usecases/ports'
import {
recordAuditEvent,
recordDownloadFailure,
recordDownloadIssued,
recordUploadResult,
type TransferAuditTarget,
} from '../usecases/transfer-activity'
import {
activeLocks,
activeLocksForResources,
@@ -638,28 +636,28 @@ app.use('*', async (c, next) => {
if (c.req.method === 'GET' && isDownloadFailureStatus(c.res.status)) {
const target = await webDavDownloadAuditTarget(c, userId)
if (target) {
await recordDownloadFailure(c.get('deps').audit, actor, target, transferFailureReason(c))
await recordDownloadFailure(c.get('deps'), actor, target, transferFailureReason(c))
}
return
}
if (preparedAction && c.res.status < 400) {
const event = await resolveWebDavActionAudit(c, userId, preparedAction)
if (event) await recordAuditEffect(event.action, () => c.get('deps').audit.record({ ...actor, ...event }))
if (event) await recordAuditEvent(c.get('deps'), { ...actor, ...event })
}
if (c.req.method !== 'PUT') return
if (c.res.status === 201 || c.res.status === 204) {
const target = await webDavUploadedTarget(c, userId)
if (!target) throw new Error('transfer_audit_context_missing:webdav_upload')
await recordUploadResult(c.get('deps').audit, actor, target)
await recordUploadResult(c.get('deps'), actor, target)
return
}
if (!preparedPut || !isUploadFailureStatus(c.res.status)) return
const bytes = exactRequestContentLength(c)
if (bytes === null) return
await recordUploadResult(c.get('deps').audit, actor, { ...preparedPut, bytes }, transferFailureReason(c))
await recordUploadResult(c.get('deps'), actor, { ...preparedPut, bytes }, transferFailureReason(c))
})
type WebDavActionAuditContext = {
@@ -1089,7 +1087,7 @@ async function finishWebDavDownload(
): Promise<void> {
await recordWebDavDownloadIssued(c.get('deps'), params)
await recordDownloadIssued(
c.get('deps').audit,
c.get('deps'),
transferAuditActor(c.get('principal')),
'webdav_download',
{
+2 -105
View File
@@ -1,24 +1,12 @@
import { DirType } from '@shared/constants'
import type { Context } from 'hono'
import { recordAuditEffect } from '../lib/audit'
import type { AuditRepo, Matter, RecordAuditEventInput } from '../usecases/ports'
import type { Matter } from '../usecases/ports'
import { decodeChildRef } from '../usecases/share'
import type { TransferAuditTarget } from '../usecases/transfer-activity'
import { type AuditActor, auditActor } from './audit-actor'
import { type AuditRoute, type AuditRouteContext, auditRoute } from './audit-registry'
import type { AuthPrincipal, Env } from './platform'
export type TransferAuditTarget = {
orgId: string
targetType: 'file' | 'image' | 'share'
targetId?: string
targetName: string
bytes: number
source: string
metadata?: Record<string, unknown>
}
type DownloadIssuedAction = 'share_download' | 'object_download' | 'image_hosting_download' | 'webdav_download'
const DOWNLOAD_FAILURE_STATUSES = new Set([402, 422, 500])
const UPLOAD_FAILURE_STATUSES = new Set([404, 409, 422, 500, 502])
@@ -125,52 +113,6 @@ export function transferFailureReason(c: Context<Env>): string {
return 'internal'
}
export function recordUploadResult(
audit: Pick<AuditRepo, 'record'>,
actor: AuditActor,
target: TransferAuditTarget,
reason?: string,
): Promise<void> {
const event = buildUploadResultEvent(actor, target, reason)
return recordAuditEffect(event.action, () => audit.record(event))
}
export function recordDownloadFailure(
audit: Pick<AuditRepo, 'record'>,
actor: AuditActor,
target: TransferAuditTarget,
reason: string,
): Promise<void> {
const event = buildDownloadFailureEvent(actor, target, reason)
return recordAuditEffect(event.action, () => audit.record(event))
}
export function recordDownloadIssued(
audit: Pick<AuditRepo, 'record'>,
actor: AuditActor,
action: DownloadIssuedAction,
target: TransferAuditTarget,
trafficEventId: string,
): Promise<void> {
const event: RecordAuditEventInput = {
...actor,
orgId: target.orgId,
action,
targetType: target.targetType,
targetId: target.targetId,
targetName: target.targetName,
metadata: {
...target.metadata,
direction: 'download',
status: 'issued',
source: target.source,
bytes: target.bytes,
trafficEventId,
},
}
return recordAuditEffect(event.action, () => audit.record(event))
}
export function transferAuditActor(principal: AuthPrincipal | null): AuditActor {
return auditActor(principal)
}
@@ -315,51 +257,6 @@ function transferTargetName(context: AuditRouteContext): string | undefined {
return transferTarget(context)?.targetName
}
function buildUploadResultEvent(
actor: AuditActor,
target: TransferAuditTarget,
reason?: string,
): RecordAuditEventInput {
return {
...actor,
orgId: target.orgId,
action: reason ? 'upload_failed' : 'upload_confirm',
targetType: target.targetType,
targetId: target.targetId,
targetName: target.targetName,
metadata: {
...target.metadata,
bytes: target.bytes,
source: target.source,
status: reason ? 'failed' : 'success',
...(reason ? { reason } : {}),
},
}
}
function buildDownloadFailureEvent(
actor: AuditActor,
target: TransferAuditTarget,
reason: string,
): RecordAuditEventInput {
return {
...actor,
orgId: target.orgId,
action: 'download_failed',
targetType: target.targetType,
targetId: target.targetId,
targetName: target.targetName,
metadata: {
...target.metadata,
direction: 'download',
status: 'failed',
source: target.source,
bytes: target.bytes,
reason,
},
}
}
function stringField(value: Record<string, unknown>, field: string): string | undefined {
return typeof value[field] === 'string' ? value[field] : undefined
}
+3 -4
View File
@@ -4,8 +4,7 @@ import { reportTrafficForDownload } from '../http/store/traffic-metering'
import type { Env } from '../middleware/platform'
import { forbidden, notFound, quotaExceeded, storageNotFound } from '../usecases/ports'
import { confirmDownloadTraffic, reverseDownloadTraffic } from '../usecases/store/traffic-metering'
import { createTrafficEventId } from '../usecases/transfer-activity'
import { recordDownloadFailure, recordDownloadIssued } from './audit-transfers'
import { createTrafficEventId, recordDownloadFailure, recordDownloadIssued } from '../usecases/transfer-activity'
function stripPort(host: string): string {
const lastColon = host.lastIndexOf(':')
@@ -103,7 +102,7 @@ async function handleImageByPath(c: Context<Env>, orgId: string, virtualPath: st
console.error('[image-hosting-domain] incrementAccessCount failed:', error)
}
await recordDownloadIssued(
c.get('deps').audit,
c.get('deps'),
{ userId: null, actorType: 'anonymous', actorRef: null },
'image_hosting_download',
{
@@ -128,7 +127,7 @@ function recordImageDownloadFailure(
reason: string,
): Promise<void> {
return recordDownloadFailure(
c.get('deps').audit,
c.get('deps'),
{ userId: null, actorType: 'anonymous', actorRef: null },
{
orgId: image.orgId,
+40 -1
View File
@@ -30,7 +30,7 @@ import {
reportDownloadEgress,
reverseDownloadTraffic,
} from './store/traffic-metering'
import { createTrafficEventId } from './transfer-activity'
import { createTrafficEventId, type TransferAuditTarget } from './transfer-activity'
// The metering usecases need the cloud-report ports plus quota; the redirect
// flows additionally read shares / image-hosting / storages and presign via s3.
@@ -42,6 +42,45 @@ export type RedirectDeps = CloudTrafficMeteringDeps & {
imageHosting: ImageHostingRepo
}
export async function resolveRedirectDownloadAuditTarget(
deps: Pick<RedirectDeps, 'share' | 'imageHosting'>,
token: string,
): Promise<TransferAuditTarget | null> {
if (token.startsWith('ds_')) {
const resolved = await deps.share.resolveByToken(token)
if (resolved.status !== 'ok' || resolved.share.kind !== 'direct') return null
return {
orgId: resolved.share.orgId,
targetType: 'share',
targetId: resolved.share.id,
targetName: resolved.matter.name,
bytes: resolved.matter.size ?? 0,
source: 'direct_share',
metadata: {
shareId: resolved.share.id,
matterId: resolved.matter.id,
storageId: resolved.matter.storageId,
},
}
}
if (token.startsWith('ih_')) {
const resolved = await deps.imageHosting.resolveActiveByToken(token)
if (!resolved) return null
return {
orgId: resolved.image.orgId,
targetType: 'image',
targetId: resolved.image.id,
targetName: resolved.image.path,
bytes: resolved.image.size,
source: 'image_hosting',
metadata: { imageId: resolved.image.id, storageId: resolved.image.storageId },
}
}
return null
}
// ─── Direct share (ds_) ──────────────────────────────────────────────────────
export type DirectShareOutcome =
+103
View File
@@ -1,3 +1,106 @@
import { recordAuditEffect } from '../lib/audit'
import type { Deps } from './deps'
import type { RecordAuditEventInput } from './ports'
export type TransferAuditTarget = {
orgId: string
targetType: 'file' | 'image' | 'share'
targetId?: string
targetName: string
bytes: number
source: string
metadata?: Record<string, unknown>
}
type TransferAuditActor = Pick<RecordAuditEventInput, 'userId' | 'actorType' | 'actorRef'>
type DownloadIssuedAction = 'share_download' | 'object_download' | 'image_hosting_download' | 'webdav_download'
export function createTrafficEventId(): string {
return `traffic_${crypto.randomUUID()}`
}
export function recordAuditEvent(deps: Pick<Deps, 'audit'>, event: RecordAuditEventInput): Promise<void> {
return recordAuditEffect(event.action, () => deps.audit.record(event))
}
export function recordAuditEventOnce(
deps: Pick<Deps, 'audit'>,
event: RecordAuditEventInput,
idempotencyKey: string,
occurredAt: Date,
): Promise<void> {
return recordAuditEffect(event.action, () => deps.audit.recordOnce(event, idempotencyKey, occurredAt))
}
export function recordUploadResult(
deps: Pick<Deps, 'audit'>,
actor: TransferAuditActor,
target: TransferAuditTarget,
reason?: string,
): Promise<void> {
const event: RecordAuditEventInput = {
...actor,
orgId: target.orgId,
action: reason ? 'upload_failed' : 'upload_confirm',
targetType: target.targetType,
targetId: target.targetId,
targetName: target.targetName,
metadata: {
...target.metadata,
bytes: target.bytes,
source: target.source,
status: reason ? 'failed' : 'success',
...(reason ? { reason } : {}),
},
}
return recordAuditEvent(deps, event)
}
export function recordDownloadFailure(
deps: Pick<Deps, 'audit'>,
actor: TransferAuditActor,
target: TransferAuditTarget,
reason: string,
): Promise<void> {
return recordAuditEvent(deps, {
...actor,
orgId: target.orgId,
action: 'download_failed',
targetType: target.targetType,
targetId: target.targetId,
targetName: target.targetName,
metadata: {
...target.metadata,
direction: 'download',
status: 'failed',
source: target.source,
bytes: target.bytes,
reason,
},
})
}
export function recordDownloadIssued(
deps: Pick<Deps, 'audit'>,
actor: TransferAuditActor,
action: DownloadIssuedAction,
target: TransferAuditTarget,
trafficEventId: string,
): Promise<void> {
return recordAuditEvent(deps, {
...actor,
orgId: target.orgId,
action,
targetType: target.targetType,
targetId: target.targetId,
targetName: target.targetName,
metadata: {
...target.metadata,
direction: 'download',
status: 'issued',
source: target.source,
bytes: target.bytes,
trafficEventId,
},
})
}