diff --git a/README.md b/README.md index 8b345202..94028ee6 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ - Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。 - 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配;完整资金流水对账和真实生产账号联调还没接完。 -- OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF/图片预览和资源复检 worker 已完成,CDN 防盗链、杀毒扫描和视频动态水印还没完成。 +- OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF/图片预览、资源访问事件、锁定资源 CDN 边界和资源复检 worker 已完成,杀毒扫描、视频动态水印、CDN 刷新和对象生命周期策略还没完成。 - Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。 - 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。 - 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。 @@ -304,7 +304,7 @@ API 身份上下文: - 租户公开配置和主题配置不能存放密钥;主题 token 只能是后端允许的颜色、半径、安全 CSS 变量、图标 token 和公开素材引用。 - 商户密钥、短信密钥、OAuth app secret 等必须进入 `app_private.tenant_secrets`,或后续生产 KMS/Vault。 -- 资料、PDF、视频等资源必须先进入 `content_assets` 台账,再由 API 校验权限并下发签名 URL;生产环境应定时运行 assets worker 复检对象元数据,异常资源会被标记 failed 并退回 draft。 +- 资料、PDF、视频等资源必须先进入 `content_assets` 台账,再由 API 校验权限并下发签名 URL;学生端预览、锁定资料和视频会使用短 TTL。`members/svip/private` 外部 CDN URL 默认拒绝,除非显式登记 provider-managed 访问;所有上传签名、上传确认、下载/预览 granted/denied 都写入 `content_asset_access_events`。生产环境应定时运行 assets worker 复检对象元数据,异常资源会被标记 failed 并退回 draft。 - 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。 - 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。题目、单词、知识手册、分数线和视频 JSON/CSV/Excel 导入已走这套后台校验管线;大批量任务可提交 `executionMode=async`,由 imports worker 消费,前端只轮询 job 状态和展示 issues。 - 题库导出必须由后端按权限生成,不允许前端直接读取数据库拼导出文件;不开启答案/解析时,顶层题目和复合题子题都必须脱敏;PDF/Word/每日一练 ZIP 只通过 exports worker 写入 `content_assets` 后再签名下载/预览。 @@ -318,14 +318,14 @@ API 身份上下文: npx supabase db reset npm run check:api npm run check:worker +npm run test:worker:assets npm run test:api -npm run test:worker:exports npm run check:refactor npm run audit:runtime git diff --check ``` -结果:通过。`npm run test:worker:exports` 已覆盖 PDF、Word、每日一练 PDF 和 `daily_practice_zip` 图片素材包生成、资源台账写入、ZIP 解包和答案/解析脱敏。`npm run audit:runtime` 无 high/critical 漏洞;当前运行时依赖树仍有 `exceljs -> uuid` 的 moderate 级提示,修复需要破坏性降级 `exceljs`,后续应在导入 Excel 回归充分后单独处理。 +结果:通过。`npm run test:api` 已覆盖资源访问事件、锁定 CDN 资源拒绝、provider-managed CDN 显式放行、学生短 TTL 下载/预览和访问记录查询。`npm run test:worker:assets` 已覆盖托管资源复检和异常资源自动下架。`npm run audit:runtime` 无 high/critical 漏洞;当前运行时依赖树仍有 `exceljs -> uuid` 的 moderate 级提示,修复需要破坏性降级 `exceljs`,后续应在导入 Excel 回归充分后单独处理。 注意:`apps/taro` 是静态构建工程,线上发布 `apps/taro/dist/**`,不发布 `node_modules`。Taro 4.2.0 当前构建工具链仍会触发 `npm run audit:taro:toolchain` 的上游 high/critical 提示,不能用 `npm audit fix --force` 降级到 Taro 3 破坏构建;上线验收时以 `audit:runtime`、构建产物、前端密钥检查和静态服务器配置为准,并持续跟进 Taro 官方修复。 diff --git a/apps/api/src/features/catalog/assets.ts b/apps/api/src/features/catalog/assets.ts index 8cb6bc69..90018195 100644 --- a/apps/api/src/features/catalog/assets.ts +++ b/apps/api/src/features/catalog/assets.ts @@ -2,6 +2,12 @@ import { HttpError, type RequestContext } from '../../core/http.js'; import { intParam, optionalUserIdFrom, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js'; import { query, queryOne } from '../../core/db.js'; import { signStorageDownload, type StorageProviderName } from '../storage/service.js'; +import { + assetAccessTtl, + assertCdnAccessAllowed, + recordAssetAccessEvent, + signedAssetFingerprint, +} from '../storage/asset-access.js'; interface CatalogAssetRow { id: string; @@ -20,6 +26,8 @@ interface CatalogAssetRow { subjectId: string | null; uploadStatus: string; previewStatus: string; + accessRules: Record; + metadata: Record; } async function hasActiveMembership(tenantId: string, userId: string) { @@ -83,6 +91,28 @@ async function assertAssetAccess(ctx: RequestContext, asset: CatalogAssetRow) { return { userId, svip: true }; } +async function recordDeniedAssetAccess(ctx: RequestContext, tenantId: string, asset: CatalogAssetRow, accessType: 'download' | 'preview', error: unknown) { + const userId = await optionalUserIdFrom(ctx).catch(() => ''); + const code = error instanceof HttpError ? error.code : 'ASSET_ACCESS_DENIED'; + await recordAssetAccessEvent({ + ctx, + tenantId, + assetId: asset.id, + userId: userId || null, + actorRole: userId ? 'student' : 'anonymous', + accessType, + visibility: asset.visibility, + assetType: asset.assetType, + storageProvider: asset.storageProvider, + result: 'denied', + denyCode: code, + metadata: { + title: asset.title, + fileName: asset.fileName, + }, + }); +} + function assetPreviewable(asset: CatalogAssetRow) { const mimeType = asset.mimeType?.split(';')[0]?.trim().toLowerCase() || ''; return asset.assetType === 'pdf' || mimeType === 'application/pdf' || asset.assetType === 'image' || mimeType.startsWith('image/'); @@ -174,7 +204,8 @@ export async function assetDownloadRoute(ctx: RequestContext) { cdn_url as "cdnUrl", preview_url as "previewUrl", preview_object_key as "previewObjectKey", mime_type as "mimeType", visibility, region_id as "regionId", subject_id as "subjectId", - upload_status as "uploadStatus", preview_status as "previewStatus" + upload_status as "uploadStatus", preview_status as "previewStatus", + access_rules as "accessRules", metadata from public.content_assets where tenant_id = $1 and id = $2 and status = 'active' limit 1 @@ -186,12 +217,63 @@ export async function assetDownloadRoute(ctx: RequestContext) { throw new HttpError(404, 'Asset not found', 'ASSET_NOT_FOUND'); } - const access = await assertAssetAccess(ctx, asset); - assertPublishedAsset(asset); + let access: { userId: string; svip: boolean }; + try { + access = await assertAssetAccess(ctx, asset); + assertPublishedAsset(asset); + assertCdnAccessAllowed({ + assetId: asset.id, + 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 recordDeniedAssetAccess(ctx, tenantId, asset, 'download', error); + throw error; + } + + const expiresInSec = assetAccessTtl({ + actorRole: access.userId ? 'student' : 'anonymous', + accessType: 'download', + visibility: asset.visibility, + assetType: asset.assetType, + disposition: 'attachment', + requestedExpiresInSec: 900, + }); + const download = await signStorageDownload({ + tenantId, + provider: asset.storageProvider as StorageProviderName, + bucket: asset.bucket, + objectKey: asset.objectKey, + cdnUrl: asset.cdnUrl, + fileName: asset.fileName, + expiresInSec, + disposition: 'attachment', + }); await query( 'update public.content_assets set download_count = download_count + 1, updated_at = now() where tenant_id = $1 and id = $2', [tenantId, assetId], ); + await recordAssetAccessEvent({ + ctx, + tenantId, + assetId: asset.id, + userId: access.userId || null, + actorRole: access.userId ? 'student' : 'anonymous', + accessType: '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: { @@ -205,16 +287,7 @@ export async function assetDownloadRoute(ctx: RequestContext) { visibility: asset.visibility, }, access, - download: await signStorageDownload({ - tenantId, - provider: asset.storageProvider as StorageProviderName, - bucket: asset.bucket, - objectKey: asset.objectKey, - cdnUrl: asset.cdnUrl, - fileName: asset.fileName, - expiresInSec: 900, - disposition: 'attachment', - }), + download, }; } @@ -232,7 +305,8 @@ export async function assetPreviewRoute(ctx: RequestContext) { cdn_url as "cdnUrl", preview_url as "previewUrl", preview_object_key as "previewObjectKey", mime_type as "mimeType", visibility, region_id as "regionId", subject_id as "subjectId", - upload_status as "uploadStatus", preview_status as "previewStatus" + upload_status as "uploadStatus", preview_status as "previewStatus", + access_rules as "accessRules", metadata from public.content_assets where tenant_id = $1 and id = $2 and status = 'active' limit 1 @@ -247,8 +321,58 @@ export async function assetPreviewRoute(ctx: RequestContext) { throw new HttpError(400, 'Asset type does not support inline preview', 'ASSET_PREVIEW_NOT_SUPPORTED'); } - const access = await assertAssetAccess(ctx, asset); - assertPublishedAsset(asset); + let access: { userId: string; svip: boolean }; + try { + access = await assertAssetAccess(ctx, asset); + assertPublishedAsset(asset); + assertCdnAccessAllowed({ + assetId: asset.id, + visibility: asset.visibility, + assetType: asset.assetType, + storageProvider: asset.storageProvider as StorageProviderName, + objectKey: asset.previewObjectKey || asset.objectKey, + cdnUrl: asset.previewUrl || asset.cdnUrl, + metadata: asset.metadata, + accessRules: asset.accessRules, + }); + } catch (error) { + await recordDeniedAssetAccess(ctx, tenantId, asset, 'preview', error); + throw error; + } + const expiresInSec = assetAccessTtl({ + actorRole: access.userId ? 'student' : 'anonymous', + accessType: 'preview', + visibility: asset.visibility, + assetType: asset.assetType, + disposition: 'inline', + requestedExpiresInSec: 900, + }); + const preview = await signStorageDownload({ + tenantId, + provider: asset.storageProvider as StorageProviderName, + bucket: asset.bucket, + objectKey: asset.previewObjectKey || asset.objectKey, + cdnUrl: asset.previewUrl || asset.cdnUrl, + fileName: asset.fileName || asset.title || 'preview.pdf', + expiresInSec, + disposition: 'inline', + }); + await recordAssetAccessEvent({ + ctx, + tenantId, + assetId: asset.id, + userId: access.userId || null, + actorRole: access.userId ? 'student' : 'anonymous', + accessType: '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: { @@ -261,15 +385,6 @@ export async function assetPreviewRoute(ctx: RequestContext) { visibility: asset.visibility, }, access, - preview: await signStorageDownload({ - tenantId, - provider: asset.storageProvider as StorageProviderName, - bucket: asset.bucket, - objectKey: asset.previewObjectKey || asset.objectKey, - cdnUrl: asset.previewUrl || asset.cdnUrl, - fileName: asset.fileName || asset.title || 'preview.pdf', - expiresInSec: 900, - disposition: 'inline', - }), + preview, }; } diff --git a/apps/api/src/features/storage/asset-access.ts b/apps/api/src/features/storage/asset-access.ts new file mode 100644 index 00000000..e8c609c6 --- /dev/null +++ b/apps/api/src/features/storage/asset-access.ts @@ -0,0 +1,153 @@ +import { getHeader, HttpError, type RequestContext } from '../../core/http.js'; +import { query } from '../../core/db.js'; +import type { + SignedStorageUrl, + StorageContentDisposition, + StorageProviderName, +} from './service.js'; + +export type AssetAccessType = 'download' | 'preview' | 'admin_download' | 'admin_preview' | 'upload_sign' | 'upload_confirm'; +export type AssetActorRole = 'anonymous' | 'student' | 'tenant_admin' | 'tenant_content_editor' | 'system'; +export type AssetAccessResult = 'granted' | 'denied'; + +export interface AssetAccessRecordInput { + ctx: RequestContext; + tenantId: string; + assetId?: string | null; + userId?: string | null; + actorRole: AssetActorRole; + accessType: AssetAccessType; + visibility?: string | null; + assetType?: string | null; + storageProvider?: string | null; + disposition?: StorageContentDisposition | null; + expiresInSec?: number | null; + signatureMode?: string | null; + result: AssetAccessResult; + denyCode?: string | null; + metadata?: Record; +} + +export interface AssetAccessPolicyInput { + actorRole: AssetActorRole; + accessType: AssetAccessType; + visibility: string; + assetType: string; + disposition: StorageContentDisposition; + requestedExpiresInSec?: number | null; +} + +export interface AssetCdnPolicyInput { + assetId: string; + visibility: string; + assetType: string; + storageProvider: StorageProviderName; + objectKey?: string | null; + cdnUrl?: string | null; + metadata?: Record | null; + accessRules?: Record | null; +} + +const LOCKED_VISIBILITIES = new Set(['members', 'svip', 'private']); +const SHORT_LIVED_ASSET_TYPES = new Set(['video', 'package']); + +function clientIpFrom(ctx: RequestContext) { + return (getHeader(ctx.req, 'x-forwarded-for').split(',')[0] || getHeader(ctx.req, 'x-real-ip') || ctx.req.socket.remoteAddress || '').trim(); +} + +function userAgentFrom(ctx: RequestContext) { + return getHeader(ctx.req, 'user-agent'); +} + +function objectValue(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + +function boolFlag(...values: unknown[]) { + return values.some(value => value === true || value === 'true'); +} + +export function assetAccessTtl(input: AssetAccessPolicyInput) { + const requested = input.requestedExpiresInSec ?? 900; + const minTtl = 60; + let maxTtl = 900; + + if (input.actorRole === 'tenant_admin' || input.actorRole === 'tenant_content_editor') { + maxTtl = input.accessType === 'admin_preview' ? 3600 : 86_400; + } else if (input.disposition === 'inline') { + maxTtl = 300; + } else if (LOCKED_VISIBILITIES.has(input.visibility) || SHORT_LIVED_ASSET_TYPES.has(input.assetType)) { + maxTtl = 300; + } + + return Math.min(Math.max(requested, minTtl), maxTtl); +} + +export function assertCdnAccessAllowed(input: AssetCdnPolicyInput) { + if (!input.cdnUrl) return; + + const metadata = objectValue(input.metadata); + const accessRules = objectValue(input.accessRules); + const providerManagedAccess = boolFlag( + metadata.providerManagedAccess, + metadata.providerManagedCdn, + metadata.cdnAccessMode === 'signed_by_provider', + accessRules.providerManagedAccess, + accessRules.providerManagedCdn, + accessRules.cdnAccessMode === 'signed_by_provider', + ); + + if (LOCKED_VISIBILITIES.has(input.visibility) && !providerManagedAccess) { + throw new HttpError( + 409, + 'Locked asset requires managed object signing or explicit provider-managed CDN access', + 'ASSET_CDN_ACCESS_NOT_ALLOWED', + ); + } +} + +export function signedAssetFingerprint(signed: SignedStorageUrl) { + return { + provider: signed.provider, + bucket: signed.bucket, + objectKey: signed.objectKey, + method: signed.method, + expiresAt: signed.expiresAt, + expiresInSec: signed.expiresInSec, + signatureMode: signed.signatureMode, + }; +} + +export async function recordAssetAccessEvent(input: AssetAccessRecordInput) { + await query( + ` + insert into public.content_asset_access_events ( + tenant_id, asset_id, user_id, actor_role, access_type, visibility, + asset_type, storage_provider, disposition, expires_in_sec, + signature_mode, result, deny_code, ip_address, user_agent, metadata + ) + values ( + $1, $2::uuid, $3::uuid, $4, $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, $16::jsonb + ) + `, + [ + input.tenantId, + input.assetId || null, + input.userId || null, + input.actorRole, + input.accessType, + input.visibility || null, + input.assetType || null, + input.storageProvider || null, + input.disposition || null, + input.expiresInSec || null, + input.signatureMode || null, + input.result, + input.denyCode || null, + clientIpFrom(input.ctx) || null, + userAgentFrom(input.ctx) || null, + JSON.stringify(input.metadata || {}), + ], + ); +} diff --git a/apps/api/src/features/tenant-content/assets.ts b/apps/api/src/features/tenant-content/assets.ts index e3fd77df..00eca8b1 100644 --- a/apps/api/src/features/tenant-content/assets.ts +++ b/apps/api/src/features/tenant-content/assets.ts @@ -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; + metadata?: Record; } 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( ` @@ -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( ` @@ -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, }; } diff --git a/apps/api/src/features/tenant-content/index.ts b/apps/api/src/features/tenant-content/index.ts index 240480eb..714c4035 100644 --- a/apps/api/src/features/tenant-content/index.ts +++ b/apps/api/src/features/tenant-content/index.ts @@ -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], diff --git a/apps/api/src/features/video/routes.ts b/apps/api/src/features/video/routes.ts index e1725f17..0b64cc0c 100644 --- a/apps/api/src/features/video/routes.ts +++ b/apps/api/src/features/video/routes.ts @@ -12,6 +12,11 @@ import { } from '../../core/request.js'; import { query, queryOne, transaction } from '../../core/db.js'; import { signStorageDownload, type StorageProviderName } from '../storage/service.js'; +import { + assetAccessTtl, + assertCdnAccessAllowed, + signedAssetFingerprint, +} from '../storage/asset-access.js'; interface QuestionVideoRow { questionId: string; @@ -52,6 +57,11 @@ interface VideoPlaybackRow { fileName: string | null; assetTitle: string | null; assetStatus: string | null; + assetType: string | null; + assetVisibility: string | null; + assetUploadStatus: string | null; + assetAccessRules: Record | null; + assetMetadata: Record | null; } interface EntitlementAccessRow { @@ -150,6 +160,19 @@ async function signVideoPlayback(tenantId: string, video: VideoPlaybackRow) { if (video.assetStatus !== 'active') { throw new HttpError(404, 'Video asset is not active', 'VIDEO_ASSET_NOT_FOUND'); } + if (video.objectKey && video.assetUploadStatus !== 'verified') { + throw new HttpError(409, 'Video asset upload has not been verified', 'VIDEO_ASSET_UPLOAD_NOT_VERIFIED'); + } + assertCdnAccessAllowed({ + assetId: video.assetId, + visibility: video.assetVisibility || 'svip', + assetType: video.assetType || 'video', + storageProvider: video.storageProvider as StorageProviderName, + objectKey: video.objectKey, + cdnUrl: video.cdnUrl, + metadata: video.assetMetadata, + accessRules: video.assetAccessRules, + }); return signStorageDownload({ tenantId, provider: video.storageProvider as StorageProviderName, @@ -157,7 +180,14 @@ async function signVideoPlayback(tenantId: string, video: VideoPlaybackRow) { objectKey: video.objectKey, cdnUrl: video.cdnUrl, fileName: video.fileName || video.assetTitle || video.title, - expiresInSec: 600, + expiresInSec: assetAccessTtl({ + actorRole: 'student', + accessType: 'download', + visibility: video.assetVisibility || 'svip', + assetType: video.assetType || 'video', + disposition: 'attachment', + requestedExpiresInSec: 600, + }), }); } if (isExternalPublicVideo(video)) { @@ -274,7 +304,10 @@ export async function videoPlaybackRoute(ctx: RequestContext) { v.asset_id as "assetId", a.storage_provider as "storageProvider", a.bucket, a.object_key as "objectKey", a.cdn_url as "cdnUrl", a.file_name as "fileName", - a.title as "assetTitle", a.status as "assetStatus" + a.title as "assetTitle", a.status as "assetStatus", + a.asset_type as "assetType", a.visibility as "assetVisibility", + a.upload_status as "assetUploadStatus", + a.access_rules as "assetAccessRules", a.metadata as "assetMetadata" from public.video_explanations v left join public.content_assets a on a.tenant_id = v.tenant_id and a.id = v.asset_id left join public.subjects vs on vs.tenant_id = v.tenant_id and vs.id = v.subject_id @@ -359,7 +392,9 @@ export async function videoPlaybackRoute(ctx: RequestContext) { provider: signed.provider, bucket: signed.bucket, objectKey: signed.objectKey, + assetId: video.assetId, freePreviewSeconds: video.freePreviewSeconds, + signature: signedAssetFingerprint(signed), }), ], ); diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 08da1eb1..6568a3fd 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -91,14 +91,16 @@ | --- | --- | --- | | 内容资源台账 | 可联调 | `content_assets` | | 租户后台资源维护 | 可联调 | `/api/tenant-content/assets` | -| 学生端资源列表/下载签名 | 可联调 | `/api/catalog/assets`、`/api/catalog/assets/download` | +| 学生端资源列表/下载签名 | 可联调 | `/api/catalog/assets`、`/api/catalog/assets/download`;学生端锁定资源使用短 TTL,访问事件写入 `content_asset_access_events` | | 阿里云 OSS 签名 | 可联调 | `aliyun_oss` provider | | 腾讯 COS 签名 | 可联调 | `tencent_cos` provider | | Supabase Storage 签名 | 可联调 | `supabase_storage` provider | | 上传后对象校验 | 可联调 | `/api/tenant-content/assets/confirm-upload`;托管对象必须 verified 后才能发布/下载 | -| PDF/图片预览签名 | 可联调 | `/api/catalog/assets/preview`、`/api/tenant-content/assets/sign-preview`;使用 inline 短期签名 | +| PDF/图片预览签名 | 可联调 | `/api/catalog/assets/preview`、`/api/tenant-content/assets/sign-preview`;使用 inline 短期签名,学生预览默认短 TTL | | 托管资源 worker 复检 | 可联调 | `apps/worker --job assets` 定期复检 pending/verified 对象元数据;异常资源会标记 failed 并从 active 退回 draft,写入审计和 `security_flags` | -| 深度防盗链/水印/杀毒 | 待补齐 | 商用上线前继续补 CDN 防盗链、动态水印、安全扫描和对象生命周期策略 | +| 资源访问审计 | 可联调 | `content_asset_access_events` + `GET /api/tenant-content/assets/access-events`;记录上传签名/确认、学生下载/预览、后台下载/预览的 granted/denied、TTL、签名模式、IP 和 UA | +| CDN 访问边界 | 可联调 | `members/svip/private` 外部 CDN URL 默认拒绝,必须显式 `metadata.providerManagedAccess=true` 或 `cdnAccessMode=signed_by_provider`;视频绑定资源也复用该规则 | +| 深度防盗链/水印/杀毒 | 待补齐 | 商用上线前继续补动态水印、安全扫描、CDN 刷新和对象生命周期策略 | ## 订单、会员、营销 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index eb3735c6..fd7d32d7 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -49,7 +49,9 @@ - 已接阿里云 OSS、腾讯云 COS、Supabase Storage 的上传/下载签名 provider。 - 已补上传后对象确认接口、托管对象发布前 verified 校验、PDF/图片 inline 预览签名。 - 已补 assets worker 复检,异常托管对象会自动下架并记录审计。 - - 继续补视频深度防盗链、动态水印、杀毒扫描、CDN 刷新和对象生命周期策略。 + - 已补资源访问事件 `content_asset_access_events`,覆盖上传签名/确认、学生下载/预览、后台下载/预览的 granted/denied、短 TTL、签名模式、IP 和 UA。 + - 已收紧锁定资源 CDN 边界:`members/svip/private` 外链默认拒绝,必须显式 provider-managed 才允许;视频绑定资源也复用该策略。 + - 继续补动态水印、杀毒扫描、CDN 刷新和对象生命周期策略。 - `content_assets` 继续作为资源台账,不允许前端绕过台账直接访问私有资源。 3. 真实导入 dry-run @@ -222,5 +224,5 @@ 3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。 4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。 5. 导出现有 PocketBase 数据,做完整 dry-run 迁移。 -6. 并行补对象存储、真实登录、完整资金流水对账、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。 +6. 并行补真实登录、完整资金流水对账、对象存储杀毒/水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。 7. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。 diff --git a/docs/refactor/object-storage.md b/docs/refactor/object-storage.md index 3ca20908..fa629b95 100644 --- a/docs/refactor/object-storage.md +++ b/docs/refactor/object-storage.md @@ -52,6 +52,12 @@ GET /api/catalog/assets/preview?assetId=... POST /api/videos/play ``` +后台查看资源访问事件: + +```text +GET /api/tenant-content/assets/access-events?assetId=... +``` + 后台管理员下载: ```text @@ -86,8 +92,9 @@ npm --workspace @tiku-saas/worker run exports:once 4. 调用 `POST /api/tenant-content/assets/confirm-upload`,由后端读取对象元数据并比对大小、MIME、SHA-256。 5. 校验通过且 `publish=true` 时,后端将资源置为 `status=active`、`uploadStatus=verified`。 6. 学生端只能下载或预览 `active + verified` 的托管对象资源。 -7. 生产环境定时运行 assets worker,复检 `pending/verified` 托管对象的大小、MIME、SHA-256 等元数据。 -8. 如果复检发现对象丢失、跨租户 objectKey、大小/MIME/checksum 不一致,worker 会把资源置为 `uploadStatus=failed`,并将 `active` 资源退回 `draft`,同时写入 `security_flags.assetRecheckFailed=true` 和 `audit_logs`。 +7. 学生端和后台管理员下载/预览都会写入 `content_asset_access_events`,包含 `assetId/userId/accessType/result/expiresInSec/signatureMode/ip/userAgent` 等审计字段。 +8. 生产环境定时运行 assets worker,复检 `pending/verified` 托管对象的大小、MIME、SHA-256 等元数据。 +9. 如果复检发现对象丢失、跨租户 objectKey、大小/MIME/checksum 不一致,worker 会把资源置为 `uploadStatus=failed`,并将 `active` 资源退回 `draft`,同时写入 `security_flags.assetRecheckFailed=true` 和 `audit_logs`。 ## 题库导出生成资源 @@ -131,11 +138,45 @@ npm --workspace @tiku-saas/worker run exports:once - 上传会校验 MIME 类型和文件大小。 - 托管对象资源未确认前不能发布为 `active`,学生端不可下载。 - PDF/图片预览使用 `inline` 签名,不等同于长期公开 URL。 -- 下载和视频播放必须先经过 API 权限判断,再下发短期签名 URL。 +- 下载、预览和视频播放必须先经过 API 权限判断,再下发短期签名 URL。 +- 学生端 `preview`、锁定资料下载、视频和资料包默认使用更短 TTL。当前策略是学生 inline 预览、`members/svip/private` 资料、`video/package` 资源最多 300 秒;租户后台预览最多 3600 秒,后台下载最多 86400 秒。 +- `visibility in ('members','svip','private')` 的外部 CDN/直链资源默认会被拒绝,除非资源 `metadata.providerManagedAccess=true` 或 `metadata.cdnAccessMode='signed_by_provider'`。商用建议这类资源优先登记为 `objectKey`,由 API 生成 OSS/COS/Supabase Storage 私有签名 URL。 +- 每次上传签名、上传确认、学生下载/预览、后台下载/预览都会写入 `content_asset_access_events`。授权失败也会记录 `result=denied` 和 `denyCode`,用于租户后台排查资源访问问题。 - 云厂商 AccessKey、SecretKey、Service Role Key 只存在服务端环境变量,不返回前端。 - `content_assets` 是资源唯一台账,前端不得绕过台账直接访问私有 bucket。 - 前端不能把 `uploadStatus=failed` 或 `status=draft` 的资源继续展示为可下载;列表仍返回时应展示“资料处理中”或“资源异常已下架”,真正下载/预览会被后端拒绝。 +## 访问事件字段 + +资源访问事件表: + +```text +public.content_asset_access_events +``` + +关键字段: + +| 字段 | 说明 | +| --- | --- | +| `tenant_id` | 租户隔离维度 | +| `asset_id` | 资源 ID,上传签名阶段可能为空 | +| `user_id` | 学生或后台操作者 | +| `actor_role` | `anonymous/student/tenant_content_editor/tenant_admin/system` | +| `access_type` | `download/preview/admin_download/admin_preview/upload_sign/upload_confirm` | +| `result` | `granted` 或 `denied` | +| `deny_code` | 拒绝原因,例如 `ASSET_CDN_ACCESS_NOT_ALLOWED` | +| `expires_in_sec` | 下发签名有效期 | +| `signature_mode` | `local-placeholder`、`supabase-storage-signed-url`、`aliyun-oss-signature-url-v1`、`tencent-cos-signature-url-v5`、`public-or-provider-managed` | +| `metadata` | 签名摘要、对象位置、文件名等排查信息;不保存云厂商密钥 | + +后台接口: + +```text +GET /api/tenant-content/assets/access-events?assetId=&limit=100 +``` + +前端只用于后台审计和排查,不要把 `content_asset_access_events` 当成学生端下载列表来源。 + ## 环境变量 通用: @@ -198,6 +239,6 @@ WORKER_ASSET_REQUEST_TIMEOUT_MS=10000 ## 官方依据 -- 阿里云 OSS Node.js SDK 支持通过 `signatureUrl` 为上传/下载生成带过期时间的签名 URL,并可通过对象 HEAD 读取元数据。 -- 腾讯云 COS XML API V5 签名由 `q-sign-algorithm`、`q-ak`、`q-sign-time`、`q-key-time`、`q-header-list`、`q-url-param-list`、`q-signature` 等字段组成,可用于预签名 URL 和 HEAD Object。 -- Supabase Storage 提供 `createSignedUploadUrl` 和 `createSignedUrl`,分别用于签名上传和签名下载;私有 bucket 仍应配合 RLS、服务端权限控制和资源台账。 +- Supabase Storage JavaScript `createSignedUrl` 用于为文件创建固定有效期的签名 URL,并要求对象具备 `select` 权限;本项目由服务端集中处理权限与签名。 +- 阿里云 OSS 官方文档建议服务端生成 signed URL 后让客户端直传对象,也说明私有对象可通过 presigned URL 在有效期内授权下载或预览。 +- 腾讯云 COS 文档给出的签名 URL 格式包含 `q-sign-algorithm`、`q-ak`、`q-sign-time`、`q-key-time`、`q-header-list`、`q-url-param-list`、`q-signature` 等字段;本项目服务端生成签名,不把 SecretKey 下发前端。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index e824f7a1..5c48e6b7 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -270,6 +270,37 @@ tenant::theme | 公共题库采纳/同步 | `GET /api/tenant-content/public-question-banks`、`POST /api/tenant-content/public-question-banks/adopt`、`POST /api/tenant-content/public-question-banks/sync`、`GET /api/tenant-content/public-question-banks/conflicts?adoptionId=...`、`POST /api/tenant-content/public-question-banks/conflicts/resolve`、`POST /api/tenant-content/public-question-banks/conflicts/resolve-batch` | | 题库导出 | `POST /api/tenant-content/exports/questions`、`GET /api/tenant-content/exports/jobs` | +## 资料、PDF 和视频资源契约 + +前端必须把 `content_assets` 当成资源唯一台账。学生端资料、PDF 预览和题目视频播放都不能直接拼接私有 OSS/COS/Supabase Storage URL,也不能把后台配置的 `cdnUrl` 持久缓存成长期可访问地址。 + +学生端资料流程: + +1. 列表页调用 `GET /api/catalog/assets`,只展示后端返回的 active 资源。 +2. 预览 PDF/图片时调用 `GET /api/catalog/assets/preview?assetId=...`。 +3. 下载资料时调用 `GET /api/catalog/assets/download?assetId=...`。 +4. 使用响应里的 `preview.url` 或 `download.url` 立即打开;不要写入本地长期缓存。 + +签名有效期规则: + +- 学生 inline 预览、SVIP/会员资料、视频和资料包通常只有 300 秒左右有效期。 +- 后台预览有效期也不是永久 URL,租户后台应在用户点击时重新请求签名。 +- 响应里的 `expiresInSec/expiresAt/signatureMode` 只用于 UI 提示和排查,不要自行延长有效期。 + +锁定资源 CDN 规则: + +- `visibility=members/svip/private` 的外部 `cdnUrl` 默认会被后端拒绝,返回 `ASSET_CDN_ACCESS_NOT_ALLOWED`。 +- 只有后台明确登记 `metadata.providerManagedAccess=true` 或 `metadata.cdnAccessMode='signed_by_provider'`,后端才允许把外部 URL 作为 provider-managed 资源返回。 +- 商用环境更推荐把锁定资料登记为 `objectKey`,由后端生成 OSS/COS/Supabase Storage 私有签名 URL。 + +租户后台排查: + +```text +GET /api/tenant-content/assets/access-events?assetId=&limit=100 +``` + +该接口返回资源访问事件,包括学生下载、学生预览、后台下载、后台预览、上传签名、上传确认以及 denied 原因。租户后台可以在资源详情页增加“访问记录/异常记录”面板。 + ## 练习访问控制契约 前端不要先拉完整题目列表再自行判断免费额度。用户点击顺序刷题、随机刷题、全真模拟时,统一调用 `POST /api/learning/practice-sessions`,后端会根据 `content_entries.accessRules`、`content_nodes.accessRules`、`question_collections.accessRules`、`practice_blueprints.accessRules` 和当前用户权益决定最终题目快照。 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index dc029491..1131e151 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -3148,11 +3148,29 @@ async function testTenantContentAssetsAndImports() { regionId: ids.region, subjectId: ids.subject, categoryId: ids.category, - metadata: { source: 'api-integration' }, + metadata: { source: 'api-integration', providerManagedAccess: true }, }, }); assert.equal(asset.item?.visibility, 'svip', 'tenant admin should create svip asset'); + const lockedCdnAsset = await request('/api/tenant-content/assets', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + title: '集成测试未授权 CDN SVIP 资料', + assetType: 'pdf', + storageProvider: 'external_url', + cdnUrl: 'https://example.test/resources/locked-without-provider-managed.pdf', + fileName: 'locked-without-provider-managed.pdf', + mimeType: 'application/pdf', + visibility: 'svip', + regionId: ids.region, + subjectId: ids.subject, + categoryId: ids.category, + metadata: { source: 'api-integration' }, + }, + }); + const adminAssets = await request('/api/tenant-content/assets', { userId: TENANT_ADMIN_USER_ID, query: { assetType: 'pdf', regionId: ids.region }, @@ -3178,6 +3196,31 @@ async function testTenantContentAssetsAndImports() { assert.equal(download.access?.svip, true, 'asset download should report svip access'); assert.equal(download.download?.url, 'https://example.test/resources/integration.pdf', 'external asset download should use cdn url'); assert.equal(download.download?.signatureMode, 'public-or-provider-managed', 'external asset should be marked provider managed'); + assert.equal(download.download?.expiresInSec, 300, 'locked student asset downloads should use short TTL'); + + const deniedLockedCdn = await request('/api/catalog/assets/download', { + query: { assetId: lockedCdnAsset.item.id }, + expectStatus: 409, + }); + assert.equal(deniedLockedCdn.code, 'ASSET_CDN_ACCESS_NOT_ALLOWED', 'locked cdn asset should require explicit provider-managed access'); + + const accessEventsAfterLockedDeny = await request('/api/tenant-content/assets/access-events', { + userId: TENANT_ADMIN_USER_ID, + query: { assetId: lockedCdnAsset.item.id }, + }); + assert.ok( + accessEventsAfterLockedDeny.items?.some(item => item.result === 'denied' && item.denyCode === 'ASSET_CDN_ACCESS_NOT_ALLOWED'), + 'locked cdn denial should be recorded as an asset access event', + ); + + const accessEventsAfterDownload = await request('/api/tenant-content/assets/access-events', { + userId: TENANT_ADMIN_USER_ID, + query: { assetId: asset.item.id }, + }); + assert.ok( + accessEventsAfterDownload.items?.some(item => item.accessType === 'download' && item.result === 'granted'), + 'successful student asset download should be recorded as an access event', + ); const localAsset = await request('/api/tenant-content/assets', { userId: TENANT_ADMIN_USER_ID, @@ -3233,6 +3276,7 @@ async function testTenantContentAssetsAndImports() { assert.equal(localPreview.preview?.method, 'GET', 'asset preview should sign GET'); assert.equal(localPreview.preview?.signatureMode, 'local-placeholder', 'local asset preview should use local placeholder signer'); assert.ok(localPreview.preview?.url?.includes('disposition=inline'), 'asset preview should request inline disposition'); + assert.equal(localPreview.preview?.expiresInSec, 300, 'student inline previews should use short TTL'); const adminPreview = await request('/api/tenant-content/assets/sign-preview', { userId: TENANT_ADMIN_USER_ID, @@ -3241,6 +3285,15 @@ async function testTenantContentAssetsAndImports() { }); assert.ok(adminPreview.preview?.url?.includes('disposition=inline'), 'admin preview should request inline disposition'); + const localAccessEvents = await request('/api/tenant-content/assets/access-events', { + userId: TENANT_ADMIN_USER_ID, + query: { assetId: localAsset.item.id }, + }); + assert.ok( + localAccessEvents.items?.some(item => item.accessType === 'preview' && item.expiresInSec === 300), + 'student preview access event should include short TTL', + ); + const failedAsset = await request('/api/tenant-content/assets', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', diff --git a/scripts/smoke-seed.js b/scripts/smoke-seed.js index 67b6e375..31c802cc 100644 --- a/scripts/smoke-seed.js +++ b/scripts/smoke-seed.js @@ -1354,22 +1354,30 @@ async function main() { ` insert into public.content_assets ( id, tenant_id, asset_key, title, asset_type, storage_provider, - bucket, object_key, file_name, mime_type, visibility, status, source + bucket, object_key, file_name, mime_type, file_size_bytes, + checksum_sha256, visibility, status, upload_status, verified_at, + verified_size_bytes, verified_checksum_sha256, source ) values ( $1, $2, 'smoke-video-asset', '烟测视频对象', 'video', 'local_dev', - 'tenant-assets', $3, 'smoke.mp4', 'video/mp4', - 'svip', 'active', 'smoke-seed' + 'tenant-assets', $3, 'smoke.mp4', 'video/mp4', 8192, + $4, 'svip', 'active', 'verified', now(), 8192, $4, 'smoke-seed' ) on conflict (id) do update set storage_provider = excluded.storage_provider, bucket = excluded.bucket, object_key = excluded.object_key, + file_size_bytes = excluded.file_size_bytes, + checksum_sha256 = excluded.checksum_sha256, visibility = excluded.visibility, status = 'active', + upload_status = 'verified', + verified_at = coalesce(public.content_assets.verified_at, now()), + verified_size_bytes = excluded.verified_size_bytes, + verified_checksum_sha256 = excluded.verified_checksum_sha256, updated_at = now() `, - [ids.videoAsset, tenantId, `${tenantId}/videos/smoke.mp4`], + [ids.videoAsset, tenantId, `${tenantId}/videos/smoke.mp4`, 'c'.repeat(64)], ); await client.query( diff --git a/supabase/migrations/202606290021_content_asset_access_security.sql b/supabase/migrations/202606290021_content_asset_access_security.sql new file mode 100644 index 00000000..f3704749 --- /dev/null +++ b/supabase/migrations/202606290021_content_asset_access_security.sql @@ -0,0 +1,43 @@ +create table if not exists public.content_asset_access_events ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + asset_id uuid references public.content_assets(id) on delete set null, + user_id uuid references public.platform_users(id) on delete set null, + actor_role text not null default 'anonymous' + check (actor_role in ('anonymous', 'student', 'tenant_admin', 'tenant_content_editor', 'system')), + access_type text not null + check (access_type in ('download', 'preview', 'admin_download', 'admin_preview', 'upload_sign', 'upload_confirm')), + visibility text, + asset_type text, + storage_provider text, + disposition text check (disposition is null or disposition in ('attachment', 'inline')), + expires_in_sec integer check (expires_in_sec is null or expires_in_sec > 0), + signature_mode text, + result text not null check (result in ('granted', 'denied')), + deny_code text, + ip_address text, + user_agent text, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); + +comment on table public.content_asset_access_events is + '短期资源签名与资源访问授权事件。用于租户隔离审计、资料下载追踪、异常 CDN/私有资源排查。'; + +create index if not exists idx_asset_access_events_asset + on public.content_asset_access_events(tenant_id, asset_id, created_at desc); + +create index if not exists idx_asset_access_events_user + on public.content_asset_access_events(tenant_id, user_id, created_at desc) + where user_id is not null; + +create index if not exists idx_asset_access_events_type + on public.content_asset_access_events(tenant_id, access_type, result, created_at desc); + +alter table public.content_asset_access_events enable row level security; + +drop policy if exists tenant_isolation on public.content_asset_access_events; +create policy tenant_isolation on public.content_asset_access_events + for all + using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) + with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());