forked from wangziqi/gongxue-base
feat: add content asset security scanning
This commit is contained in:
@@ -31,6 +31,7 @@ const VISIBILITIES = ['public', 'tenant', 'members', 'svip', 'private'];
|
||||
const ASSET_STATUSES = ['draft', 'active', 'archived'];
|
||||
const UPLOAD_STATUSES = ['not_required', 'pending', 'verified', 'failed'];
|
||||
const PREVIEW_STATUSES = ['none', 'pending', 'ready', 'failed'];
|
||||
const SECURITY_SCAN_STATUSES = ['not_required', 'pending', 'scanning', 'passed', 'failed', 'skipped'];
|
||||
|
||||
interface AssetRow {
|
||||
id: string;
|
||||
@@ -50,6 +51,9 @@ interface AssetRow {
|
||||
visibility: string;
|
||||
status: string;
|
||||
uploadStatus: string;
|
||||
securityScanStatus: string;
|
||||
securityScanProvider: string | null;
|
||||
securityScanSummary: Record<string, unknown>;
|
||||
verifiedSizeBytes: number | null;
|
||||
verifiedChecksumSha256: string | null;
|
||||
previewStatus: string;
|
||||
@@ -122,6 +126,9 @@ async function existingAssetForUpsert(tenantId: string, id: string | null) {
|
||||
cdn_url as "cdnUrl", preview_url as "previewUrl", mime_type as "mimeType",
|
||||
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
|
||||
visibility, status, upload_status as "uploadStatus",
|
||||
security_scan_status as "securityScanStatus",
|
||||
security_scan_provider as "securityScanProvider",
|
||||
security_scan_summary as "securityScanSummary",
|
||||
verified_size_bytes as "verifiedSizeBytes",
|
||||
verified_checksum_sha256 as "verifiedChecksumSha256",
|
||||
preview_status as "previewStatus"
|
||||
@@ -156,14 +163,46 @@ function objectUploadStatus(input: {
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
function assertPublishableManagedObject(status: string, uploadStatus: string, provider: StorageProviderName, objectKey: string | null) {
|
||||
if (status === 'active' && objectKey && isManagedObjectProvider(provider) && uploadStatus !== 'verified') {
|
||||
function objectSecurityScanStatus(input: {
|
||||
existing: AssetRow | null;
|
||||
provider: StorageProviderName;
|
||||
objectKey: string | null;
|
||||
uploadStatus: string;
|
||||
}) {
|
||||
if (!input.objectKey || !isManagedObjectProvider(input.provider)) return 'not_required';
|
||||
if (input.uploadStatus !== 'verified') return 'pending';
|
||||
return input.existing?.securityScanStatus || 'pending';
|
||||
}
|
||||
|
||||
export function assertAssetSecurityScanPassed(input: {
|
||||
provider: StorageProviderName;
|
||||
objectKey: string | null;
|
||||
securityScanStatus?: string | null;
|
||||
}) {
|
||||
if (!input.objectKey || !isManagedObjectProvider(input.provider)) return;
|
||||
if (input.securityScanStatus === 'passed') return;
|
||||
if (input.securityScanStatus === 'failed') {
|
||||
throw new HttpError(409, 'Asset security scan failed', 'ASSET_SECURITY_SCAN_FAILED');
|
||||
}
|
||||
throw new HttpError(409, 'Asset security scan is required before access', 'ASSET_SECURITY_SCAN_REQUIRED');
|
||||
}
|
||||
|
||||
function assertPublishableManagedObject(
|
||||
status: string,
|
||||
uploadStatus: string,
|
||||
scanStatus: string,
|
||||
provider: StorageProviderName,
|
||||
objectKey: string | null,
|
||||
) {
|
||||
if (status !== 'active' || !objectKey || !isManagedObjectProvider(provider)) return;
|
||||
if (uploadStatus !== 'verified') {
|
||||
throw new HttpError(
|
||||
409,
|
||||
'Managed storage asset must be confirmed before publishing',
|
||||
'ASSET_UPLOAD_CONFIRMATION_REQUIRED',
|
||||
);
|
||||
}
|
||||
assertAssetSecurityScanPassed({ provider, objectKey, securityScanStatus: scanStatus });
|
||||
}
|
||||
|
||||
async function assertOptionalReference(tenantId: string, table: string, id: string | null, code: string) {
|
||||
@@ -219,6 +258,7 @@ export async function assetsAdminRoute(ctx: RequestContext) {
|
||||
const categoryId = stringParam(ctx, 'categoryId');
|
||||
const entryId = stringParam(ctx, 'entryId');
|
||||
const contentNodeId = stringParam(ctx, 'contentNodeId');
|
||||
const securityScanStatus = stringParam(ctx, 'securityScanStatus');
|
||||
|
||||
const params: unknown[] = [auth.tenantId];
|
||||
const filters = ['tenant_id = $1'];
|
||||
@@ -254,6 +294,13 @@ export async function assetsAdminRoute(ctx: RequestContext) {
|
||||
params.push(contentNodeId);
|
||||
filters.push(`content_node_id = $${params.length}`);
|
||||
}
|
||||
if (securityScanStatus) {
|
||||
if (!SECURITY_SCAN_STATUSES.includes(securityScanStatus)) {
|
||||
throw new HttpError(400, `Invalid securityScanStatus: ${securityScanStatus}`, 'INVALID_FIELD_VALUE');
|
||||
}
|
||||
params.push(securityScanStatus);
|
||||
filters.push(`security_scan_status = $${params.length}`);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query(
|
||||
@@ -269,6 +316,10 @@ export async function assetsAdminRoute(ctx: RequestContext) {
|
||||
verified_checksum_sha256 as "verifiedChecksumSha256",
|
||||
verification_details as "verificationDetails",
|
||||
preview_object_key as "previewObjectKey", preview_status as "previewStatus",
|
||||
security_scan_status as "securityScanStatus",
|
||||
security_scanned_at as "securityScannedAt",
|
||||
security_scan_provider as "securityScanProvider",
|
||||
security_scan_summary as "securityScanSummary",
|
||||
security_flags as "securityFlags",
|
||||
visibility, is_public as "isPublic", region_id as "regionId",
|
||||
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
|
||||
@@ -288,6 +339,44 @@ export async function assetsAdminRoute(ctx: RequestContext) {
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function assetSecurityScanEventsAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
const assetId = stringParam(ctx, 'assetId');
|
||||
const scanStatus = stringParam(ctx, 'scanStatus');
|
||||
|
||||
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`);
|
||||
}
|
||||
if (scanStatus) {
|
||||
if (!SECURITY_SCAN_STATUSES.includes(scanStatus) || scanStatus === 'not_required') {
|
||||
throw new HttpError(400, `Invalid scanStatus: ${scanStatus}`, 'INVALID_FIELD_VALUE');
|
||||
}
|
||||
params.push(scanStatus);
|
||||
filters.push(`scan_status = $${params.length}`);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, asset_id as "assetId", provider,
|
||||
scan_status as "scanStatus", risk_level as "riskLevel",
|
||||
issue_codes as "issueCodes", details, created_at as "createdAt"
|
||||
from public.content_asset_security_scan_events
|
||||
where ${filters.join(' and ')}
|
||||
order by created_at desc
|
||||
limit $${params.length}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function assetAccessEventsAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
@@ -362,14 +451,22 @@ export async function upsertAssetRoute(ctx: RequestContext) {
|
||||
objectKey: cleanObjectKey,
|
||||
cdnUrl,
|
||||
});
|
||||
const nextSecurityScanStatus = objectSecurityScanStatus({
|
||||
existing,
|
||||
provider: storageProvider,
|
||||
objectKey: cleanObjectKey,
|
||||
uploadStatus: nextUploadStatus,
|
||||
});
|
||||
const nextPreviewStatus = previewObjectKey || nullableString(body.previewUrl)
|
||||
? choice(body.previewStatus, PREVIEW_STATUSES, existing?.previewStatus || 'ready', 'previewStatus')
|
||||
: 'none';
|
||||
const statusFallback = cleanObjectKey && isManagedObjectProvider(storageProvider) && nextUploadStatus !== 'verified'
|
||||
const statusFallback = cleanObjectKey
|
||||
&& isManagedObjectProvider(storageProvider)
|
||||
&& (nextUploadStatus !== 'verified' || nextSecurityScanStatus !== 'passed')
|
||||
? 'draft'
|
||||
: 'active';
|
||||
const status = choice(body.status, ASSET_STATUSES, statusFallback, 'status');
|
||||
assertPublishableManagedObject(status, nextUploadStatus, storageProvider, cleanObjectKey);
|
||||
assertPublishableManagedObject(status, nextUploadStatus, nextSecurityScanStatus, storageProvider, cleanObjectKey);
|
||||
|
||||
if (status === 'active' && !cdnUrl && !objectKey) {
|
||||
throw new HttpError(400, 'Active asset requires cdnUrl or objectKey', 'ASSET_LOCATION_REQUIRED');
|
||||
@@ -388,7 +485,9 @@ export async function upsertAssetRoute(ctx: RequestContext) {
|
||||
id, tenant_id, legacy_id, asset_key, asset_type, storage_provider,
|
||||
bucket, object_key, title, category, description, file_name, cdn_url,
|
||||
preview_url, mime_type, file_size_bytes, checksum_sha256,
|
||||
upload_status, preview_object_key, preview_status, visibility,
|
||||
upload_status, security_scan_status, security_scanned_at,
|
||||
security_scan_provider, security_scan_summary,
|
||||
preview_object_key, preview_status, visibility,
|
||||
is_public, region_id, subject_id, category_id, node_id, entry_id,
|
||||
content_node_id, status,
|
||||
sort_order, access_rules, metadata, created_by, updated_by, source
|
||||
@@ -397,10 +496,12 @@ export async function upsertAssetRoute(ctx: RequestContext) {
|
||||
coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6,
|
||||
$7, $8, $9, $10, $11, $12, $13,
|
||||
$14, $15, $16, $17, $18,
|
||||
$19, $20, $21,
|
||||
$22, $23::uuid, $24::uuid, $25::uuid, $26::uuid, $27::uuid,
|
||||
$28::uuid, $29,
|
||||
$30, $31::jsonb, $32::jsonb, $33, $33, $34
|
||||
$19, case when $19 in ('passed', 'not_required') then now() else null end,
|
||||
$20, $21::jsonb,
|
||||
$22, $23, $24,
|
||||
$25, $26::uuid, $27::uuid, $28::uuid, $29::uuid, $30::uuid,
|
||||
$31::uuid, $32,
|
||||
$33, $34::jsonb, $35::jsonb, $36, $36, $37
|
||||
)
|
||||
on conflict (id)
|
||||
do update set legacy_id = excluded.legacy_id,
|
||||
@@ -419,6 +520,31 @@ export async function upsertAssetRoute(ctx: RequestContext) {
|
||||
file_size_bytes = excluded.file_size_bytes,
|
||||
checksum_sha256 = excluded.checksum_sha256,
|
||||
upload_status = excluded.upload_status,
|
||||
security_scan_status = excluded.security_scan_status,
|
||||
security_scanned_at = case
|
||||
when public.content_assets.storage_provider = excluded.storage_provider
|
||||
and coalesce(public.content_assets.bucket, '') = coalesce(excluded.bucket, '')
|
||||
and coalesce(public.content_assets.object_key, '') = coalesce(excluded.object_key, '')
|
||||
and excluded.security_scan_status = public.content_assets.security_scan_status
|
||||
then public.content_assets.security_scanned_at
|
||||
else excluded.security_scanned_at
|
||||
end,
|
||||
security_scan_provider = case
|
||||
when public.content_assets.storage_provider = excluded.storage_provider
|
||||
and coalesce(public.content_assets.bucket, '') = coalesce(excluded.bucket, '')
|
||||
and coalesce(public.content_assets.object_key, '') = coalesce(excluded.object_key, '')
|
||||
and excluded.security_scan_status = public.content_assets.security_scan_status
|
||||
then public.content_assets.security_scan_provider
|
||||
else excluded.security_scan_provider
|
||||
end,
|
||||
security_scan_summary = case
|
||||
when public.content_assets.storage_provider = excluded.storage_provider
|
||||
and coalesce(public.content_assets.bucket, '') = coalesce(excluded.bucket, '')
|
||||
and coalesce(public.content_assets.object_key, '') = coalesce(excluded.object_key, '')
|
||||
and excluded.security_scan_status = public.content_assets.security_scan_status
|
||||
then public.content_assets.security_scan_summary
|
||||
else excluded.security_scan_summary
|
||||
end,
|
||||
verified_at = case
|
||||
when public.content_assets.storage_provider = excluded.storage_provider
|
||||
and coalesce(public.content_assets.bucket, '') = coalesce(excluded.bucket, '')
|
||||
@@ -476,6 +602,10 @@ export async function upsertAssetRoute(ctx: RequestContext) {
|
||||
verified_checksum_sha256 as "verifiedChecksumSha256",
|
||||
verification_details as "verificationDetails",
|
||||
preview_object_key as "previewObjectKey", preview_status as "previewStatus",
|
||||
security_scan_status as "securityScanStatus",
|
||||
security_scanned_at as "securityScannedAt",
|
||||
security_scan_provider as "securityScanProvider",
|
||||
security_scan_summary as "securityScanSummary",
|
||||
security_flags as "securityFlags",
|
||||
visibility, is_public as "isPublic", region_id as "regionId",
|
||||
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
|
||||
@@ -504,6 +634,15 @@ export async function upsertAssetRoute(ctx: RequestContext) {
|
||||
fileSizeBytes,
|
||||
checksum,
|
||||
nextUploadStatus,
|
||||
nextSecurityScanStatus,
|
||||
nextSecurityScanStatus === 'not_required' ? null : existing?.securityScanProvider || 'metadata_rules',
|
||||
JSON.stringify(
|
||||
nextSecurityScanStatus === 'not_required'
|
||||
? {}
|
||||
: nextSecurityScanStatus === 'passed'
|
||||
? existing?.securityScanSummary || { riskLevel: 'none', issueCodes: [] }
|
||||
: { riskLevel: 'unknown', issueCodes: [], pendingReason: 'asset_upsert' },
|
||||
),
|
||||
previewObjectKey,
|
||||
nextPreviewStatus,
|
||||
visibility,
|
||||
@@ -550,6 +689,9 @@ export async function confirmAssetUploadRoute(ctx: RequestContext) {
|
||||
cdn_url as "cdnUrl", preview_url as "previewUrl", mime_type as "mimeType",
|
||||
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
|
||||
visibility, status, upload_status as "uploadStatus",
|
||||
security_scan_status as "securityScanStatus",
|
||||
security_scan_provider as "securityScanProvider",
|
||||
security_scan_summary as "securityScanSummary",
|
||||
verified_size_bytes as "verifiedSizeBytes",
|
||||
verified_checksum_sha256 as "verifiedChecksumSha256",
|
||||
preview_status as "previewStatus"
|
||||
@@ -623,13 +765,23 @@ export async function confirmAssetUploadRoute(ctx: RequestContext) {
|
||||
`
|
||||
update public.content_assets
|
||||
set upload_status = 'failed',
|
||||
security_scan_status = 'skipped',
|
||||
security_scanned_at = null,
|
||||
security_scan_provider = 'metadata_rules',
|
||||
security_scan_summary = $5::jsonb,
|
||||
verification_details = $3::jsonb,
|
||||
security_flags = jsonb_set(coalesce(security_flags, '{}'::jsonb), '{uploadVerificationFailed}', 'true'::jsonb, true),
|
||||
updated_by = $4,
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[auth.tenantId, assetId, JSON.stringify(verificationDetails), auth.userId],
|
||||
[
|
||||
auth.tenantId,
|
||||
assetId,
|
||||
JSON.stringify(verificationDetails),
|
||||
auth.userId,
|
||||
JSON.stringify({ riskLevel: 'medium', issueCodes: issues, skippedReason: 'upload_verification_failed' }),
|
||||
],
|
||||
);
|
||||
await recordAssetAccessEvent({
|
||||
ctx,
|
||||
@@ -666,7 +818,12 @@ export async function confirmAssetUploadRoute(ctx: RequestContext) {
|
||||
mime_type = coalesce(mime_type, $6),
|
||||
checksum_sha256 = coalesce(checksum_sha256, $5),
|
||||
verification_details = $7::jsonb,
|
||||
status = case when $8::boolean and status <> 'archived' then 'active' else status end,
|
||||
security_scan_status = 'pending',
|
||||
security_scanned_at = null,
|
||||
security_scan_provider = 'metadata_rules',
|
||||
security_scan_summary = '{"riskLevel":"unknown","issueCodes":[],"pendingReason":"upload_confirmed"}'::jsonb,
|
||||
security_flags = coalesce(security_flags, '{}'::jsonb) - 'assetSecurityScanFailed',
|
||||
status = case when status = 'archived' then status else 'draft' end,
|
||||
updated_by = $3,
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
@@ -677,7 +834,11 @@ export async function confirmAssetUploadRoute(ctx: RequestContext) {
|
||||
upload_status as "uploadStatus", verified_at as "verifiedAt",
|
||||
verified_by as "verifiedBy", verified_size_bytes as "verifiedSizeBytes",
|
||||
verified_checksum_sha256 as "verifiedChecksumSha256",
|
||||
verification_details as "verificationDetails"
|
||||
verification_details as "verificationDetails",
|
||||
security_scan_status as "securityScanStatus",
|
||||
security_scanned_at as "securityScannedAt",
|
||||
security_scan_provider as "securityScanProvider",
|
||||
security_scan_summary as "securityScanSummary"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
@@ -687,7 +848,6 @@ export async function confirmAssetUploadRoute(ctx: RequestContext) {
|
||||
metadata.checksumSha256 ?? declaredChecksum,
|
||||
observedMime || expectedMime,
|
||||
JSON.stringify(verificationDetails),
|
||||
publish,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -720,7 +880,16 @@ export async function confirmAssetUploadRoute(ctx: RequestContext) {
|
||||
},
|
||||
});
|
||||
|
||||
return { item, metadata, verification: verificationDetails };
|
||||
return {
|
||||
item,
|
||||
metadata,
|
||||
verification: verificationDetails,
|
||||
securityScan: {
|
||||
status: 'pending',
|
||||
provider: 'metadata_rules',
|
||||
requiredBeforePublish: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function signAssetUploadRoute(ctx: RequestContext) {
|
||||
@@ -811,6 +980,9 @@ export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
|
||||
preview_url as "previewUrl", mime_type as "mimeType",
|
||||
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
|
||||
visibility, status, upload_status as "uploadStatus",
|
||||
security_scan_status as "securityScanStatus",
|
||||
security_scan_provider as "securityScanProvider",
|
||||
security_scan_summary as "securityScanSummary",
|
||||
verified_size_bytes as "verifiedSizeBytes",
|
||||
verified_checksum_sha256 as "verifiedChecksumSha256",
|
||||
preview_status as "previewStatus",
|
||||
@@ -844,6 +1016,11 @@ export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
|
||||
metadata: asset.metadata,
|
||||
accessRules: asset.accessRules,
|
||||
});
|
||||
assertAssetSecurityScanPassed({
|
||||
provider: asset.storageProvider as StorageProviderName,
|
||||
objectKey: asset.objectKey,
|
||||
securityScanStatus: asset.securityScanStatus,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordDeniedAdminAssetAccess(ctx, auth, asset, 'admin_download', error);
|
||||
throw error;
|
||||
@@ -898,6 +1075,9 @@ export async function signAssetPreviewAdminRoute(ctx: RequestContext) {
|
||||
cdn_url as "cdnUrl", preview_url as "previewUrl", mime_type as "mimeType",
|
||||
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
|
||||
visibility, status, upload_status as "uploadStatus",
|
||||
security_scan_status as "securityScanStatus",
|
||||
security_scan_provider as "securityScanProvider",
|
||||
security_scan_summary as "securityScanSummary",
|
||||
verified_size_bytes as "verifiedSizeBytes",
|
||||
verified_checksum_sha256 as "verifiedChecksumSha256",
|
||||
preview_status as "previewStatus",
|
||||
@@ -936,6 +1116,11 @@ export async function signAssetPreviewAdminRoute(ctx: RequestContext) {
|
||||
metadata: asset.metadata,
|
||||
accessRules: asset.accessRules,
|
||||
});
|
||||
assertAssetSecurityScanPassed({
|
||||
provider: asset.storageProvider as StorageProviderName,
|
||||
objectKey,
|
||||
securityScanStatus: asset.securityScanStatus,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordDeniedAdminAssetAccess(ctx, auth, asset, 'admin_preview', error);
|
||||
throw error;
|
||||
|
||||
Reference in New Issue
Block a user