feat: harden content asset access

This commit is contained in:
Codex
2026-06-29 18:25:47 +08:00
parent ee578af1f6
commit 79d0d786a0
13 changed files with 762 additions and 67 deletions

View File

@@ -18,6 +18,12 @@ import {
validateObjectKey,
type StorageProviderName,
} from '../storage/service.js';
import {
assetAccessTtl,
assertCdnAccessAllowed,
recordAssetAccessEvent,
signedAssetFingerprint,
} from '../storage/asset-access.js';
const ASSET_TYPES = ['pdf', 'video', 'image', 'audio', 'document', 'package', 'link', 'other'];
const STORAGE_PROVIDERS = ['external_url', 'supabase_storage', 'aliyun_oss', 'tencent_cos', 'qiniu_kodo', 'local_dev'];
@@ -47,6 +53,8 @@ interface AssetRow {
verifiedSizeBytes: number | null;
verifiedChecksumSha256: string | null;
previewStatus: string;
accessRules?: Record<string, unknown>;
metadata?: Record<string, unknown>;
}
function choice(value: unknown, allowed: string[], fallback: string, label: string) {
@@ -61,6 +69,12 @@ function nullableUuid(value: unknown) {
return nullableString(value);
}
function assertUuidParam(value: string, label: string) {
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
throw new HttpError(400, `${label} must be a valid UUID`, 'INVALID_UUID');
}
}
function safeFileName(fileName: string) {
return fileName
.trim()
@@ -173,6 +187,27 @@ async function recordAssetAudit(auth: TenantContentAuth, action: string, targetI
);
}
async function recordDeniedAdminAssetAccess(ctx: RequestContext, auth: TenantContentAuth, asset: AssetRow, accessType: 'admin_download' | 'admin_preview', error: unknown) {
const denyCode = error instanceof HttpError ? error.code : 'ASSET_ACCESS_DENIED';
await recordAssetAccessEvent({
ctx,
tenantId: auth.tenantId,
assetId: asset.id,
userId: auth.userId,
actorRole: 'tenant_content_editor',
accessType,
visibility: asset.visibility,
assetType: asset.assetType,
storageProvider: asset.storageProvider,
result: 'denied',
denyCode,
metadata: {
title: asset.title,
fileName: asset.fileName,
},
});
}
export async function assetsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const limit = intParam(ctx, 'limit', 100, 500);
@@ -253,6 +288,40 @@ export async function assetsAdminRoute(ctx: RequestContext) {
return { items };
}
export async function assetAccessEventsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const limit = intParam(ctx, 'limit', 100, 500);
const assetId = stringParam(ctx, 'assetId');
const params: unknown[] = [auth.tenantId];
const filters = ['tenant_id = $1'];
if (assetId) {
assertUuidParam(assetId, 'assetId');
params.push(assetId);
filters.push(`asset_id = $${params.length}::uuid`);
}
params.push(limit);
const items = await query(
`
select id, asset_id as "assetId", user_id as "userId",
actor_role as "actorRole", access_type as "accessType",
visibility, asset_type as "assetType",
storage_provider as "storageProvider", disposition,
expires_in_sec as "expiresInSec", signature_mode as "signatureMode",
result, deny_code as "denyCode", ip_address as "ipAddress",
user_agent as "userAgent", metadata, created_at as "createdAt"
from public.content_asset_access_events
where ${filters.join(' and ')}
order by created_at desc
limit $${params.length}
`,
params,
);
return { items };
}
export async function upsertAssetRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
@@ -562,6 +631,25 @@ export async function confirmAssetUploadRoute(ctx: RequestContext) {
`,
[auth.tenantId, assetId, JSON.stringify(verificationDetails), auth.userId],
);
await recordAssetAccessEvent({
ctx,
tenantId: auth.tenantId,
assetId,
userId: auth.userId,
actorRole: 'tenant_content_editor',
accessType: 'upload_confirm',
visibility: asset.visibility,
assetType: asset.assetType,
storageProvider: asset.storageProvider,
result: 'denied',
denyCode: 'UPLOAD_VERIFICATION_FAILED',
metadata: {
provider,
bucket: asset.bucket,
objectKey: asset.objectKey,
issues,
},
});
throw new HttpError(409, `Upload verification failed: ${issues.join(', ')}`, 'UPLOAD_VERIFICATION_FAILED');
}
@@ -611,6 +699,26 @@ export async function confirmAssetUploadRoute(ctx: RequestContext) {
checksumVerified: verificationDetails.checksumVerified,
checksumUnavailable: verificationDetails.checksumUnavailable,
});
await recordAssetAccessEvent({
ctx,
tenantId: auth.tenantId,
assetId,
userId: auth.userId,
actorRole: 'tenant_content_editor',
accessType: 'upload_confirm',
visibility: asset.visibility,
assetType: asset.assetType,
storageProvider: asset.storageProvider,
result: 'granted',
metadata: {
provider,
bucket: asset.bucket,
objectKey: asset.objectKey,
publish,
checksumVerified: verificationDetails.checksumVerified,
checksumUnavailable: verificationDetails.checksumUnavailable,
},
});
return { item, metadata, verification: verificationDetails };
}
@@ -651,6 +759,27 @@ export async function signAssetUploadRoute(ctx: RequestContext) {
mimeType,
fileSizeBytes,
});
await recordAssetAccessEvent({
ctx,
tenantId: auth.tenantId,
userId: auth.userId,
actorRole: 'tenant_content_editor',
accessType: 'upload_sign',
assetType,
storageProvider,
disposition: 'attachment',
expiresInSec: upload.expiresInSec,
signatureMode: upload.signatureMode,
result: 'granted',
metadata: {
bucket,
objectKey: cleanObjectKey,
fileName,
mimeType,
fileSizeBytes,
signature: signedAssetFingerprint(upload),
},
});
return {
upload,
@@ -671,7 +800,7 @@ export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const assetId = requiredString(body, 'assetId');
const expiresInSec = Math.min(Math.max(intValue(body.expiresInSec, 900), 60), 86_400);
const requestedExpiresInSec = intValue(body.expiresInSec, 900);
const asset = await queryOne<AssetRow>(
`
@@ -684,7 +813,8 @@ export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
visibility, status, upload_status as "uploadStatus",
verified_size_bytes as "verifiedSizeBytes",
verified_checksum_sha256 as "verifiedChecksumSha256",
preview_status as "previewStatus"
preview_status as "previewStatus",
access_rules as "accessRules", metadata
from public.content_assets
where tenant_id = $1 and id = $2
limit 1
@@ -695,15 +825,36 @@ export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
if (!asset) {
throw new HttpError(404, 'Asset not found', 'ASSET_NOT_FOUND');
}
const expiresInSec = assetAccessTtl({
actorRole: 'tenant_content_editor',
accessType: 'admin_download',
visibility: asset.visibility,
assetType: asset.assetType,
disposition: 'attachment',
requestedExpiresInSec,
});
try {
assertCdnAccessAllowed({
assetId,
visibility: asset.visibility,
assetType: asset.assetType,
storageProvider: asset.storageProvider as StorageProviderName,
objectKey: asset.objectKey,
cdnUrl: asset.cdnUrl,
metadata: asset.metadata,
accessRules: asset.accessRules,
});
} catch (error) {
await recordDeniedAdminAssetAccess(ctx, auth, asset, 'admin_download', error);
throw error;
}
await query(
'update public.content_assets set download_count = download_count + 1, updated_at = now() where tenant_id = $1 and id = $2',
[auth.tenantId, assetId],
);
return {
item: asset,
download: await signStorageDownload({
const download = await signStorageDownload({
tenantId: auth.tenantId,
provider: asset.storageProvider as StorageProviderName,
bucket: asset.bucket,
@@ -712,15 +863,32 @@ export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
fileName: asset.fileName,
expiresInSec,
disposition: 'attachment',
}),
};
});
await recordAssetAccessEvent({
ctx,
tenantId: auth.tenantId,
assetId,
userId: auth.userId,
actorRole: 'tenant_content_editor',
accessType: 'admin_download',
visibility: asset.visibility,
assetType: asset.assetType,
storageProvider: asset.storageProvider,
disposition: 'attachment',
expiresInSec: download.expiresInSec,
signatureMode: download.signatureMode,
result: 'granted',
metadata: { signature: signedAssetFingerprint(download) },
});
return { item: asset, download };
}
export async function signAssetPreviewAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const assetId = requiredString(body, 'assetId');
const expiresInSec = Math.min(Math.max(intValue(body.expiresInSec, 900), 60), 3600);
const requestedExpiresInSec = intValue(body.expiresInSec, 900);
const asset = await queryOne<AssetRow>(
`
@@ -732,7 +900,8 @@ export async function signAssetPreviewAdminRoute(ctx: RequestContext) {
visibility, status, upload_status as "uploadStatus",
verified_size_bytes as "verifiedSizeBytes",
verified_checksum_sha256 as "verifiedChecksumSha256",
preview_status as "previewStatus"
preview_status as "previewStatus",
access_rules as "accessRules", metadata
from public.content_assets
where tenant_id = $1 and id = $2
limit 1
@@ -748,6 +917,56 @@ export async function signAssetPreviewAdminRoute(ctx: RequestContext) {
const objectKey = asset.previewObjectKey || asset.objectKey;
const cdnUrl = asset.previewUrl || asset.cdnUrl;
const fileName = asset.fileName || asset.title || 'preview.pdf';
const expiresInSec = assetAccessTtl({
actorRole: 'tenant_content_editor',
accessType: 'admin_preview',
visibility: asset.visibility,
assetType: asset.assetType,
disposition: 'inline',
requestedExpiresInSec,
});
try {
assertCdnAccessAllowed({
assetId,
visibility: asset.visibility,
assetType: asset.assetType,
storageProvider: asset.storageProvider as StorageProviderName,
objectKey,
cdnUrl,
metadata: asset.metadata,
accessRules: asset.accessRules,
});
} catch (error) {
await recordDeniedAdminAssetAccess(ctx, auth, asset, 'admin_preview', error);
throw error;
}
const preview = await signStorageDownload({
tenantId: auth.tenantId,
provider: asset.storageProvider as StorageProviderName,
bucket: asset.bucket,
objectKey,
cdnUrl,
fileName,
expiresInSec,
disposition: 'inline',
});
await recordAssetAccessEvent({
ctx,
tenantId: auth.tenantId,
assetId,
userId: auth.userId,
actorRole: 'tenant_content_editor',
accessType: 'admin_preview',
visibility: asset.visibility,
assetType: asset.assetType,
storageProvider: asset.storageProvider,
disposition: 'inline',
expiresInSec: preview.expiresInSec,
signatureMode: preview.signatureMode,
result: 'granted',
metadata: { signature: signedAssetFingerprint(preview) },
});
return {
item: {
@@ -757,15 +976,6 @@ export async function signAssetPreviewAdminRoute(ctx: RequestContext) {
fileName: asset.fileName,
previewStatus: asset.previewStatus,
},
preview: await signStorageDownload({
tenantId: auth.tenantId,
provider: asset.storageProvider as StorageProviderName,
bucket: asset.bucket,
objectKey,
cdnUrl,
fileName,
expiresInSec,
disposition: 'inline',
}),
preview,
};
}

View File

@@ -1,5 +1,6 @@
import type { RouteDefinition } from '../../core/router.js';
import {
assetAccessEventsAdminRoute,
assetsAdminRoute,
confirmAssetUploadRoute,
signAssetDownloadAdminRoute,
@@ -102,6 +103,7 @@ export const tenantContentRoutes: RouteDefinition[] = [
['POST', '/api/tenant-content/questions', createQuestionRoute],
['PATCH', '/api/tenant-content/questions', updateQuestionRoute],
['GET', '/api/tenant-content/assets', assetsAdminRoute],
['GET', '/api/tenant-content/assets/access-events', assetAccessEventsAdminRoute],
['PUT', '/api/tenant-content/assets', upsertAssetRoute],
['POST', '/api/tenant-content/assets/sign-upload', signAssetUploadRoute],
['POST', '/api/tenant-content/assets/confirm-upload', confirmAssetUploadRoute],