diff --git a/cmd/internal/openapi/client.gen.go b/cmd/internal/openapi/client.gen.go index 7eb6de97..7d47f2fd 100644 --- a/cmd/internal/openapi/client.gen.go +++ b/cmd/internal/openapi/client.gen.go @@ -1068,16 +1068,23 @@ func (e AdminDashboardDownloadersItemsStatus) Valid() bool { // Defines values for AuditEventActorType. const ( + AuditEventActorTypeAgent AuditEventActorType = "agent" + AuditEventActorTypeAgentOauth AuditEventActorType = "agent_oauth" AuditEventActorTypeAnonymous AuditEventActorType = "anonymous" AuditEventActorTypeApiKey AuditEventActorType = "api_key" AuditEventActorTypeDownloader AuditEventActorType = "downloader" AuditEventActorTypeSystem AuditEventActorType = "system" + AuditEventActorTypeTaskUpload AuditEventActorType = "task-upload" AuditEventActorTypeUser AuditEventActorType = "user" ) // Valid indicates whether the value is a known member of the AuditEventActorType enum. func (e AuditEventActorType) Valid() bool { switch e { + case AuditEventActorTypeAgent: + return true + case AuditEventActorTypeAgentOauth: + return true case AuditEventActorTypeAnonymous: return true case AuditEventActorTypeApiKey: @@ -1086,6 +1093,8 @@ func (e AuditEventActorType) Valid() bool { return true case AuditEventActorTypeSystem: return true + case AuditEventActorTypeTaskUpload: + return true case AuditEventActorTypeUser: return true default: diff --git a/package.json b/package.json index ae01c0c7..034c7594 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "stats:backfill": "tsx scripts/backfill-admin-stats.ts", "storage:backfill": "tsx scripts/backfill-storage-usage.ts", "storage-status:backfill": "tsx scripts/backfill-storage-enabled-status.ts", + "api-key-scopes:backfill": "tsx scripts/backfill-api-key-scopes.ts", "typecheck": "tsc --noEmit -p server/tsconfig.json && tsc --noEmit -p src/tsconfig.json", "test": "vitest run --project unit --project integration", "test:cf": "vitest run --project cloudflare", diff --git a/scripts/backfill-api-key-scopes.ts b/scripts/backfill-api-key-scopes.ts new file mode 100644 index 00000000..34c4d8a0 --- /dev/null +++ b/scripts/backfill-api-key-scopes.ts @@ -0,0 +1,199 @@ +#!/usr/bin/env tsx + +import { execFileSync } from 'node:child_process' +import Database from 'better-sqlite3' +import type { ApiKeyPermissions } from '../shared/authorization' + +export type ApiKeyScopeBackfillTarget = + | { kind: 'sqlite'; path: string } + | { kind: 'd1'; database: string; remote: boolean; env?: string } + +export interface ApiKeyScopeBackfillOptions { + apply: boolean + target: ApiKeyScopeBackfillTarget +} + +interface PermissionRow { + id: string + permissions: string | null +} + +export interface ApiKeyScopeBackfillResult { + id: string + before: string | null + after: string +} + +const LEGACY_SCOPE_MAP: Record> = { + ihost: { + upload: ['images:upload'], + }, + webdav: { + read: ['objects:read'], + write: ['objects:create', 'objects:update', 'objects:delete', 'objects:move'], + }, + remoteDownload: { + read: ['download-tasks:read'], + create: ['download-tasks:create'], + cancel: ['download-tasks:cancel'], + }, +} + +export function backfillApiKeyScopePermissionsRows(rows: PermissionRow[]): ApiKeyScopeBackfillResult[] { + return rows.flatMap((row) => { + const before = parsePermissions(row.permissions) + if (!before) return [] + const after = canonicalizePermissions(before) + const beforeJson = stableJson(before) + const afterJson = stableJson(after) + return beforeJson === afterJson ? [] : [{ id: row.id, before: row.permissions, after: afterJson }] + }) +} + +function canonicalizePermissions(input: ApiKeyPermissions): ApiKeyPermissions { + const scopes = new Set() + for (const [resource, actions] of Object.entries(input)) { + if (!Array.isArray(actions)) continue + for (const action of actions) { + if (typeof action !== 'string') continue + const legacyScopes = LEGACY_SCOPE_MAP[resource]?.[action] + if (legacyScopes) { + for (const scope of legacyScopes) scopes.add(scope) + continue + } + scopes.add(`${resource}:${action}`) + } + } + const output: ApiKeyPermissions = {} + for (const scope of [...scopes].sort()) { + const separator = scope.indexOf(':') + if (separator <= 0) continue + const resource = scope.slice(0, separator) + const action = scope.slice(separator + 1) + output[resource] = [...(output[resource] ?? []), action] + } + return output +} + +function parsePermissions(value: string | null): ApiKeyPermissions | null { + if (!value) return null + try { + const parsed = JSON.parse(value) as unknown + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as ApiKeyPermissions) : null + } catch { + return null + } +} + +function stableJson(permissions: ApiKeyPermissions): string { + const sorted: ApiKeyPermissions = {} + for (const resource of Object.keys(permissions).sort()) { + sorted[resource] = [...new Set(permissions[resource])].sort() + } + return JSON.stringify(sorted) +} + +export function parseApiKeyScopeBackfillOptions(argv: string[]): ApiKeyScopeBackfillOptions { + const sqliteIndex = argv.indexOf('--sqlite') + const d1Index = argv.indexOf('--d1') + if ((sqliteIndex >= 0) === (d1Index >= 0)) usage() + if (sqliteIndex >= 0) { + const path = argv[sqliteIndex + 1] + if (!path) usage() + return { apply: argv.includes('--apply'), target: { kind: 'sqlite', path } } + } + const database = argv[d1Index + 1] + if (!database) usage() + const envIndex = argv.indexOf('--env') + return { + apply: argv.includes('--apply'), + target: { + kind: 'd1', + database, + remote: argv.includes('--remote'), + env: envIndex >= 0 ? argv[envIndex + 1] : undefined, + }, + } +} + +function usage(): never { + throw new Error('Usage: pnpm api-key-scopes:backfill -- (--sqlite | --d1 [--remote] [--env ]) [--apply]') +} + +export function apiKeyScopeBackfillD1Args(target: Extract): string[] { + return [ + 'exec', + 'wrangler', + 'd1', + 'execute', + target.database, + target.remote ? '--remote' : '--local', + ...(target.env ? ['--env', target.env] : []), + ] +} + +function executeD1(target: Extract, sql: string, json = false): string { + return execFileSync('pnpm', [...apiKeyScopeBackfillD1Args(target), '--command', sql, ...(json ? ['--json'] : [])], { + encoding: 'utf8', + stdio: json ? 'pipe' : 'inherit', + }) as string +} + +export function listApiKeyScopeBackfillRows(target: ApiKeyScopeBackfillTarget): PermissionRow[] { + if (target.kind === 'd1') { + const payload = JSON.parse(executeD1(target, 'SELECT id, permissions FROM apikey;', true)) as Array<{ + results?: PermissionRow[] + }> + return payload.flatMap((entry) => entry.results ?? []) + } + const db = new Database(target.path, { readonly: true }) + try { + return db.prepare('SELECT id, permissions FROM apikey').all() as PermissionRow[] + } finally { + db.close() + } +} + +export function applyApiKeyScopeBackfill( + target: ApiKeyScopeBackfillTarget, + changes: ApiKeyScopeBackfillResult[], +): void { + /* v8 ignore next 6 -- D1 execution is covered by argument/SQL formatting tests; integration requires Wrangler. */ + if (target.kind === 'd1') { + for (const change of changes) { + executeD1(target, `UPDATE apikey SET permissions = ${sqlString(change.after)} WHERE id = ${sqlString(change.id)};`) + } + return + } + const db = new Database(target.path) + try { + const update = db.prepare('UPDATE apikey SET permissions = ? WHERE id = ?') + const tx = db.transaction((items: ApiKeyScopeBackfillResult[]) => { + for (const item of items) update.run(item.after, item.id) + }) + tx(changes) + } finally { + db.close() + } +} + +export function sqlString(value: string): string { + return `'${value.replaceAll("'", "''")}'` +} + +export function runApiKeyScopeBackfill(options: ApiKeyScopeBackfillOptions): void { + const before = listApiKeyScopeBackfillRows(options.target) + const changes = backfillApiKeyScopePermissionsRows(before) + console.log(JSON.stringify({ mode: options.apply ? 'apply' : 'dry-run', changed: changes.length }, null, 2)) + if (!options.apply) return + applyApiKeyScopeBackfill(options.target, changes) + const remaining = backfillApiKeyScopePermissionsRows(listApiKeyScopeBackfillRows(options.target)) + if (remaining.length > 0) throw new Error(`api_key_scope_backfill_failed:${remaining.length}`) + console.log(JSON.stringify({ mode: 'complete', changed: changes.length }, null, 2)) +} + +function main(): void { + runApiKeyScopeBackfill(parseApiKeyScopeBackfillOptions(process.argv.slice(2))) +} + +if (process.argv[1]?.endsWith('backfill-api-key-scopes.ts')) main() diff --git a/server/adapters/repos/api-keys-rate-limit.integration.test.ts b/server/adapters/repos/api-keys-rate-limit.integration.test.ts index f975c750..59102ec8 100644 --- a/server/adapters/repos/api-keys-rate-limit.integration.test.ts +++ b/server/adapters/repos/api-keys-rate-limit.integration.test.ts @@ -133,7 +133,7 @@ describe('API keys', () => { put.mockClear() await expect( - apiKeys.verifyApiKeyForPermission(auth, db, webdav.key, 'webdav', 'read', 'webdav'), + apiKeys.verifyApiKeyForPermission(auth, db, webdav.key, 'objects', 'read', 'webdav'), ).resolves.toMatchObject({ referenceId: userId }) expect(get).not.toHaveBeenCalled() expect(put).not.toHaveBeenCalled() @@ -284,7 +284,7 @@ describe('API keys', () => { body: { configId: 'webdav', userId, - permissions: { webdav: ['read'] }, + permissions: { objects: ['read'] }, rateLimitMax: 1, rateLimitTimeWindow: 60_000, rateLimitEnabled: true, diff --git a/server/adapters/repos/api-keys.ts b/server/adapters/repos/api-keys.ts index 24e34b4e..0076204c 100644 --- a/server/adapters/repos/api-keys.ts +++ b/server/adapters/repos/api-keys.ts @@ -1,5 +1,6 @@ import { defaultKeyHasher } from '@better-auth/api-key' import { API_KEY_TEMPLATES, type ApiKeyPermissions, type ApiKeyTemplate } from '@shared/api-key-templates' +import { authorizationScope, hasAuthorizationScope } from '@shared/authorization' import { eq } from 'drizzle-orm' import { apikey } from '../../db/auth-schema' import type { Database } from '../../platform/interface' @@ -30,6 +31,8 @@ export function createApiKeyGateway(): ApiKeyGateway { }, async verifyApiKeyForPermission(auth, db, key, resource, action, configId) { + const scope = authorizationScope(resource, action) + if (!scope) return null const resolvedConfigId = configId ?? (await resolveApiKeyConfigId(db, key)) if (!resolvedConfigId) return null const result = await verify(auth, { @@ -43,7 +46,12 @@ export function createApiKeyGateway(): ApiKeyGateway { }, hasApiKeyPermission(permissions: ApiKeyPermissions | null | undefined, resource, action) { - return permissions?.[resource]?.includes(action) ?? false + const scope = authorizationScope(resource, action) + return scope ? hasAuthorizationScope(permissions, scope) : false + }, + + hasApiKeyScope(permissions: ApiKeyPermissions | null | undefined, scope) { + return hasAuthorizationScope(permissions, scope) }, } } diff --git a/server/adapters/repos/audit.ts b/server/adapters/repos/audit.ts index 3f976e5e..87342db9 100644 --- a/server/adapters/repos/audit.ts +++ b/server/adapters/repos/audit.ts @@ -55,7 +55,16 @@ export function idempotentSystemEventValues(input: { } function normalizeActorType(value: string | null, userId?: string | null): AuditActorType { - if (value === 'api_key' || value === 'anonymous' || value === 'system' || value === 'downloader') return value + if ( + value === 'api_key' || + value === 'agent_oauth' || + value === 'agent' || + value === 'anonymous' || + value === 'system' || + value === 'downloader' || + value === 'task-upload' + ) + return value if (!userId) return 'anonymous' return 'user' } @@ -63,8 +72,11 @@ function normalizeActorType(value: string | null, userId?: string | null): Audit function actorDisplayName(actorType: AuditActorType, actorRef: string | null): string { if (actorType === 'anonymous') return 'Anonymous' if (actorType === 'api_key') return actorRef ? `API key:${actorRef}` : 'API key' + if (actorType === 'agent_oauth') return actorRef ? `Agent OAuth:${actorRef}` : 'Agent OAuth' + if (actorType === 'agent') return actorRef ? `Agent:${actorRef}` : 'Agent' if (actorType === 'system') return actorRef ? `System:${actorRef}` : 'System' if (actorType === 'downloader') return actorRef ? `Downloader:${actorRef}` : 'Downloader' + if (actorType === 'task-upload') return actorRef ? `Task upload:${actorRef}` : 'Task upload' return '' } diff --git a/server/http/downloads/download-tasks.ts b/server/http/downloads/download-tasks.ts index f105f315..3bd82955 100644 --- a/server/http/downloads/download-tasks.ts +++ b/server/http/downloads/download-tasks.ts @@ -1,4 +1,5 @@ -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' +import { OpenAPIHono, z } from '@hono/zod-openapi' +import { AuthorizationScope } from '@shared/authorization' import { createDownloadTaskSchema, downloadTaskAttemptSchema, @@ -10,7 +11,6 @@ import { listDownloadTasksQuerySchema, updateDownloadTaskSchema, } from '@shared/schemas' -import { requirePermission } from '../../middleware/authz' import type { Env } from '../../middleware/platform' import { createDownloadTask, @@ -22,7 +22,7 @@ import { updateDownloadTask, } from '../../usecases/downloads/downloads' import { badRequest, unauthorized } from '../../usecases/ports' -import { errorResponse, jsonBody, jsonContent } from '../openapi' +import { authRoute, errorResponse, jsonBody, jsonContent } from '../openapi' import { createdAtIdCursorCodec, decodeOptionalPageToken, @@ -104,133 +104,181 @@ const taskErrorResponses = { 409: errorResponse('Invalid task state'), } -const listRoute = createRoute({ - operationId: 'listDownloadTasks', - summary: 'List download tasks', - tags: ['Download Tasks'], - method: 'get', - path: '/', - middleware: [requirePermission('remoteDownload', 'read')] as const, - request: { query: listDownloadTasksQuerySchema }, - responses: { - 200: jsonContent(downloadTaskListPageSchema, 'Download task list'), - 400: errorResponse('Invalid query'), - 401: errorResponse('Unauthorized'), +const listRoute = authRoute( + { + access: 'protected', + scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ], }, -}) + { + operationId: 'listDownloadTasks', + summary: 'List download tasks', + tags: ['Download Tasks'], + method: 'get', + path: '/', + request: { query: listDownloadTasksQuerySchema }, + responses: { + 200: jsonContent(downloadTaskListPageSchema, 'Download task list'), + 400: errorResponse('Invalid query'), + 401: errorResponse('Unauthorized'), + }, + }, +) -const downloaderTaskListRoute = createRoute({ - operationId: 'listDownloaderTasks', - summary: 'List tasks owned by the authenticated downloader', - tags: ['Downloaders'], - method: 'get', - path: '/me/tasks', - middleware: [requirePermission('remoteDownload', 'read', { allowDownloader: true })] as const, - request: { query: listDownloadTasksQuerySchema }, - responses: { - 200: jsonContent(downloadTaskPageSchema, 'Assigned download tasks'), - 400: errorResponse('Invalid query'), - 401: errorResponse('Unauthorized'), +const downloaderTaskListRoute = authRoute( + { + access: 'protected', + scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ], + allowDownloader: true, }, -}) + { + operationId: 'listDownloaderTasks', + summary: 'List tasks owned by the authenticated downloader', + tags: ['Downloaders'], + method: 'get', + path: '/me/tasks', + request: { query: listDownloadTasksQuerySchema }, + responses: { + 200: jsonContent(downloadTaskPageSchema, 'Assigned download tasks'), + 400: errorResponse('Invalid query'), + 401: errorResponse('Unauthorized'), + }, + }, +) -const createRouteDoc = createRoute({ - operationId: 'createDownloadTask', - summary: 'Create download task', - tags: ['Download Tasks'], - method: 'post', - path: '/', - middleware: [requirePermission('remoteDownload', 'create', { minTeamRole: 'editor' })] as const, - request: jsonBody(createDownloadTaskSchema), - responses: { - 201: jsonContent(downloadTaskSchema, 'Created download task'), - ...taskErrorResponses, +const createRouteDoc = authRoute( + { + access: 'protected', + scopes: [AuthorizationScope.DOWNLOAD_TASKS_CREATE], + minTeamRole: 'editor', }, -}) + { + operationId: 'createDownloadTask', + summary: 'Create download task', + tags: ['Download Tasks'], + method: 'post', + path: '/', + request: jsonBody(createDownloadTaskSchema), + responses: { + 201: jsonContent(downloadTaskSchema, 'Created download task'), + ...taskErrorResponses, + }, + }, +) -const getRoute = createRoute({ - operationId: 'getDownloadTask', - summary: 'Get download task', - tags: ['Download Tasks'], - method: 'get', - path: '/{id}', - middleware: [requirePermission('remoteDownload', 'read')] as const, - request: { params: z.object({ id: z.string() }) }, - responses: { - 200: jsonContent(downloadTaskSchema, 'Download task'), - ...taskErrorResponses, +const getRoute = authRoute( + { + access: 'protected', + scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ], }, -}) + { + operationId: 'getDownloadTask', + summary: 'Get download task', + tags: ['Download Tasks'], + method: 'get', + path: '/{id}', + request: { params: z.object({ id: z.string() }) }, + responses: { + 200: jsonContent(downloadTaskSchema, 'Download task'), + ...taskErrorResponses, + }, + }, +) -const eventsRoute = createRoute({ - operationId: 'listDownloadTaskEvents', - summary: 'List download task timeline events', - tags: ['Download Tasks'], - method: 'get', - path: '/{id}/events', - middleware: [requirePermission('remoteDownload', 'read')] as const, - request: { params: z.object({ id: z.string() }) }, - responses: { - 200: jsonContent(downloadTaskTimelineSchema, 'Download task timeline'), - ...taskErrorResponses, +const eventsRoute = authRoute( + { + access: 'protected', + scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ], }, -}) + { + operationId: 'listDownloadTaskEvents', + summary: 'List download task timeline events', + tags: ['Download Tasks'], + method: 'get', + path: '/{id}/events', + request: { params: z.object({ id: z.string() }) }, + responses: { + 200: jsonContent(downloadTaskTimelineSchema, 'Download task timeline'), + ...taskErrorResponses, + }, + }, +) -const updateRoute = createRoute({ - operationId: 'updateDownloadTask', - summary: 'Update download task', - tags: ['Download Tasks'], - method: 'patch', - path: '/{id}', - middleware: [requirePermission('remoteDownload', 'cancel', { allowDownloader: true })] as const, - request: { params: z.object({ id: z.string() }), ...jsonBody(updateDownloadTaskSchema) }, - responses: { - 200: jsonContent(downloadTaskSchema, 'Updated download task'), - ...taskErrorResponses, +const updateRoute = authRoute( + { + access: 'protected', + scopes: [AuthorizationScope.DOWNLOAD_TASKS_CANCEL], + allowDownloader: true, }, -}) + { + operationId: 'updateDownloadTask', + summary: 'Update download task', + tags: ['Download Tasks'], + method: 'patch', + path: '/{id}', + request: { params: z.object({ id: z.string() }), ...jsonBody(updateDownloadTaskSchema) }, + responses: { + 200: jsonContent(downloadTaskSchema, 'Updated download task'), + ...taskErrorResponses, + }, + }, +) -const statusRoute = createRoute({ - operationId: 'setDownloadTaskStatus', - summary: 'Pause, resume, or cancel a task', - tags: ['Download Tasks'], - method: 'put', - path: '/{id}/status', - middleware: [requirePermission('remoteDownload', 'cancel')] as const, - request: { params: z.object({ id: z.string() }), ...jsonBody(downloadTaskStatusUpdateSchema) }, - responses: { - 200: jsonContent(downloadTaskSchema, 'Updated download task'), - ...taskErrorResponses, +const statusRoute = authRoute( + { + access: 'protected', + scopes: [AuthorizationScope.DOWNLOAD_TASKS_CANCEL], }, -}) + { + operationId: 'setDownloadTaskStatus', + summary: 'Pause, resume, or cancel a task', + tags: ['Download Tasks'], + method: 'put', + path: '/{id}/status', + request: { params: z.object({ id: z.string() }), ...jsonBody(downloadTaskStatusUpdateSchema) }, + responses: { + 200: jsonContent(downloadTaskSchema, 'Updated download task'), + ...taskErrorResponses, + }, + }, +) -const attemptRoute = createRoute({ - operationId: 'retryDownloadTask', - summary: 'Retry or restart a task', - tags: ['Download Tasks'], - method: 'post', - path: '/{id}/attempts', - middleware: [requirePermission('remoteDownload', 'cancel')] as const, - request: { params: z.object({ id: z.string() }), ...jsonBody(downloadTaskAttemptSchema) }, - responses: { - 201: jsonContent(downloadTaskSchema, 'New download attempt'), - ...taskErrorResponses, +const attemptRoute = authRoute( + { + access: 'protected', + scopes: [AuthorizationScope.DOWNLOAD_TASKS_CANCEL], }, -}) + { + operationId: 'retryDownloadTask', + summary: 'Retry or restart a task', + tags: ['Download Tasks'], + method: 'post', + path: '/{id}/attempts', + request: { params: z.object({ id: z.string() }), ...jsonBody(downloadTaskAttemptSchema) }, + responses: { + 201: jsonContent(downloadTaskSchema, 'New download attempt'), + ...taskErrorResponses, + }, + }, +) -const deleteRoute = createRoute({ - operationId: 'deleteDownloadTask', - summary: 'Delete download task', - tags: ['Download Tasks'], - method: 'delete', - path: '/{id}', - middleware: [requirePermission('remoteDownload', 'cancel')] as const, - request: { params: z.object({ id: z.string() }) }, - responses: { - 204: { description: 'Deleted download task' }, - ...taskErrorResponses, +const deleteRoute = authRoute( + { + access: 'protected', + scopes: [AuthorizationScope.DOWNLOAD_TASKS_CANCEL], }, -}) + { + operationId: 'deleteDownloadTask', + summary: 'Delete download task', + tags: ['Download Tasks'], + method: 'delete', + path: '/{id}', + request: { params: z.object({ id: z.string() }) }, + responses: { + 204: { description: 'Deleted download task' }, + ...taskErrorResponses, + }, + }, +) const downloadTasksRoute = new OpenAPIHono() .openapi(listRoute, async (c) => { diff --git a/server/http/events.integration.test.ts b/server/http/events.integration.test.ts index d6db0366..a6d080ef 100644 --- a/server/http/events.integration.test.ts +++ b/server/http/events.integration.test.ts @@ -112,7 +112,7 @@ describe('GET /api/events', () => { const keyOwner = await authedOrgFor(testApp, 'api-key-owner@example.com') const otherOrg = await authedOrgFor(testApp, 'other-org-owner@example.com') const key = await createOrgApiKey(testApp.auth, keyOwner.orgId, keyOwner.userId, { - remoteDownload: ['read'], + 'download-tasks': ['read'], }) await insertDownloadTask(testApp, { id: 'authorized-org-task', @@ -146,11 +146,11 @@ describe('GET /api/events', () => { expect(text).not.toContain('event: jobs') }) - it('forbids a workspace API key without remoteDownload read [spec: events/api-key-permission-denied]', async () => { + it('forbids a workspace API key without download-tasks read [spec: events/api-key-permission-denied]', async () => { const testApp = await createTestApp() const keyOwner = await authedOrgFor(testApp, 'api-key-no-read@example.com') const key = await createOrgApiKey(testApp.auth, keyOwner.orgId, keyOwner.userId, { - remoteDownload: ['create'], + 'download-tasks': ['create'], }) const res = await testApp.app.request('/api/events', { @@ -164,7 +164,7 @@ describe('GET /api/events', () => { const testApp = await createTestApp() const keyOwner = await authedOrgFor(testApp, 'api-key-no-opt-in@example.com') const key = await createOrgApiKey(testApp.auth, keyOwner.orgId, keyOwner.userId, { - remoteDownload: ['read'], + 'download-tasks': ['read'], }) const res = await testApp.app.request('/api/events', { diff --git a/server/http/events.ts b/server/http/events.ts index 2d673598..fd618dfc 100644 --- a/server/http/events.ts +++ b/server/http/events.ts @@ -1,10 +1,11 @@ -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' +import { OpenAPIHono, z } from '@hono/zod-openapi' +import { AuthorizationScope } from '@shared/authorization' import { errorResponseSchema } from '@shared/schemas' import { createMiddleware } from 'hono/factory' -import { requirePermission } from '../middleware/authz' import type { Env } from '../middleware/platform' import { type EventsMessage, streamEvents } from '../usecases/events' import { forbidden, unauthorized } from '../usecases/ports' +import { authRoute } from './openapi' const encoder = new TextEncoder() @@ -25,32 +26,38 @@ const requireEventsAccess = createMiddleware(async (c, next) => { // The SSE body is a stream of text/event-stream frames, not JSON, so the schema // is just a string. OpenAPI 3.x has no native way to type the named events of a // single stream, so they're spelled out in the route description below. -const eventStreamRoute = createRoute({ - operationId: 'streamEvents', - tags: ['Events'], - method: 'get', - path: '/', - middleware: [requireEventsAccess, requirePermission('remoteDownload', 'read')] as const, - summary: 'Server-sent events stream', - description: [ - 'A single SSE connection multiplexing several domains via named events:', - '', - '- `resource-change` → `{ sequence, resourceType, resourceId, changeType, action, metadata, occurredAt }`', - '- `resync` → `{ sequence }` — the resume cursor is older than retained changes; invalidate active queries', - '- `heartbeat` → `{ at }` — keep-alive emitted when nothing changed for a while', - '- `error` → `{ message }` — a domain query failed this tick', - '', - 'Workspace-scoped API keys require `remoteDownload:read`. Their stream is limited to download-task changes from the key workspace plus heartbeat and error control events.', - ].join('\n'), - responses: { - 200: { - content: { 'text/event-stream': { schema: z.string() } }, - description: 'Open SSE stream of domain-change events', - }, - 401: { content: { 'application/json': { schema: errorResponseSchema } }, description: 'Unauthorized' }, - 403: { content: { 'application/json': { schema: errorResponseSchema } }, description: 'Forbidden' }, +const eventStreamRoute = authRoute( + { + access: 'protected', + scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ], }, -}) + { + operationId: 'streamEvents', + tags: ['Events'], + method: 'get', + path: '/', + middleware: [requireEventsAccess] as const, + summary: 'Server-sent events stream', + description: [ + 'A single SSE connection multiplexing several domains via named events:', + '', + '- `resource-change` → `{ sequence, resourceType, resourceId, changeType, action, metadata, occurredAt }`', + '- `resync` → `{ sequence }` — the resume cursor is older than retained changes; invalidate active queries', + '- `heartbeat` → `{ at }` — keep-alive emitted when nothing changed for a while', + '- `error` → `{ message }` — a domain query failed this tick', + '', + 'Workspace-scoped API keys require `download-tasks:read`. Their stream is limited to download-task changes from the key workspace plus heartbeat and error control events.', + ].join('\n'), + responses: { + 200: { + content: { 'text/event-stream': { schema: z.string() } }, + description: 'Open SSE stream of domain-change events', + }, + 401: { content: { 'application/json': { schema: errorResponseSchema } }, description: 'Unauthorized' }, + 403: { content: { 'application/json': { schema: errorResponseSchema } }, description: 'Forbidden' }, + }, + }, +) // One SSE stream multiplexing several domains via named events: // event: resource-change → durable invalidation event diff --git a/server/http/image-hosting/images.ts b/server/http/image-hosting/images.ts index 004b5f2a..3cb59d17 100644 --- a/server/http/image-hosting/images.ts +++ b/server/http/image-hosting/images.ts @@ -204,7 +204,7 @@ const app = new OpenAPIHono() // base64). Tool-oriented and not RESTful, so it stays a plain route, excluded from // the OpenAPI document / SDK. Registered as a statement so the `.openapi()` chain // below keeps its typing. -app.post('/images', requirePermission('ihost', 'upload'), async (c) => { +app.post('/images', requirePermission('images', 'upload'), async (c) => { const orgId = c.get('orgId') if (!orgId) throw unauthorized() diff --git a/server/http/openapi.ts b/server/http/openapi.ts index d2cbe71c..50ddd718 100644 --- a/server/http/openapi.ts +++ b/server/http/openapi.ts @@ -1,5 +1,6 @@ -import type { z } from '@hono/zod-openapi' +import { createRoute, type RouteConfig, type z } from '@hono/zod-openapi' import { errorResponseSchema } from '@shared/schemas' +import { authorize, type RouteAuthorizationDeclaration } from '../middleware/authz' // Shared OpenAPI route helpers used by every resource router. Generic over the // schema so its precise type reaches `createRoute`: that types `c.req.valid(...)` @@ -21,3 +22,49 @@ export const jsonBody = (schema: T) => ({ // usecases produce errors as `AppError` values that `app.onError` renders via // `jsonError`; this just documents the response shape in the OpenAPI document. export const errorResponse = (description: string) => jsonContent(errorResponseSchema, description) + +export function authRoute

& { path: P }>( + auth: RouteAuthorizationDeclaration, + config: T, +): T & { getRoutingPath(): string } { + const middleware = + auth.access === 'public' ? config.middleware : [authorize(auth), ...((config.middleware ?? []) as [])] + return createRoute({ + ...config, + middleware, + security: openApiSecurity(auth), + 'x-zpan-auth': openApiAuthMetadata(auth), + } as T) as T & { getRoutingPath(): string } +} + +export function findOperationsMissingAuthContract(paths: Record>): string[] { + const methods = new Set(['get', 'put', 'post', 'delete', 'patch', 'head', 'options']) + const missing: string[] = [] + for (const [path, operations] of Object.entries(paths)) { + for (const [method, operation] of Object.entries(operations)) { + if (!methods.has(method)) continue + if (!operation || typeof operation !== 'object') continue + if ('x-zpan-auth' in operation) continue + missing.push(`${method.toUpperCase()} ${path}`) + } + } + return missing +} + +function openApiSecurity(auth: RouteAuthorizationDeclaration) { + if (auth.access === 'public' || auth.access === 'internal') return [] + return auth.scopes?.length + ? [{ bearerAuth: [...auth.scopes] }, { cookieAuth: [] }] + : [{ bearerAuth: [] }, { cookieAuth: [] }] +} + +function openApiAuthMetadata(auth: RouteAuthorizationDeclaration): Record { + if (auth.access !== 'protected') return { access: auth.access } + return { + access: auth.access, + scopes: auth.scopes ?? [], + minTeamRole: auth.minTeamRole ?? null, + allowDownloader: auth.allowDownloader ?? false, + auditDenied: auth.auditDenied !== false, + } +} diff --git a/server/http/site/audit.ts b/server/http/site/audit.ts index a7235c46..38020539 100644 --- a/server/http/site/audit.ts +++ b/server/http/site/audit.ts @@ -13,7 +13,7 @@ const auditEventSchema = z id: z.string(), orgId: z.string(), userId: z.string().nullable(), - actorType: z.enum(['user', 'api_key', 'anonymous', 'system', 'downloader']), + actorType: z.enum(['user', 'api_key', 'agent_oauth', 'agent', 'anonymous', 'system', 'downloader', 'task-upload']), actorRef: z.string().nullable(), action: z.string(), targetType: z.string(), diff --git a/server/http/teams.ts b/server/http/teams.ts index a118b9e6..382f12b4 100644 --- a/server/http/teams.ts +++ b/server/http/teams.ts @@ -75,7 +75,7 @@ const activityEventSchema = z id: z.string(), orgId: z.string(), userId: z.string().nullable(), - actorType: z.enum(['user', 'api_key', 'anonymous', 'system', 'downloader']), + actorType: z.enum(['user', 'api_key', 'agent_oauth', 'agent', 'anonymous', 'system', 'downloader', 'task-upload']), actorRef: z.string().nullable(), action: z.string(), targetType: z.string(), diff --git a/server/http/webdav.integration.test.ts b/server/http/webdav.integration.test.ts index 36caa7a7..546fede1 100644 --- a/server/http/webdav.integration.test.ts +++ b/server/http/webdav.integration.test.ts @@ -166,7 +166,7 @@ describe('WebDAV API', () => { const { app, db, auth, deps } = await createTestApp({}, { [WEBDAV_RATE_LIMITER_BINDING]: { limit } }) await authedHeaders(app) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['read'] }) + const key = await apiKey(auth, account.id, { objects: ['read'] }) const verify = vi.spyOn(deps.apiKeys, 'verifyApiKeyForPermission') const headers = basicHeaders(account.email, key, { Depth: '0' }) @@ -201,7 +201,7 @@ describe('WebDAV API', () => { const account = await userAccount(db) const previousActivity = Date.parse('2026-01-01T00:00:00.000Z') await db.run(sql`UPDATE user SET last_active_at = ${previousActivity} WHERE id = ${account.id}`) - const key = await apiKey(auth, account.id, { webdav: ['read'] }) + const key = await apiKey(auth, account.id, { objects: ['read'] }) const headers = basicHeaders(account.email, key) const rejected = await app.request('/dav/', { method: 'PROPFIND', headers: { ...headers, Depth: 'infinity' } }) @@ -219,7 +219,7 @@ describe('WebDAV API', () => { const headers = await authedHeaders(app) const { slug } = await org(db) const account = await userAccount(db) - const readKey = await apiKey(auth, account.id, { webdav: ['read'] }) + const readKey = await apiKey(auth, account.id, { objects: ['read'] }) const missing = await app.request(`/dav/${slug}/`, { method: 'PROPFIND' }) expect(missing.status).toBe(401) @@ -255,13 +255,40 @@ describe('WebDAV API', () => { expect(res.headers.get('WWW-Authenticate')).toBe('Basic realm="ZPan WebDAV"') }) + it('rejects create-only API keys for overwrite-capable PUT and COPY methods', async () => { + const { app, db, auth } = await createTestApp() + await authedHeaders(app) + await seedStorage(db) + const workspace = await org(db) + const account = await userAccount(db) + const createOnlyKey = await apiKey(auth, account.id, { objects: ['create'] }) + await file(db, workspace.id, { id: 'create-only-put', name: 'create-only-put.txt' }) + await file(db, workspace.id, { id: 'create-only-copy-source', name: 'create-only-copy-source.txt' }) + await file(db, workspace.id, { id: 'create-only-copy-target', name: 'create-only-copy-target.txt' }) + + const put = await app.request(`/dav/${workspace.slug}/create-only-put.txt`, { + method: 'PUT', + headers: basicHeaders(account.email, createOnlyKey, { 'Content-Type': 'text/plain' }), + body: 'replace', + }) + expect(put.status).toBe(401) + + const copy = await app.request(`/dav/${workspace.slug}/create-only-copy-source.txt`, { + method: 'COPY', + headers: basicHeaders(account.email, createOnlyKey, { + Destination: `http://localhost/dav/${workspace.slug}/create-only-copy-target.txt`, + }), + }) + expect(copy.status).toBe(401) + }) + it('PROPFIND lists the mount root, workspace root, and folder children [spec: webdav/propfind]', async () => { const { app, db, auth } = await createTestApp() await authedHeaders(app) await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['read'] }) + const key = await apiKey(auth, account.id, { objects: ['read'] }) await folder(db, workspace.id, { id: 'docs', name: 'Docs' }) await file(db, workspace.id, { id: 'readme', name: 'readme.txt', parent: 'Docs' }) await file(db, workspace.id, { id: 'special', name: 'Miss Americana & The Heartbreak Prince.txt', parent: 'Docs' }) @@ -329,7 +356,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['read', 'write'] }) + const key = await apiKey(auth, account.id, { objects: ['read', 'create', 'update', 'delete', 'move'] }) await file(db, workspace.id, { id: 'custom-host-source', name: 'source.txt', size: 12 }) const root = await app.request('https://dav.example.com/dav/', { @@ -385,7 +412,7 @@ describe('WebDAV API', () => { const account = await userAccount(db) const team = await teamWorkspace(db, { id: 'team-dav', slug: 'team-dav', userId: account.id, name: 'Team DAV' }) const hidden = await teamWorkspace(db, { id: 'hidden-dav', slug: 'hidden-dav', name: 'Hidden DAV' }) - const key = await apiKey(auth, account.id, { webdav: ['read'] }) + const key = await apiKey(auth, account.id, { objects: ['read'] }) const root = await app.request('/dav/', { method: 'PROPFIND', headers: basicHeaders(account.email, key) }) expect(root.status).toBe(207) @@ -407,7 +434,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['read'] }) + const key = await apiKey(auth, account.id, { objects: ['read'] }) await folder(db, workspace.id, { id: 'docs', name: 'Docs' }) await file(db, workspace.id, { id: 'readme', name: 'readme.txt', parent: 'Docs' }) @@ -476,7 +503,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['read', 'write'] }) + const key = await apiKey(auth, account.id, { objects: ['read', 'create', 'update', 'delete', 'move'] }) await file(db, workspace.id, { id: 'dead-props', name: 'dead-props.txt' }) const set = await app.request(`/dav/${workspace.slug}/dead-props.txt`, { @@ -591,7 +618,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['read'] }) + const key = await apiKey(auth, account.id, { objects: ['read'] }) await file(db, workspace.id, { id: 'readme', name: 'readme.txt', size: 12 }) const head = await app.request(`/dav/${workspace.slug}/readme.txt`, { @@ -640,7 +667,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['read'] }) + const key = await apiKey(auth, account.id, { objects: ['read'] }) await file(db, workspace.id, { id: 'background-read', name: 'background.txt', size: 12 }) const originalRecord = deps.audit.record @@ -690,7 +717,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['read'] }) + const key = await apiKey(auth, account.id, { objects: ['read'] }) await file(db, workspace.id, { id: 'traffic-full', name: 'traffic.txt', size: 12 }) await seedTrafficPlan(db, workspace.id, 1000, 25) @@ -723,7 +750,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['read'] }) + const key = await apiKey(auth, account.id, { objects: ['read'] }) await file(db, workspace.id, { id: 'traffic-range', name: 'range-traffic.txt', size: 12 }) await seedTrafficPlan(db, workspace.id, 30, 25) vi.mocked(S3Service.prototype.getObjectBody).mockResolvedValueOnce(streamBody('hello')) @@ -778,7 +805,7 @@ describe('WebDAV API', () => { `) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['read'] }) + const key = await apiKey(auth, account.id, { objects: ['read'] }) await file(db, workspace.id, { id: 'traffic-report', name: 'report.txt', size: 12 }) await seedTrafficPlan(db, workspace.id, 1000, 0) @@ -804,7 +831,7 @@ describe('WebDAV API', () => { await authedHeaders(app) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['read'] }) + const key = await apiKey(auth, account.id, { objects: ['read'] }) const resolve = vi.spyOn(deps.webdavPath, 'resolveExistingWebDavPath') const res = await app.request(`/dav/${workspace.slug}/._missing.txt`, { @@ -824,7 +851,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['read'] }) + const key = await apiKey(auth, account.id, { objects: ['read'] }) await file(db, workspace.id, { id: 'range', name: 'range.txt', size: 12 }) vi.mocked(S3Service.prototype.getObjectBody).mockResolvedValueOnce(streamBody('hello')) @@ -866,7 +893,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['read'] }) + const key = await apiKey(auth, account.id, { objects: ['read'] }) await file(db, workspace.id, { id: 'mounted-media', name: 'audio.mp3', size: 2 * 1024 * 1024 }) vi.mocked(S3Service.prototype.getObjectBody).mockResolvedValueOnce(streamBody('chunk')) @@ -896,7 +923,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['read'] }) + const key = await apiKey(auth, account.id, { objects: ['read'] }) await file(db, workspace.id, { id: 'if-range', name: 'video.mp4', size: 12 }) const head = await app.request(`/dav/${workspace.slug}/video.mp4`, { @@ -967,7 +994,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['read', 'write'] }) + const key = await apiKey(auth, account.id, { objects: ['read', 'create', 'update', 'delete', 'move'] }) await file(db, workspace.id, { id: 'precondition', name: 'precondition.txt', size: 12 }) const head = await app.request(`/dav/${workspace.slug}/precondition.txt`, { @@ -1027,7 +1054,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['read'] }) + const key = await apiKey(auth, account.id, { objects: ['read'] }) await file(db, workspace.id, { id: 'webdavfs-cache', name: 'cached.mp3', size: 12 }) const head = await app.request(`/dav/${workspace.slug}/cached.mp3`, { @@ -1058,7 +1085,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['read', 'write'] }) + const key = await apiKey(auth, account.id, { objects: ['read', 'create', 'update', 'delete', 'move'] }) await file(db, workspace.id, { id: 'date-precondition', name: 'date.txt', size: 12 }) const head = await app.request(`/dav/${workspace.slug}/date.txt`, { @@ -1102,7 +1129,7 @@ describe('WebDAV API', () => { const { app, db, auth } = await createTestApp() await authedHeaders(app) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['read'] }) + const key = await apiKey(auth, account.id, { objects: ['read'] }) const res = await app.request('/dav/', { method: 'OPTIONS', headers: basicHeaders(account.email, key) }) expect(res.status).toBe(204) @@ -1127,7 +1154,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['write'] }) + const key = await apiKey(auth, account.id, { objects: ['create', 'update', 'delete', 'move'] }) const resolve = vi.spyOn(deps.webdavPath, 'resolveWebDavPath') const res = await app.request(`/dav/${workspace.slug}/upload.txt`, { @@ -1170,7 +1197,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['write'] }) + const key = await apiKey(auth, account.id, { objects: ['create', 'update', 'delete', 'move'] }) const res = await app.request(`/dav/${workspace.slug}/upload.txt`, { method: 'PUT', @@ -1197,7 +1224,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['write'] }) + const key = await apiKey(auth, account.id, { objects: ['create', 'update', 'delete', 'move'] }) await file(db, workspace.id, { id: 'existing', name: 'existing', size: 20 }) await folder(db, workspace.id, { id: 'docs', name: 'Docs' }) @@ -1246,7 +1273,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['write'] }) + const key = await apiKey(auth, account.id, { objects: ['create', 'update', 'delete', 'move'] }) vi.mocked(S3Service.prototype.putObject).mockRejectedValueOnce(new Error('s3 failed')) const res = await app.request(`/dav/${workspace.slug}/will-fail.txt`, { @@ -1265,7 +1292,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['read', 'write'] }) + const key = await apiKey(auth, account.id, { objects: ['read', 'create', 'update', 'delete', 'move'] }) const before = await app.request(`/dav/${workspace.slug}/`, { method: 'PROPFIND', @@ -1295,7 +1322,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['write'] }) + const key = await apiKey(auth, account.id, { objects: ['create', 'update', 'delete', 'move'] }) await folder(db, workspace.id, { id: 'projects', name: 'Projects' }) await file(db, workspace.id, { id: 'file-parent', name: 'file-parent.txt' }) @@ -1337,7 +1364,7 @@ describe('WebDAV API', () => { userId: account.id, name: 'Second DAV', }) - const key = await apiKey(auth, account.id, { webdav: ['write'] }) + const key = await apiKey(auth, account.id, { objects: ['create', 'update', 'delete', 'move'] }) await file(db, workspace.id, { id: 'move-me', name: 'move-me.txt' }) const move = await app.request(`/dav/${workspace.slug}/move-me.txt`, { @@ -1392,7 +1419,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['write'] }) + const key = await apiKey(auth, account.id, { objects: ['create', 'update', 'delete', 'move'] }) await file(db, workspace.id, { id: 'source', name: 'source.txt' }) await file(db, workspace.id, { id: 'target', name: 'target.txt' }) @@ -1432,7 +1459,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['write'] }) + const key = await apiKey(auth, account.id, { objects: ['create', 'update', 'delete', 'move'] }) await folder(db, workspace.id, { id: 'src-folder', name: 'Source' }) await folder(db, workspace.id, { id: 'nested-folder', name: 'Nested', parent: 'Source' }) await file(db, workspace.id, { id: 'nested-file', name: 'note.txt', parent: 'Source/Nested', size: 12 }) @@ -1485,7 +1512,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['write'] }) + const key = await apiKey(auth, account.id, { objects: ['create', 'update', 'delete', 'move'] }) await folder(db, workspace.id, { id: 'locked-target', name: 'LockedTarget' }) await file(db, workspace.id, { id: 'copy-locked-source', name: 'locked-source.txt', size: 12 }) @@ -1530,7 +1557,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['write'] }) + const key = await apiKey(auth, account.id, { objects: ['create', 'update', 'delete', 'move'] }) await folder(db, workspace.id, { id: 'move-folder', name: 'MoveMe' }) await folder(db, workspace.id, { id: 'move-child', name: 'Child', parent: 'MoveMe' }) await file(db, workspace.id, { id: 'move-file', name: 'note.txt', parent: 'MoveMe/Child' }) @@ -1563,7 +1590,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['write'] }) + const key = await apiKey(auth, account.id, { objects: ['create', 'update', 'delete', 'move'] }) await file(db, workspace.id, { id: 'guarded-file', name: 'guarded.txt' }) await file(db, workspace.id, { id: 'move-guarded-file', name: 'move-guarded.txt' }) await file(db, workspace.id, { id: 'copy-guarded-file', name: 'copy-guarded.txt' }) @@ -1634,7 +1661,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['read', 'write'] }) + const key = await apiKey(auth, account.id, { objects: ['read', 'create', 'update', 'delete', 'move'] }) await folder(db, workspace.id, { id: 'delete-folder', name: 'DeleteMe' }) await file(db, workspace.id, { id: 'delete-file', name: 'gone.txt', parent: 'DeleteMe' }) @@ -1663,7 +1690,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['read', 'write'] }) + const key = await apiKey(auth, account.id, { objects: ['read', 'create', 'update', 'delete', 'move'] }) await file(db, workspace.id, { id: 'state-file', name: 'state.txt' }) const patch = await app.request(`/dav/${workspace.slug}/state.txt`, { @@ -1746,7 +1773,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['read', 'write'] }) + const key = await apiKey(auth, account.id, { objects: ['read', 'create', 'update', 'delete', 'move'] }) await file(db, workspace.id, { id: 'if-file', name: 'if.txt', size: 12 }) const head = await app.request(`/dav/${workspace.slug}/if.txt`, { @@ -1862,7 +1889,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['read', 'write'] }) + const key = await apiKey(auth, account.id, { objects: ['read', 'create', 'update', 'delete', 'move'] }) await file(db, workspace.id, { id: 'lock-file', name: 'locked.txt', size: 12 }) await file(db, workspace.id, { id: 'other-lock-file', name: 'other-locked.txt', size: 12 }) @@ -2050,7 +2077,7 @@ describe('WebDAV API', () => { userId: account.id, name: 'Refresh Other Workspace', }) - const key = await apiKey(auth, account.id, { webdav: ['write'] }) + const key = await apiKey(auth, account.id, { objects: ['create', 'update', 'delete', 'move'] }) await folder(db, workspace.id, { id: 'refresh-folder', name: 'RefreshScope' }) await file(db, workspace.id, { id: 'refresh-child', name: 'child.txt', parent: 'RefreshScope' }) await file(db, workspace.id, { id: 'refresh-outside', name: 'outside.txt' }) @@ -2115,7 +2142,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['read', 'write'] }) + const key = await apiKey(auth, account.id, { objects: ['read', 'create', 'update', 'delete', 'move'] }) await folder(db, workspace.id, { id: 'discovery-folder', name: 'DiscoveryScope' }) await file(db, workspace.id, { id: 'discovery-child', name: 'child.txt', parent: 'DiscoveryScope' }) @@ -2144,7 +2171,7 @@ describe('WebDAV API', () => { await authedHeaders(app) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['read', 'write'] }) + const key = await apiKey(auth, account.id, { objects: ['read', 'create', 'update', 'delete', 'move'] }) const missingGet = await app.request(`/dav/${workspace.slug}/missing.txt`, { method: 'GET', @@ -2165,7 +2192,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['write'] }) + const key = await apiKey(auth, account.id, { objects: ['create', 'update', 'delete', 'move'] }) await file(db, workspace.id, { id: 'move-source', name: 'move-source.txt' }) await file(db, workspace.id, { id: 'move-target', name: 'move-target.txt' }) @@ -2202,7 +2229,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['write'] }) + const key = await apiKey(auth, account.id, { objects: ['create', 'update', 'delete', 'move'] }) await file(db, workspace.id, { id: 'copy-source', name: 'copy-source.txt', size: 12 }) await file(db, workspace.id, { id: 'copy-target', name: 'copy-target.txt' }) await folder(db, workspace.id, { id: 'copy-folder', name: 'Copy Folder' }) @@ -2258,7 +2285,7 @@ describe('WebDAV API', () => { await seedStorage(db) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['write'] }) + const key = await apiKey(auth, account.id, { objects: ['create', 'update', 'delete', 'move'] }) await file(db, workspace.id, { id: 'source', name: 'source.txt', size: 12 }) vi.mocked(S3Service.prototype.copyObject).mockRejectedValueOnce(new Error('copy failed')) @@ -2276,7 +2303,7 @@ describe('WebDAV API', () => { await authedHeaders(app) const workspace = await org(db) const account = await userAccount(db) - const key = await apiKey(auth, account.id, { webdav: ['read'] }) + const key = await apiKey(auth, account.id, { objects: ['read'] }) for (const path of [ `/dav/${workspace.slug}/%252e%252e/x`, @@ -2340,7 +2367,11 @@ describe('WebDAV over real HTTP (npm client)', () => { workspaceSlug = workspace.slug apiKey = ( await (auth.api as { createApiKey(input: unknown): Promise<{ key: string }> }).createApiKey({ - body: { configId: 'webdav', userId: user.id, permissions: { webdav: ['read', 'write'] } }, + body: { + configId: 'webdav', + userId: user.id, + permissions: { objects: ['read', 'create', 'update', 'delete', 'move'] }, + }, }) ).key diff --git a/server/http/webdav.ts b/server/http/webdav.ts index 3df91551..6ce01ec5 100644 --- a/server/http/webdav.ts +++ b/server/http/webdav.ts @@ -67,9 +67,29 @@ import { resolveWebDavPathWithChildren, } from '../usecases/webdav' -const READ_METHODS = new Set(['OPTIONS', 'PROPFIND', 'GET', 'HEAD']) -const WRITE_METHODS = new Set(['PUT', 'DELETE', 'MKCOL', 'MOVE', 'COPY', 'PROPPATCH', 'LOCK', 'UNLOCK']) -const WEBDAV_RESOURCE = 'webdav' +const WEBDAV_METHOD_SCOPES: Record< + string, + Array<{ resource: 'objects'; action: 'read' | 'create' | 'update' | 'delete' | 'move' }> +> = { + OPTIONS: [{ resource: 'objects', action: 'read' }], + PROPFIND: [{ resource: 'objects', action: 'read' }], + GET: [{ resource: 'objects', action: 'read' }], + HEAD: [{ resource: 'objects', action: 'read' }], + PUT: [ + { resource: 'objects', action: 'create' }, + { resource: 'objects', action: 'update' }, + ], + MKCOL: [{ resource: 'objects', action: 'create' }], + COPY: [ + { resource: 'objects', action: 'create' }, + { resource: 'objects', action: 'update' }, + ], + PROPPATCH: [{ resource: 'objects', action: 'update' }], + LOCK: [{ resource: 'objects', action: 'update' }], + UNLOCK: [{ resource: 'objects', action: 'update' }], + DELETE: [{ resource: 'objects', action: 'delete' }], + MOVE: [{ resource: 'objects', action: 'move' }], +} type DavContext = Context type DavAuth = { @@ -87,8 +107,8 @@ const cloudBaseUrl = (c: DavContext): string => c.get('platform').getEnv('ZPAN_C async function requireWebDavApiKey(c: DavContext): Promise { const method = c.req.method.toUpperCase() - const action = READ_METHODS.has(method) ? 'read' : WRITE_METHODS.has(method) ? 'write' : null - if (!action) return c.text('Method Not Allowed', 405) + const requiredScope = WEBDAV_METHOD_SCOPES[method] + if (!requiredScope) return c.text('Method Not Allowed', 405) const credentials = parseBasicAuth(c.req.raw.headers.get('Authorization')) if (!credentials) return unauthorized() @@ -108,8 +128,7 @@ async function requireWebDavApiKey(c: DavContext): Promise { db: c.get('platform').db, username: credentials.username, password: credentials.password, - resource: WEBDAV_RESOURCE, - action, + requiredScopes: requiredScope, configId: ApiKeyTemplate.WEBDAV, }) c.get('webDavTrace').push(`auth:${Math.round(performance.now() - startedAt)}`) diff --git a/server/middleware/audit-actor.ts b/server/middleware/audit-actor.ts index bf302d33..fcc6b5af 100644 --- a/server/middleware/audit-actor.ts +++ b/server/middleware/audit-actor.ts @@ -12,5 +12,5 @@ export function auditActor(principal: AuthPrincipal | null): AuditActor { if (principal.kind === 'downloader') { return { userId: null, actorType: 'downloader', actorRef: principal.downloaderId } } - return { userId: principal.createdByUserId, actorType: 'downloader', actorRef: principal.downloaderId } + return { userId: principal.createdByUserId, actorType: 'task-upload', actorRef: principal.taskId } } diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 63318a56..88a0db2d 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -1,6 +1,7 @@ +import { isAuthorizationScope, permissionScopes } from '@shared/authorization' import { createMiddleware } from 'hono/factory' import { ApiKeyRateLimitError, type CachePolicy, forbidden, rateLimited, unauthorized } from '../usecases/ports' -import type { Env } from './platform' +import { anonymousAuthzContext, type Env } from './platform' // 'member' is the better-auth schema default; map it to viewer level so // existing org members get read access rather than being silently denied. @@ -35,6 +36,15 @@ export const authMiddleware = createMiddleware(async (c, next) => { const taskUpload = await deps.downloadTokens.resolveTaskUploadToken(platform.db, platform, token) if (taskUpload) { c.set('principal', { ...taskUpload, kind: 'download-task-upload', authMethod: 'bearer' }) + c.set('authzContext', { + credential: 'download-task-upload', + userId: taskUpload.createdByUserId, + orgId: taskUpload.orgId, + fixedOrgId: taskUpload.orgId, + grantedScopes: new Set(taskUpload.scopes.filter(isAuthorizationScope)), + actor: { type: 'task-upload', ref: taskUpload.taskId }, + state: { downloaderId: taskUpload.downloaderId, taskId: taskUpload.taskId }, + }) c.set('userId', null) c.set('userRole', null) c.set('orgId', taskUpload.orgId) @@ -44,6 +54,15 @@ export const authMiddleware = createMiddleware(async (c, next) => { const downloader = await deps.downloadTokens.resolveDownloaderToken(platform, token) if (downloader) { c.set('principal', { kind: 'downloader', downloaderId: downloader.downloaderId, authMethod: 'bearer' }) + c.set('authzContext', { + credential: 'downloader', + userId: null, + orgId: null, + fixedOrgId: null, + grantedScopes: new Set(), + actor: { type: 'downloader', ref: downloader.downloaderId }, + state: {}, + }) c.set('userId', null) c.set('userRole', null) c.set('orgId', null) @@ -76,6 +95,15 @@ export const authMiddleware = createMiddleware(async (c, next) => { permissions: apiKey.permissions, authMethod: 'api-key', }) + c.set('authzContext', { + credential: 'api_key', + userId, + orgId, + fixedOrgId: orgId, + grantedScopes: new Set(permissionScopes(apiKey.permissions)), + actor: { type: 'api_key', ref: apiKey.id }, + state: { configId: apiKey.configId, enabled: true }, + }) c.set('userId', userId) c.set('userRole', null) c.set('orgId', orgId) @@ -100,9 +128,19 @@ export const authMiddleware = createMiddleware(async (c, next) => { orgId, authMethod: authHeader?.startsWith('Bearer ') ? 'bearer' : 'cookie', }) + c.set('authzContext', { + credential: 'session', + userId: result.user.id, + orgId, + fixedOrgId: null, + grantedScopes: null, + actor: { type: 'user', ref: result.user.id }, + state: { firstParty: true, role: result.user.role }, + }) } else { c.set('orgId', null) c.set('principal', null) + c.set('authzContext', anonymousAuthzContext()) } await next() diff --git a/server/middleware/authz.integration.test.ts b/server/middleware/authz.integration.test.ts index 9324016c..13ca25e9 100644 --- a/server/middleware/authz.integration.test.ts +++ b/server/middleware/authz.integration.test.ts @@ -1,7 +1,8 @@ +import { AuthorizationScope } from '@shared/authorization' import { sql } from 'drizzle-orm' import { describe, expect, it } from 'vitest' import { adminHeaders, authedHeaders, createTestApp } from '../test/setup.js' -import { requirePermission } from './authz.js' +import { evaluateAuthorization, requirePermission } from './authz.js' type TestCtx = Awaited> type TestApp = TestCtx['app'] @@ -13,11 +14,11 @@ type TestAuth = TestCtx['auth'] // userId, orgId, and deps from the request). Each route maps to one guard in // requirePermission; the body is a sentinel proving the middleware called next. function mountProbes(app: TestApp) { - app.get('/api/test-authz/api-perm', requirePermission('remoteDownload', 'create'), (c) => c.json({ ok: true })) - app.get('/api/test-authz/no-downloader', requirePermission('remoteDownload', 'read'), (c) => c.json({ ok: true })) + app.get('/api/test-authz/api-perm', requirePermission('download-tasks', 'create'), (c) => c.json({ ok: true })) + app.get('/api/test-authz/no-downloader', requirePermission('download-tasks', 'read'), (c) => c.json({ ok: true })) app.get( '/api/test-authz/team-editor', - requirePermission('remoteDownload', 'create', { minTeamRole: 'editor' }), + requirePermission('download-tasks', 'create', { minTeamRole: 'editor' }), (c) => c.json({ ok: true }), ) } @@ -124,9 +125,9 @@ describe('requirePermission middleware', () => { const userId = await getUserId(db, 'test@example.com') // Key authenticates (valid) but carries only `read`, not the `create` the // probe route demands, so the api-key branch denies with 403. - const key = await createApiKey(auth, orgId, userId, { remoteDownload: ['read'] }) + const key = await createApiKey(auth, orgId, userId, { 'download-tasks': ['read'] }) - const res = await app.request('/api/test-authz/api-perm', { + const res = await app.request('/api/test-authz/team-editor', { headers: { Authorization: `Bearer ${key}` }, }) expect(res.status).toBe(403) @@ -141,9 +142,9 @@ describe('requirePermission middleware', () => { await authedHeaders(app) const orgId = await getOrgId(db) const userId = await getUserId(db, 'test@example.com') - const key = await createApiKey(auth, orgId, userId, { remoteDownload: ['create'] }) + const key = await createApiKey(auth, orgId, userId, { 'download-tasks': ['create'] }) - const res = await app.request('/api/test-authz/api-perm', { + const res = await app.request('/api/test-authz/team-editor', { headers: { Authorization: `Bearer ${key}` }, }) expect(res.status).toBe(200) @@ -164,13 +165,13 @@ describe('requirePermission middleware', () => { INSERT INTO member (id, organization_id, user_id, role) VALUES (${`member-${teamOrgId}`}, ${teamOrgId}, ${userId}, 'editor') `) - const key = await createApiKey(auth, teamOrgId, userId, { remoteDownload: ['create'] }) + const key = await createApiKey(auth, teamOrgId, userId, { 'download-tasks': ['create'] }) await db.run(sql` UPDATE member SET role = 'viewer' WHERE organization_id = ${teamOrgId} AND user_id = ${userId} `) - const res = await app.request('/api/test-authz/api-perm', { + const res = await app.request('/api/test-authz/team-editor', { headers: { Authorization: `Bearer ${key}` }, }) expect(res.status).toBe(403) @@ -246,18 +247,37 @@ describe('requirePermission middleware', () => { await expect(res.json()).resolves.toEqual({ ok: true }) }) - it('allows a personal-org user without a member row via the isPersonalOrg fallback', async () => { + it('denies personal-looking orgs when findPersonalOrg does not prove ownership', async () => { const { app, db } = await createTestApp() mountProbes(app) const headers = await authedHeaders(app, 'personal@example.com') const orgId = await getOrgId(db) - // Drop the member row so getMemberRole returns null, forcing the - // isPersonalOrg branch (a personal org owner still has full access). + const userId = await getUserId(db, 'personal@example.com') await db.run(sql`DELETE FROM member WHERE organization_id = ${orgId}`) const res = await app.request('/api/test-authz/team-editor', { headers }) - expect(res.status).toBe(200) - await expect(res.json()).resolves.toEqual({ ok: true }) + expect(res.status).toBe(403) + + const otherPersonalOrgId = 'other-personal' + await db.run(sql` + INSERT INTO organization (id, name, slug, metadata) + VALUES (${otherPersonalOrgId}, 'Other Personal', ${otherPersonalOrgId}, '{"type":"personal"}') + `) + await db.run(sql` + INSERT INTO member (id, organization_id, user_id, role) + VALUES (${`member-${otherPersonalOrgId}`}, ${otherPersonalOrgId}, ${userId}, 'owner') + `) + const setActive = await app.request('/api/auth/organization/set-active', { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ organizationId: otherPersonalOrgId }), + }) + const cookies = setActive.headers.getSetCookie() + if (cookies.length > 0) headers.Cookie = cookies.map((c) => c.split(';')[0]).join('; ') + await db.run(sql`DELETE FROM member WHERE organization_id = ${otherPersonalOrgId}`) + + const denied = await app.request('/api/test-authz/team-editor', { headers }) + expect(denied.status).toBe(403) }) it('returns 403 for a team org with no member row that is not personal', async () => { @@ -290,4 +310,101 @@ describe('requirePermission middleware', () => { const body = (await res.json()) as { error: { message: string } } expect(body.error.message).toBe('Forbidden') }) + + it('records safe audit for authenticated 403 denials only', async () => { + const { app, db, auth } = await createTestApp() + mountProbes(app) + await authedHeaders(app) + const orgId = await getOrgId(db) + const userId = await getUserId(db, 'test@example.com') + const key = await createApiKey(auth, orgId, userId, { 'download-tasks': ['read'] }) + + expect((await app.request('/api/test-authz/api-perm')).status).toBe(401) + expect( + (await app.request('/api/test-authz/api-perm', { headers: { Authorization: `Bearer ${key}` } })).status, + ).toBe(403) + + const rows = await db.all<{ action: string; actorType: string; targetName: string; metadata: string }>(sql` + SELECT action, actor_type AS actorType, target_name AS targetName, metadata + FROM audit_events + WHERE action = 'authorization_denied' + `) + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ + action: 'authorization_denied', + actorType: 'api_key', + targetName: 'protected route', + }) + expect(JSON.parse(rows[0].metadata)).toEqual({ + credential: 'api_key', + method: 'GET', + reason: 'missing_scope', + }) + }) + + it('keeps the 403 response when denial audit recording fails', async () => { + const { app, db, auth, deps } = await createTestApp() + mountProbes(app) + await authedHeaders(app) + const orgId = await getOrgId(db) + const userId = await getUserId(db, 'test@example.com') + const key = await createApiKey(auth, orgId, userId, { 'download-tasks': ['read'] }) + deps.audit.record = async () => { + throw new Error('audit unavailable') + } + + const res = await app.request('/api/test-authz/api-perm', { + headers: { Authorization: `Bearer ${key}` }, + }) + + expect(res.status).toBe(403) + const body = (await res.json()) as { error: { message: string } } + expect(body.error.message).toBe('Forbidden') + }) +}) + +describe('evaluateAuthorization', () => { + it('lets sessions bypass declared scopes but keeps role policy', async () => { + const deps = { + getMemberRole: async () => 'viewer', + findPersonalOrg: async () => 'personal-org', + } + + await expect( + evaluateAuthorization({ + context: { + credential: 'session', + userId: 'user-1', + orgId: 'org-1', + fixedOrgId: null, + grantedScopes: null, + actor: { type: 'user', ref: 'user-1' }, + state: { firstParty: true }, + }, + declaration: { access: 'protected', scopes: [AuthorizationScope.DOWNLOAD_TASKS_CREATE], minTeamRole: 'editor' }, + deps, + }), + ).resolves.toMatchObject({ allowed: false, status: 403, reason: 'insufficient_role' }) + }) + + it('blocks fixed-workspace credentials from a different effective workspace', async () => { + await expect( + evaluateAuthorization({ + context: { + credential: 'api_key', + userId: 'user-1', + orgId: 'org-2', + fixedOrgId: 'org-1', + grantedScopes: new Set([AuthorizationScope.DOWNLOAD_TASKS_READ]), + actor: { type: 'api_key', ref: 'key-1' }, + state: { configId: 'remote-download', enabled: true }, + }, + declaration: { access: 'protected', scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ], minTeamRole: 'viewer' }, + deps: { + getMemberRole: async () => 'owner', + findPersonalOrg: async () => null, + }, + }), + ).resolves.toMatchObject({ allowed: false, status: 403, reason: 'workspace_mismatch' }) + }) }) diff --git a/server/middleware/authz.ts b/server/middleware/authz.ts index 68ecb766..8aab297d 100644 --- a/server/middleware/authz.ts +++ b/server/middleware/authz.ts @@ -1,6 +1,9 @@ +import { type AuthorizationScope, authorizationScope } from '@shared/authorization' +import type { Context } from 'hono' import { createMiddleware } from 'hono/factory' +import { recordAuditEffect } from '../lib/audit' import { forbidden, unauthorized } from '../usecases/ports' -import type { Env } from './platform' +import type { AuthzContext, Env } from './platform' const ROLE_LEVELS: Record = { owner: 3, @@ -9,51 +12,158 @@ const ROLE_LEVELS: Record = { member: 1, } -export function requirePermission( - resource: string, - action: string, - opts: { minTeamRole?: 'viewer' | 'editor' | 'owner'; allowDownloader?: boolean } = {}, -) { +export type TeamRole = 'viewer' | 'editor' | 'owner' + +export type RouteAuthorizationDeclaration = + | { access: 'public' } + | { access: 'internal' } + | { + access: 'protected' + scopes?: readonly AuthorizationScope[] + minTeamRole?: TeamRole + allowDownloader?: boolean + auditDenied?: boolean + } + +export type AuthzDenialReason = + | 'missing_credential' + | 'actor_not_allowed' + | 'missing_scope' + | 'workspace_required' + | 'workspace_mismatch' + | 'insufficient_role' + +export type AuthzDecision = + | { allowed: true; effectiveOrgId: string | null; reason: 'allowed' } + | { allowed: false; status: 401 | 403; reason: AuthzDenialReason; audit: boolean } + +type AuthzDeps = { + getMemberRole(orgId: string, userId: string): Promise + findPersonalOrg(userId: string): Promise +} + +export async function evaluateAuthorization(input: { + context: AuthzContext + declaration: RouteAuthorizationDeclaration + deps: AuthzDeps +}): Promise { + const { context, declaration, deps } = input + if (declaration.access === 'public') return { allowed: true, effectiveOrgId: context.orgId, reason: 'allowed' } + if (declaration.access === 'internal') return deny(context, 403, 'actor_not_allowed', declaration) + if (context.credential === 'anonymous') return deny(context, 401, 'missing_credential', declaration) + if (context.credential === 'downloader') { + return declaration.allowDownloader + ? { allowed: true, effectiveOrgId: null, reason: 'allowed' } + : deny(context, 401, 'actor_not_allowed', declaration) + } + + const requiredScopes = declaration.scopes ?? [] + if (context.grantedScopes) { + for (const scope of requiredScopes) { + if (!context.grantedScopes.has(scope)) return deny(context, 403, 'missing_scope', declaration) + } + } + + if (!declaration.minTeamRole) return { allowed: true, effectiveOrgId: context.orgId, reason: 'allowed' } + const userId = context.userId + if (!userId) return deny(context, 401, 'actor_not_allowed', declaration) + const orgId = context.fixedOrgId ?? context.orgId + if (!orgId) return deny(context, 401, 'workspace_required', declaration) + if (context.fixedOrgId && context.orgId && context.fixedOrgId !== context.orgId) { + return deny(context, 403, 'workspace_mismatch', declaration) + } + + const role = await deps.getMemberRole(orgId, userId) + if (role !== null) { + return (ROLE_LEVELS[role] ?? 0) >= ROLE_LEVELS[declaration.minTeamRole] + ? { allowed: true, effectiveOrgId: orgId, reason: 'allowed' } + : deny(context, 403, 'insufficient_role', declaration) + } + + const personalOrgId = await deps.findPersonalOrg(userId) + return personalOrgId === orgId + ? { allowed: true, effectiveOrgId: orgId, reason: 'allowed' } + : deny(context, 403, 'insufficient_role', declaration) +} + +export function authorize(declaration: RouteAuthorizationDeclaration) { return createMiddleware(async (c, next) => { - const principal = c.get('principal') - if (!principal) throw unauthorized('Unauthorized') - - if (principal.kind === 'downloader') { - if (opts.allowDownloader) return next() - throw unauthorized('Unauthorized') + const decision = await evaluateAuthorization({ + context: c.get('authzContext'), + declaration, + deps: { + getMemberRole: (orgId, userId) => c.get('deps').org.getMemberRole(orgId, userId), + findPersonalOrg: (userId) => c.get('deps').org.findPersonalOrg(userId), + }, + }) + if (decision.allowed) { + if (decision.effectiveOrgId) c.set('orgId', decision.effectiveOrgId) + await next() + return } - - if (principal.kind === 'download-task-upload') throw unauthorized('Unauthorized') - - if (principal.kind === 'api-key') { - if (!c.get('deps').apiKeys.hasApiKeyPermission(principal.permissions, resource, action)) { - throw forbidden('Forbidden') - } - if (principal.scope.mode === 'workspace') { - const role = await c.get('deps').org.getMemberRole(principal.scope.orgId, principal.userId) - const minRole = opts.minTeamRole ?? 'editor' - if (role !== null) { - if ((ROLE_LEVELS[role] ?? 0) < ROLE_LEVELS[minRole]) throw forbidden('Forbidden') - return next() - } - throw forbidden('Forbidden') - } - return next() + if (decision.audit) { + await recordAuditEffect('authorization_denied', () => recordDenialAudit(c, declaration, decision.reason)) } - - const userId = c.get('userId') - if (!userId) throw unauthorized('Unauthorized') - if (!opts.minTeamRole) return next() - - const orgId = c.get('orgId') - if (!orgId) throw unauthorized('Unauthorized') - - const role = await c.get('deps').org.getMemberRole(orgId, userId) - if (role !== null) { - if ((ROLE_LEVELS[role] ?? 0) < ROLE_LEVELS[opts.minTeamRole]) throw forbidden('Forbidden') - return next() - } - if (await c.get('deps').org.isPersonalOrg(orgId)) return next() + if (decision.status === 401) throw unauthorized('Unauthorized') throw forbidden('Forbidden') }) } + +export function requirePermission( + resource: string, + action: string, + opts: { minTeamRole?: TeamRole; allowDownloader?: boolean } = {}, +) { + const scope = authorizationScope(resource, action) + if (!scope) throw new Error(`Unknown authorization scope: ${resource}:${action}`) + return authorize({ + access: 'protected', + scopes: [scope], + minTeamRole: opts.minTeamRole, + allowDownloader: opts.allowDownloader, + auditDenied: true, + }) +} + +function deny( + context: AuthzContext, + status: 401 | 403, + reason: AuthzDenialReason, + declaration: RouteAuthorizationDeclaration, +): AuthzDecision { + return { + allowed: false, + status, + reason, + audit: status === 403 && context.credential !== 'anonymous' && shouldAudit(declaration), + } +} + +function shouldAudit(declaration: RouteAuthorizationDeclaration): boolean { + return declaration.access === 'protected' && declaration.auditDenied !== false +} + +async function recordDenialAudit( + c: Context, + _declaration: RouteAuthorizationDeclaration, + reason: AuthzDenialReason, +) { + const context = c.get('authzContext') + if (!context.actor) return + const orgId = context.fixedOrgId ?? context.orgId ?? c.get('orgId') + if (!orgId) return + await c.get('deps').audit.record({ + orgId, + userId: context.userId, + actorType: context.actor.type, + actorRef: context.actor.ref, + action: 'authorization_denied', + targetType: 'route', + targetName: 'protected route', + metadata: { + method: c.req.method.toUpperCase(), + credential: context.credential, + reason, + }, + }) +} diff --git a/server/middleware/platform.ts b/server/middleware/platform.ts index 8059be22..b92b7bb3 100644 --- a/server/middleware/platform.ts +++ b/server/middleware/platform.ts @@ -1,3 +1,4 @@ +import type { AuthorizationScope } from '@shared/authorization' import { createMiddleware } from 'hono/factory' import type { Auth } from '../auth' import type { DavLock } from '../domain/webdav' @@ -13,6 +14,7 @@ export type Env = { auth: Auth deps: Deps principal: AuthPrincipal | null + authzContext: AuthzContext userId: string | null userRole: string | null orgId: string | null @@ -66,11 +68,60 @@ export type AuthPrincipal = authMethod: 'bearer' } +export type AuthzContext = + | { credential: 'anonymous'; userId: null; orgId: null; fixedOrgId: null; grantedScopes: null; actor: null } + | { + credential: 'session' + userId: string + orgId: string | null + fixedOrgId: null + grantedScopes: null + actor: { type: 'user'; ref: string } + state: { firstParty: true; role?: string } + } + | { + credential: 'api_key' + userId: string + orgId: string | null + fixedOrgId: string | null + grantedScopes: ReadonlySet + actor: { type: 'api_key'; ref: string } + state: { configId: string; enabled: true } + } + | { + credential: 'downloader' + userId: null + orgId: null + fixedOrgId: null + grantedScopes: ReadonlySet + actor: { type: 'downloader'; ref: string } + state: Record + } + | { + credential: 'download-task-upload' + userId: string + orgId: string + fixedOrgId: string + grantedScopes: ReadonlySet + actor: { type: 'task-upload'; ref: string } + state: { downloaderId: string; taskId: string } + } + +export const anonymousAuthzContext = (): AuthzContext => ({ + credential: 'anonymous', + userId: null, + orgId: null, + fixedOrgId: null, + grantedScopes: null, + actor: null, +}) + export const platformMiddleware = (platform: Platform, auth: Auth) => createMiddleware(async (c, next) => { c.set('platform', platform) c.set('auth', auth) c.set('principal', null) + c.set('authzContext', anonymousAuthzContext()) c.set('errorLog', null) c.set('sitePublicOrigin', null) c.set('webDavEnabled', false) diff --git a/server/openapi.test.ts b/server/openapi.test.ts index 9c4d8a2d..62a5df74 100644 --- a/server/openapi.test.ts +++ b/server/openapi.test.ts @@ -1,4 +1,6 @@ +import { AuthorizationScope } from '@shared/authorization' import { describe, expect, it } from 'vitest' +import { authRoute, findOperationsMissingAuthContract } from './http/openapi' import { createTestApp } from './test/setup' describe('global OpenAPI document', () => { @@ -55,14 +57,84 @@ describe('global OpenAPI document', () => { const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) const res = await app.request('/api/openapi.json') const doc = (await res.json()) as { - paths: Record } }> + paths: Record< + string, + { + get?: { + description?: string + responses?: Record + 'x-zpan-auth'?: unknown + } + } + > } const events = doc.paths['/api/events']?.get expect(events?.responses?.['403']?.description).toBe('Forbidden') expect(events?.description).toContain('Workspace-scoped API keys') - expect(events?.description).toContain('remoteDownload:read') + expect(events?.description).toContain('download-tasks:read') expect(events?.description).toContain('resource-change') + expect(events?.['x-zpan-auth']).toMatchObject({ + access: 'protected', + scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ], + }) + }) + + it('emits explicit authorization metadata for routes migrated to authRoute', async () => { + const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) + const res = await app.request('/api/openapi.json') + const doc = (await res.json()) as { paths: Record> } + const migratedPaths = { + '/api/events': doc.paths['/api/events'], + '/api/downloads/tasks': doc.paths['/api/downloads/tasks'], + '/api/downloads/tasks/{id}': doc.paths['/api/downloads/tasks/{id}'], + '/api/downloads/tasks/{id}/events': doc.paths['/api/downloads/tasks/{id}/events'], + '/api/downloads/tasks/{id}/status': doc.paths['/api/downloads/tasks/{id}/status'], + '/api/downloads/tasks/{id}/attempts': doc.paths['/api/downloads/tasks/{id}/attempts'], + '/api/downloads/downloaders/me/tasks': doc.paths['/api/downloads/downloaders/me/tasks'], + } + + expect(findOperationsMissingAuthContract(migratedPaths)).toEqual([]) + }) + + it('emits OpenAPI authorization metadata from one route declaration helper', () => { + const route = authRoute( + { + access: 'protected', + scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ], + minTeamRole: 'viewer', + }, + { + operationId: 'authzProbe', + method: 'get', + path: '/probe', + responses: { 200: { description: 'OK' } }, + }, + ) as { + security?: unknown + 'x-zpan-auth'?: unknown + middleware?: unknown[] + } + + expect(route.security).toEqual([{ bearerAuth: [AuthorizationScope.DOWNLOAD_TASKS_READ] }, { cookieAuth: [] }]) + expect(route['x-zpan-auth']).toEqual({ + access: 'protected', + scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ], + minTeamRole: 'viewer', + allowDownloader: false, + auditDenied: true, + }) + expect(route.middleware).toHaveLength(1) + }) + + it('detects OpenAPI operations missing explicit authorization declarations without an allowlist', () => { + expect( + findOperationsMissingAuthContract({ + '/public': { get: { 'x-zpan-auth': { access: 'public' } } }, + '/protected': { post: { 'x-zpan-auth': { access: 'protected' } } }, + '/missing': { delete: { responses: { 204: { description: 'Deleted' } } } }, + }), + ).toEqual(['DELETE /missing']) }) it('documents the concrete public profile contract without the removed objects placeholder', async () => { diff --git a/server/scripts/backfill-api-key-scopes.test.ts b/server/scripts/backfill-api-key-scopes.test.ts new file mode 100644 index 00000000..35cfea0c --- /dev/null +++ b/server/scripts/backfill-api-key-scopes.test.ts @@ -0,0 +1,277 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import Database from 'better-sqlite3' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + apiKeyScopeBackfillD1Args, + applyApiKeyScopeBackfill, + backfillApiKeyScopePermissionsRows, + listApiKeyScopeBackfillRows, + parseApiKeyScopeBackfillOptions, + runApiKeyScopeBackfill, + sqlString, +} from '../../scripts/backfill-api-key-scopes' + +describe('API key scope backfill', () => { + afterEach(() => { + vi.restoreAllMocks() + vi.resetModules() + vi.doUnmock('node:child_process') + }) + + async function importWithChildProcessMock( + execFileSync: (command: string, args: string[], options: { encoding: string; stdio: 'pipe' | 'inherit' }) => string, + ) { + vi.doMock('node:child_process', async (importOriginal) => { + const actual = await importOriginal() + const execFileSyncMock = vi.fn(execFileSync) + return { + ...actual, + execFileSync: execFileSyncMock, + default: { + ...actual, + execFileSync: execFileSyncMock, + }, + } + }) + return import('../../scripts/backfill-api-key-scopes') + } + + it('converts legacy permissions and can be rerun', () => { + const db = new Database(':memory:') + db.exec(` + CREATE TABLE apikey ( + id TEXT PRIMARY KEY, + permissions TEXT + ); + INSERT INTO apikey (id, permissions) VALUES + ('ihost', '{"ihost":["upload"]}'), + ('webdav', '{"webdav":["read","write"]}'), + ('remote', '{"remoteDownload":["read","create","cancel"]}'), + ('canonical', '{"download-tasks":["read"]}'), + ('empty', NULL); + `) + + const rows = db.prepare('SELECT id, permissions FROM apikey ORDER BY id').all() as Array<{ + id: string + permissions: string | null + }> + const changes = backfillApiKeyScopePermissionsRows(rows) + const update = db.prepare('UPDATE apikey SET permissions = ? WHERE id = ?') + for (const change of changes) update.run(change.after, change.id) + + expect( + backfillApiKeyScopePermissionsRows(db.prepare('SELECT id, permissions FROM apikey').all() as typeof rows), + ).toEqual([]) + expect(db.prepare('SELECT permissions FROM apikey WHERE id = ?').get('ihost')).toEqual({ + permissions: '{"images":["upload"]}', + }) + expect(db.prepare('SELECT permissions FROM apikey WHERE id = ?').get('webdav')).toEqual({ + permissions: '{"objects":["create","delete","move","read","update"]}', + }) + expect(db.prepare('SELECT permissions FROM apikey WHERE id = ?').get('remote')).toEqual({ + permissions: '{"download-tasks":["cancel","create","read"]}', + }) + expect(db.prepare('SELECT permissions FROM apikey WHERE id = ?').get('canonical')).toEqual({ + permissions: '{"download-tasks":["read"]}', + }) + db.close() + }) + + it('ignores invalid and unchanged permission payloads', () => { + expect( + backfillApiKeyScopePermissionsRows([ + { id: 'null', permissions: null }, + { id: 'empty', permissions: '' }, + { id: 'invalid-json', permissions: '{' }, + { id: 'array-json', permissions: '["webdav"]' }, + { id: 'non-array-actions', permissions: '{"webdav":"write"}' }, + { id: 'non-string-action', permissions: '{"webdav":["read",1]}' }, + { id: 'duplicate-canonical', permissions: '{"objects":["read","read"]}' }, + ]), + ).toEqual([ + { + id: 'non-array-actions', + before: '{"webdav":"write"}', + after: '{}', + }, + { + id: 'non-string-action', + before: '{"webdav":["read",1]}', + after: '{"objects":["read"]}', + }, + ]) + }) + + it('parses sqlite and d1 options', () => { + expect(parseApiKeyScopeBackfillOptions(['--sqlite', '/tmp/zpan.db'])).toEqual({ + apply: false, + target: { kind: 'sqlite', path: '/tmp/zpan.db' }, + }) + expect(parseApiKeyScopeBackfillOptions(['--apply', '--d1', 'zpan-db', '--remote', '--env', 'staging'])).toEqual({ + apply: true, + target: { kind: 'd1', database: 'zpan-db', remote: true, env: 'staging' }, + }) + }) + + it('rejects invalid option combinations', () => { + expect(() => parseApiKeyScopeBackfillOptions([])).toThrow( + 'Usage: pnpm api-key-scopes:backfill -- (--sqlite | --d1 [--remote] [--env ]) [--apply]', + ) + expect(() => parseApiKeyScopeBackfillOptions(['--sqlite', '/tmp/zpan.db', '--d1', 'zpan-db'])).toThrow( + 'Usage: pnpm api-key-scopes:backfill -- (--sqlite | --d1 [--remote] [--env ]) [--apply]', + ) + expect(() => parseApiKeyScopeBackfillOptions(['--sqlite'])).toThrow( + 'Usage: pnpm api-key-scopes:backfill -- (--sqlite | --d1 [--remote] [--env ]) [--apply]', + ) + expect(() => parseApiKeyScopeBackfillOptions(['--d1'])).toThrow( + 'Usage: pnpm api-key-scopes:backfill -- (--sqlite | --d1 [--remote] [--env ]) [--apply]', + ) + }) + + it('builds d1 arguments and escapes sql strings', () => { + expect(apiKeyScopeBackfillD1Args({ kind: 'd1', database: 'zpan-db', remote: true, env: 'prod' })).toEqual([ + 'exec', + 'wrangler', + 'd1', + 'execute', + 'zpan-db', + '--remote', + '--env', + 'prod', + ]) + expect(apiKeyScopeBackfillD1Args({ kind: 'd1', database: 'zpan-db', remote: false })).toEqual([ + 'exec', + 'wrangler', + 'd1', + 'execute', + 'zpan-db', + '--local', + ]) + expect(sqlString("key ' one")).toBe("'key '' one'") + }) + + it('lists and applies sqlite changes', () => { + const dir = mkdtempSync(join(tmpdir(), 'zpan-api-key-backfill-')) + + try { + const path = join(dir, 'db.sqlite') + const db = new Database(path) + db.exec(` + CREATE TABLE apikey ( + id TEXT PRIMARY KEY, + permissions TEXT + ); + INSERT INTO apikey (id, permissions) VALUES + ('legacy', '{"webdav":["write"]}'), + ('canonical', '{"objects":["read"]}'); + `) + db.close() + + const target = { kind: 'sqlite' as const, path } + const changes = backfillApiKeyScopePermissionsRows(listApiKeyScopeBackfillRows(target)) + + expect(changes).toEqual([ + { + id: 'legacy', + before: '{"webdav":["write"]}', + after: '{"objects":["create","delete","move","update"]}', + }, + ]) + + applyApiKeyScopeBackfill(target, changes) + + expect(backfillApiKeyScopePermissionsRows(listApiKeyScopeBackfillRows(target))).toEqual([]) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('lists d1 rows from wrangler JSON output', () => { + return importWithChildProcessMock((command, args, options) => { + expect(command).toBe('pnpm') + expect(args).toEqual([ + 'exec', + 'wrangler', + 'd1', + 'execute', + 'zpan-db', + '--remote', + '--env', + 'staging', + '--command', + 'SELECT id, permissions FROM apikey;', + '--json', + ]) + expect(options).toEqual({ encoding: 'utf8', stdio: 'pipe' }) + return JSON.stringify([ + { results: [{ id: 'legacy', permissions: '{"ihost":["upload"]}' }] }, + { results: [{ id: 'canonical', permissions: '{"images":["upload"]}' }] }, + ]) + }).then((mockedModule) => { + expect( + mockedModule.listApiKeyScopeBackfillRows({ kind: 'd1', database: 'zpan-db', remote: true, env: 'staging' }), + ).toEqual([ + { id: 'legacy', permissions: '{"ihost":["upload"]}' }, + { id: 'canonical', permissions: '{"images":["upload"]}' }, + ]) + }) + }) + + it('runs dry-run and apply modes against sqlite', () => { + const dir = mkdtempSync(join(tmpdir(), 'zpan-api-key-backfill-run-')) + + try { + const path = join(dir, 'db.sqlite') + const db = new Database(path) + db.exec(` + CREATE TABLE apikey ( + id TEXT PRIMARY KEY, + permissions TEXT + ); + INSERT INTO apikey (id, permissions) VALUES + ('legacy', '{"ihost":["upload"]}'); + `) + db.close() + + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined) + const target = { kind: 'sqlite' as const, path } + + runApiKeyScopeBackfill({ apply: false, target }) + expect(log).toHaveBeenLastCalledWith(JSON.stringify({ mode: 'dry-run', changed: 1 }, null, 2)) + expect(backfillApiKeyScopePermissionsRows(listApiKeyScopeBackfillRows(target))).toHaveLength(1) + + runApiKeyScopeBackfill({ apply: true, target }) + expect(log).toHaveBeenNthCalledWith(2, JSON.stringify({ mode: 'apply', changed: 1 }, null, 2)) + expect(log).toHaveBeenNthCalledWith(3, JSON.stringify({ mode: 'complete', changed: 1 }, null, 2)) + expect(backfillApiKeyScopePermissionsRows(listApiKeyScopeBackfillRows(target))).toEqual([]) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('fails apply mode when d1 rows still need backfill after update', () => { + return importWithChildProcessMock((_, args, options) => { + const sql = args[args.indexOf('--command') + 1] + + if (sql.startsWith('SELECT')) { + expect(options).toEqual({ encoding: 'utf8', stdio: 'pipe' }) + return JSON.stringify([{ results: [{ id: 'legacy', permissions: '{"ihost":["upload"]}' }] }]) + } + + expect(options).toEqual({ encoding: 'utf8', stdio: 'inherit' }) + return '' + }).then((mockedModule) => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined) + + expect(() => + mockedModule.runApiKeyScopeBackfill({ + apply: true, + target: { kind: 'd1', database: 'zpan-db', remote: false }, + }), + ).toThrow('api_key_scope_backfill_failed:1') + expect(log).toHaveBeenCalledWith(JSON.stringify({ mode: 'apply', changed: 1 }, null, 2)) + }) + }) +}) diff --git a/server/usecases/ports/api-keys.ts b/server/usecases/ports/api-keys.ts index af21500d..63d7d39c 100644 --- a/server/usecases/ports/api-keys.ts +++ b/server/usecases/ports/api-keys.ts @@ -1,4 +1,5 @@ -import type { ApiKeyPermissions, ApiKeyScope } from '@shared/api-key-templates' +import type { ApiKeyScope } from '@shared/api-key-templates' +import type { ApiKeyPermissions, AuthorizationScope } from '@shared/authorization' import type { Database } from '../../platform/interface' export interface VerifiedApiKey { @@ -38,4 +39,5 @@ export interface ApiKeyGateway { configId?: string, ): Promise hasApiKeyPermission(permissions: ApiKeyPermissions | null | undefined, resource: string, action: string): boolean + hasApiKeyScope(permissions: ApiKeyPermissions | null | undefined, scope: AuthorizationScope): boolean } diff --git a/server/usecases/ports/audit.ts b/server/usecases/ports/audit.ts index f842e5a5..1d4b5902 100644 --- a/server/usecases/ports/audit.ts +++ b/server/usecases/ports/audit.ts @@ -1,6 +1,14 @@ // Plain, framework-free DTOs and the repository port for audit events. -export type AuditActorType = 'user' | 'api_key' | 'anonymous' | 'system' | 'downloader' +export type AuditActorType = + | 'user' + | 'api_key' + | 'agent_oauth' + | 'agent' + | 'anonymous' + | 'system' + | 'downloader' + | 'task-upload' export interface RecordAuditEventInput { orgId: string diff --git a/server/usecases/webdav.test.ts b/server/usecases/webdav.test.ts index 129e0397..82068649 100644 --- a/server/usecases/webdav.test.ts +++ b/server/usecases/webdav.test.ts @@ -209,8 +209,7 @@ const authParams = { db: {} as Database, username: 'user@example.com', password: 'secret', - resource: 'webdav', - action: 'read' as const, + requiredScopes: [{ resource: 'objects', action: 'read' }], configId: 'webdav', } @@ -233,7 +232,7 @@ describe('webdav usecase', () => { }) const out = await resolveWebDavAuth(deps, authParams) expect(out).toEqual({ ok: true, userId: 'u9', keyId: 'k1', configId: 'webdav', permissions: null }) - expect(verifyApiKeyForPermission).toHaveBeenCalledWith({}, {}, 'secret', 'webdav', 'read', 'webdav') + expect(verifyApiKeyForPermission).toHaveBeenCalledWith({}, {}, 'secret', 'objects', 'read', 'webdav') expect(findActiveUserIdByUsername).toHaveBeenCalledWith('user@example.com') }) diff --git a/server/usecases/webdav.ts b/server/usecases/webdav.ts index 3944c33f..4a8ca93b 100644 --- a/server/usecases/webdav.ts +++ b/server/usecases/webdav.ts @@ -55,19 +55,20 @@ export async function resolveWebDavAuth( db: Database username: string password: string - resource: string - action: 'read' | 'write' + requiredScopes: Array<{ resource: string; action: string }> configId: string }, ): Promise { + const [firstScope, ...additionalScopes] = params.requiredScopes + if (!firstScope) return { ok: false, reason: 'unauthorized' } try { const [key, activeUserId] = await Promise.all([ deps.apiKeys.verifyApiKeyForPermission( params.auth, params.db, params.password, - params.resource, - params.action, + firstScope.resource, + firstScope.action, params.configId, ), deps.userAdmin.findActiveUserIdByUsername(params.username), @@ -75,6 +76,11 @@ export async function resolveWebDavAuth( if (!key || activeUserId !== key.referenceId) { return { ok: false, reason: 'unauthorized' } } + if ( + additionalScopes.some((scope) => !deps.apiKeys.hasApiKeyPermission(key.permissions, scope.resource, scope.action)) + ) { + return { ok: false, reason: 'unauthorized' } + } return { ok: true, userId: key.referenceId, diff --git a/shared/api-key-templates.ts b/shared/api-key-templates.ts index 922e83b8..bc5cc5f8 100644 --- a/shared/api-key-templates.ts +++ b/shared/api-key-templates.ts @@ -1,3 +1,7 @@ +import { type ApiKeyPermissions, AuthorizationScope, scopePermissions } from './authorization' + +export type { ApiKeyPermissions } from './authorization' + export const ApiKeyTemplate = { IHOST: 'ihost', WEBDAV: 'webdav', @@ -6,8 +10,6 @@ export const ApiKeyTemplate = { export type ApiKeyTemplate = (typeof ApiKeyTemplate)[keyof typeof ApiKeyTemplate] -export type ApiKeyPermissions = Record - export type ApiKeyScope = | { mode: 'user-workspaces' } | { @@ -39,10 +41,20 @@ export const WEBDAV_API_KEY_RATE_LIMIT_WINDOW_MS = 60_000 export const WEBDAV_API_KEY_RATE_LIMIT_MAX_REQUESTS = 3600 export const WEBDAV_RATE_LIMITER_BINDING = 'WEBDAV_RATE_LIMITER' -export const IHOST_API_KEY_PERMISSIONS = { ihost: ['upload'] } satisfies ApiKeyPermissions -export const WEBDAV_API_KEY_PERMISSIONS = { webdav: ['read', 'write'] } satisfies ApiKeyPermissions +export const IHOST_API_KEY_PERMISSIONS = scopePermissions([AuthorizationScope.IMAGES_UPLOAD]) +export const WEBDAV_API_KEY_PERMISSIONS = scopePermissions([ + AuthorizationScope.OBJECTS_READ, + AuthorizationScope.OBJECTS_CREATE, + AuthorizationScope.OBJECTS_UPDATE, + AuthorizationScope.OBJECTS_DELETE, + AuthorizationScope.OBJECTS_MOVE, +]) export const REMOTE_DOWNLOAD_API_KEY_PERMISSIONS = { - remoteDownload: ['read', 'create', 'cancel'], + ...scopePermissions([ + AuthorizationScope.DOWNLOAD_TASKS_READ, + AuthorizationScope.DOWNLOAD_TASKS_CREATE, + AuthorizationScope.DOWNLOAD_TASKS_CANCEL, + ]), } satisfies ApiKeyPermissions export const API_KEY_TEMPLATE_PERMISSIONS = { diff --git a/shared/authorization.test.ts b/shared/authorization.test.ts new file mode 100644 index 00000000..024b99ca --- /dev/null +++ b/shared/authorization.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_GRANTABLE_AUTHORIZATION_SCOPES, + AuthorizationScope, + authorizationScope, + CANONICAL_AUTHORIZATION_SCOPES, + scopePermissions, +} from './authorization' + +describe('authorization scope registry', () => { + it('uses lowercase resource:action scopes without wildcard semantics', () => { + for (const scope of CANONICAL_AUTHORIZATION_SCOPES) { + expect(scope).toMatch(/^[a-z][a-z-]*s?:[a-z][a-z-]*$/) + expect(scope).not.toContain('*') + expect(scope).not.toContain('zpan') + } + expect(authorizationScope('download-tasks', 'read')).toBe(AuthorizationScope.DOWNLOAD_TASKS_READ) + expect(authorizationScope('remoteDownload', 'read')).toBeNull() + expect(authorizationScope('objects', '*')).toBeNull() + }) + + it('keeps permanent object purge out of agent-grantable scopes', () => { + expect(CANONICAL_AUTHORIZATION_SCOPES).toContain(AuthorizationScope.OBJECTS_PURGE) + expect(AGENT_GRANTABLE_AUTHORIZATION_SCOPES).not.toContain(AuthorizationScope.OBJECTS_PURGE) + expect(scopePermissions([AuthorizationScope.OBJECTS_DELETE])).toEqual({ objects: ['delete'] }) + }) +}) diff --git a/shared/authorization.ts b/shared/authorization.ts new file mode 100644 index 00000000..ec2436ed --- /dev/null +++ b/shared/authorization.ts @@ -0,0 +1,71 @@ +export const AuthorizationScope = { + OBJECTS_READ: 'objects:read', + OBJECTS_CREATE: 'objects:create', + OBJECTS_UPDATE: 'objects:update', + OBJECTS_DELETE: 'objects:delete', + OBJECTS_MOVE: 'objects:move', + OBJECTS_PURGE: 'objects:purge', + SHARES_READ: 'shares:read', + SHARES_WRITE: 'shares:write', + IMAGES_UPLOAD: 'images:upload', + DOWNLOAD_TASKS_READ: 'download-tasks:read', + DOWNLOAD_TASKS_CREATE: 'download-tasks:create', + DOWNLOAD_TASKS_CANCEL: 'download-tasks:cancel', +} as const + +export type AuthorizationScope = (typeof AuthorizationScope)[keyof typeof AuthorizationScope] + +export const CANONICAL_AUTHORIZATION_SCOPES = Object.values(AuthorizationScope) + +export const AGENT_GRANTABLE_AUTHORIZATION_SCOPES = CANONICAL_AUTHORIZATION_SCOPES.filter( + (scope) => scope !== AuthorizationScope.OBJECTS_PURGE, +) + +const AUTHORIZATION_SCOPE_SET = new Set(CANONICAL_AUTHORIZATION_SCOPES) + +export type ApiKeyPermissions = Record + +export function isAuthorizationScope(value: string): value is AuthorizationScope { + return AUTHORIZATION_SCOPE_SET.has(value) +} + +export function authorizationScope(resource: string, action: string): AuthorizationScope | null { + const value = `${resource}:${action}` + return isAuthorizationScope(value) ? value : null +} + +export function scopePermissions(scopes: readonly AuthorizationScope[]): ApiKeyPermissions { + const permissions: ApiKeyPermissions = {} + for (const scope of scopes) { + const [resource, action] = splitAuthorizationScope(scope) + permissions[resource] = [...(permissions[resource] ?? []), action] + } + return permissions +} + +export function permissionScopes(permissions: ApiKeyPermissions | null | undefined): AuthorizationScope[] { + if (!permissions) return [] + const scopes: AuthorizationScope[] = [] + for (const [resource, actions] of Object.entries(permissions)) { + if (!Array.isArray(actions)) continue + for (const action of actions) { + if (typeof action !== 'string') continue + const scope = authorizationScope(resource, action) + if (scope) scopes.push(scope) + } + } + return scopes +} + +export function hasAuthorizationScope( + permissions: ApiKeyPermissions | null | undefined, + scope: AuthorizationScope, +): boolean { + const [resource, action] = splitAuthorizationScope(scope) + return permissions?.[resource]?.includes(action) ?? false +} + +function splitAuthorizationScope(scope: AuthorizationScope): [string, string] { + const index = scope.indexOf(':') + return [scope.slice(0, index), scope.slice(index + 1)] +} diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index 2e2328b6..3fb9085f 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -3113,7 +3113,7 @@ describe('api', () => { prefix: null, createdAt: '2024-01-01T00:00:00.000Z', lastRequest: null, - permissions: { ihost: ['upload'] }, + permissions: { images: ['upload'] }, metadata: { scope: { mode: 'workspace', orgId: 'org-1' } }, referenceId: 'user-1', enabled: true, @@ -3154,7 +3154,7 @@ describe('api', () => { prefix: null, createdAt: '2024-01-01T00:00:00.000Z', lastRequest: null, - permissions: { ihost: ['upload'] }, + permissions: { images: ['upload'] }, referenceId: 'org-1', enabled: true, } @@ -3244,7 +3244,7 @@ describe('api', () => { prefix: null, createdAt: '2024-01-01T00:00:00.000Z', lastRequest: null, - permissions: { webdav: ['read', 'write'] }, + permissions: { objects: ['read', 'create', 'update', 'delete', 'move'] }, metadata: { scope: { mode: 'user-workspaces' } }, referenceId: 'user-1', enabled: true, @@ -3285,7 +3285,7 @@ describe('api', () => { prefix: null, createdAt: '2024-01-01T00:00:00.000Z', lastRequest: null, - permissions: { remoteDownload: ['read', 'create', 'cancel'] }, + permissions: { 'download-tasks': ['read', 'create', 'cancel'] }, metadata: { scope: { mode: 'workspace', orgId: 'org-1' } }, referenceId: 'user-1', enabled: true,