Skip to content
31 changes: 17 additions & 14 deletions docs/exports.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,20 +93,22 @@ POST /api/exports/me?scope=all&format=csv&columns={"vaults":["id","status"],"tra

When `EXPORT_S3_BUCKET` and `EXPORT_S3_REGION` are both configured, completed exports are uploaded to S3 using streaming multipart upload via `@aws-sdk/lib-storage`. The export job record stores the S3 key instead of the file bytes.

On poll, `GET /api/exports/status/:jobId` returns a short-lived pre-signed S3 URL instead of the local download token.
On completion, `GET /api/exports/status/:jobId` returns a proxied download link (`/api/exports/:id/download`) rather than exposing long-lived raw S3 signed URLs directly.

**Environment variables**:

| Variable | Required | Default | Description |
| -------------------------- | -------- | ------- | ---------------------------------------------- |
| `EXPORT_S3_BUCKET` | No | – | S3 bucket name for export storage |
| `EXPORT_S3_REGION` | No | – | AWS region for the S3 bucket |
| `EXPORT_SIGNED_URL_TTL_S` | No | `3600` | Signed URL expiration in seconds (1 hour) |
| Variable | Required | Default | Description |
| ------------------------------- | -------- | ------- | ---------------------------------------------- |
| `EXPORT_S3_BUCKET` | No | – | S3 bucket name for export storage |
| `EXPORT_S3_REGION` | No | – | AWS region for the S3 bucket |
| `EXPORT_SIGNED_URL_TTL_S` | No | `3600` | Default fallback signed URL expiration in seconds |
| `EXPORT_SIGNED_URL_SHORT_TTL_S` | No | `60` | Short-lived S3 signed URL TTL for proxied downloads |

**Behavior**:
- When S3 is configured, `result_data` remains `NULL` in the database and the `s3_key` column contains the S3 object key.
- When S3 is not configured, `result_data` stores the generated file bytes and `s3_key` remains `NULL`.
- The status endpoint returns either a signed S3 URL (when S3 is enabled) or a local `/api/exports/download/:token` URL (when S3 is disabled).
- The status endpoint returns the authenticated download endpoint `/api/exports/:id/download`.
- When accessing `GET /api/exports/:id/download`, callers are re-authenticated and verified for organization ownership. When S3 is configured, a short-lived signed URL (default 60s TTL) is issued.

## Durability and retries

Expand All @@ -115,14 +117,15 @@ On poll, `GET /api/exports/status/:jobId` returns a short-lived pre-signed S3 UR
- On worker startup, any export jobs left in `pending` or `running` state are re-enqueued.
- Request-level idempotency is supported through the `Idempotency-Key` header. Reusing the same key with a different request shape returns `409`.

## Security
## Security & Proxied Download Re-Authorization

- Non-admin users only export their own data.
- Admin exports can target a specific user or all users.
- Status polling is restricted to the requesting user unless the caller is an admin.
- Download links are signed and time-limited.
- CSV cells that start with spreadsheet formula prefixes such as `=`, `+`, `-`, `@`, tab, or carriage return are prefixed with `'` to mitigate formula injection.
- Column selection is validated against an allowlist to prevent exposure of unintended data.
- **Authenticated Proxied Downloads (`GET /api/exports/:id/download`)**: Requires valid authentication bearer token.
- **Per-Object Organization Ownership & Authorization**: The caller's organization ID is verified against the export job's owning organization. Cross-tenant or unauthorized access attempts are rejected with `403 Forbidden`.
- **Short-Lived S3 Presigned Links**: S3 presigned URLs are generated with short expiration windows (e.g. 60 seconds) during proxied downloads and are never embedded in list/status responses without authorization.
- **Audit Logging**: Every download attempt is recorded in tamper-evident audit logs (`export.download`) with the requesting principal, export job ID, and organization context.
- Non-admin users only export and access their own organization's data. Admin exports can target specific users or global data.
- CSV cells starting with formula prefixes (`=`, `+`, `-`, `@`, tab, carriage return) are prefixed with `'` to prevent formula injection.
- Column selection is validated against an allowlist.

## Per-tenant export quotas

Expand Down
102 changes: 81 additions & 21 deletions src/routes/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@
import { checkAndIncrementExportQuota } from '../services/exportQuota.js'
import { getEnv } from '../config/index.js'
import { resolveS3Config, getExportSignedUrl } from '../services/exportS3.js'
import { createAuditLog } from '../lib/audit-logs.js'
import { isOrgMember } from '../models/organizations.js'

const resolveOrgId = (req: AuthenticatedRequest): string =>
(req as any).orgId as string | undefined ?? req.user!.userId
(req as any).orgId as string | undefined ?? (req.query.orgId as string | undefined) ?? (req.headers['x-organization-id'] as string | undefined) ?? (req.user as any)?.orgId ?? req.user!.userId

const enforceExportQuota = async (
req: AuthenticatedRequest,
Expand Down Expand Up @@ -125,6 +127,7 @@
try {
const job = await enqueueExportJob(jobSystem, {
userId: req.user!.userId,
orgId: resolveOrgId(req),
isAdmin: false,
scope: options.scope,
format: options.format,
Expand Down Expand Up @@ -159,6 +162,7 @@
try {
const job = await enqueueExportJob(jobSystem, {
userId: req.user!.userId,
orgId: resolveOrgId(req),
isAdmin: true,
targetUserId,
scope: options.scope,
Expand Down Expand Up @@ -202,32 +206,14 @@
return
}

const s3Config = resolveS3Config()

if (s3Config && job.s3Key) {
const signedUrl = await getExportSignedUrl(s3Config, job.s3Key)
res.json({
jobId: job.id,
status: 'done',
attempts: job.attempts,
maxAttempts: job.maxAttempts,
completedAt: job.completedAt,
downloadUrl: signedUrl,
expiresInSeconds: s3Config.signedUrlTtlSeconds,
})
return
}

const downloadToken = signDownloadToken(job.id, job.userId, 3600)

res.json({
jobId: job.id,
status: 'done',
attempts: job.attempts,
maxAttempts: job.maxAttempts,
completedAt: job.completedAt,
downloadUrl: `/api/exports/download/${downloadToken}`,
expiresInSeconds: 3600,
downloadUrl: `/api/exports/${job.id}/download`,
expiresInSeconds: 60,
})
})

Expand Down Expand Up @@ -267,5 +253,79 @@
res.send(job.result)
})

router.get('/:id/download', authenticate, async (req: AuthenticatedRequest, res: Response) => {

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.
This route handler performs
authorization
, but is not rate-limited.
const job = await getJob(req.params.id)
if (!job || job.status !== 'done') {
res.status(404).json({ error: 'Export not ready or not found' })
return
}

const callerOrgId = resolveOrgId(req)
const jobOrgId = job.orgId ?? job.userId
const isOwner = (jobOrgId === callerOrgId) || (job.userId === req.user!.userId) || isOrgMember(jobOrgId, req.user!.userId)

if (!isOwner && req.user!.role !== 'ADMIN') {
res.status(403).json({ error: 'Forbidden: Cross-organization export download rejected' })
return
}

try {
await createAuditLog({
actor_user_id: req.user!.userId,
organization_id: callerOrgId !== req.user!.userId ? callerOrgId : undefined,
action: 'export.download',
target_type: 'export_job',
target_id: job.id,
metadata: {
jobId: job.id,
format: job.format,
scope: job.scope,
storage: job.s3Key ? 's3' : 'local',
},
})
} catch (err) {
console.warn('Failed to record audit log for export download:', err)
}

console.info(
JSON.stringify({
level: 'info',
event: 'exports.download_served',
jobId: job.id,
principal: req.user!.userId,
org: callerOrgId,
timestamp: new Date().toISOString(),
}),
)

const s3Config = resolveS3Config()
if (s3Config && job.s3Key) {
const shortTtlSeconds = Number.parseInt(process.env.EXPORT_SIGNED_URL_SHORT_TTL_S ?? '60', 10)
const signedUrl = await getExportSignedUrl(s3Config, job.s3Key, shortTtlSeconds)
if (req.headers.accept?.includes('application/json') || req.query.redirect === 'false') {
res.json({ downloadUrl: signedUrl, expiresInSeconds: shortTtlSeconds })
return
}
res.redirect(302, signedUrl)
return
}

if (!job.result) {
res.status(404).json({ error: 'Export result data unavailable' })
return
}

const mimeType = job.format === 'csv'
? 'text/csv; charset=utf-8'
: job.format === 'json'
? 'application/json; charset=utf-8'
: 'application/x-ndjson'

res.setHeader('Content-Type', mimeType)
res.setHeader('Content-Disposition', `attachment; filename="${job.filename ?? 'export'}"`)
res.setHeader('Content-Length', job.result.length)
res.send(job.result)
})

return router
}
6 changes: 6 additions & 0 deletions src/services/exportQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export type DlqMetricsHook = (event: DlqMetricsEvent) => void
export interface ExportJob {
id: string
userId: string
orgId?: string
isAdmin: boolean
targetUserId?: string
scope: ExportScope
Expand All @@ -57,6 +58,7 @@ export interface ExportJob {

export interface EnqueueExportJobInput {
userId: string
orgId?: string
isAdmin: boolean
targetUserId?: string
scope: ExportScope
Expand All @@ -69,6 +71,7 @@ export interface EnqueueExportJobInput {
interface ExportJobRecord {
id: string
requester_user_id: string
org_id?: string | null
requester_is_admin: boolean
target_user_id: string | null
scope: ExportScope
Expand Down Expand Up @@ -206,6 +209,7 @@ const sanitizeExportTelemetry = (
const toExportJob = (record: ExportJobRecord): ExportJob => ({
id: record.id,
userId: record.requester_user_id,
orgId: record.org_id ?? undefined,
isAdmin: record.requester_is_admin,
targetUserId: record.target_user_id ?? undefined,
scope: record.scope,
Expand All @@ -227,6 +231,7 @@ const toExportJob = (record: ExportJobRecord): ExportJob => ({
const toRecord = (job: ExportJob): ExportJobRecord => ({
id: job.id,
requester_user_id: job.userId,
org_id: job.orgId ?? null,
requester_is_admin: job.isAdmin,
target_user_id: job.targetUserId ?? null,
scope: job.scope,
Expand Down Expand Up @@ -734,6 +739,7 @@ export const enqueueExportJob = async (

const created = await exportJobRepository.create({
userId: input.userId,
orgId: input.orgId,
isAdmin: input.isAdmin,
targetUserId: input.targetUserId,
scope: input.scope,
Expand Down
5 changes: 3 additions & 2 deletions src/services/exportS3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,10 @@ export async function uploadToS3(config: S3Config, key: string, data: Buffer | R
/**
* Return a pre-signed GET URL valid for `ttlSeconds`.
*/
export async function getExportSignedUrl(config: S3Config, key: string): Promise<string> {
export async function getExportSignedUrl(config: S3Config, key: string, overrideTtlSeconds?: number): Promise<string> {
const client = _clientFactory(config.region)
return _presigner(client, new GetObjectCommand({ Bucket: config.bucket, Key: key }), {
expiresIn: config.signedUrlTtlSeconds,
expiresIn: overrideTtlSeconds ?? config.signedUrlTtlSeconds,
})
}

Loading
Loading