From 4817afecdbfb15101bb86dcdd8d9debb5c472fdc Mon Sep 17 00:00:00 2001 From: "agent-kanban[bot]" <295243365+agent-kanban[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:11:08 -0400 Subject: [PATCH] feat: migrate content APIs to unified authorization (#534) Agent-Profile: https://agent-kanban.dev/agents/b0abe6cd7aeba133 Co-authored-by: Noah Reed --- scripts/backfill-api-key-scopes.ts | 2 +- .../adapters/repos/share.integration.test.ts | 65 +++++ server/adapters/repos/share.test.ts | 82 ++++++ server/adapters/repos/share.ts | 12 +- server/http/objects.integration.test.ts | 182 ++++++++++++- server/http/objects.ts | 249 ++++++++++-------- server/http/quotas.integration.test.ts | 32 ++- server/http/quotas.ts | 10 +- server/http/shares.integration.test.ts | 131 +++++++++ server/http/shares.ts | 47 +++- server/http/storage-usage.integration.test.ts | 43 +++ server/http/storage-usage.ts | 16 +- server/http/trash.ts | 30 ++- server/http/webdav.ts | 4 +- .../scripts/backfill-api-key-scopes.test.ts | 4 +- server/usecases/object.test.ts | 16 ++ server/usecases/object.ts | 2 + server/usecases/ports/share.ts | 4 +- server/usecases/share.test.ts | 53 +++- server/usecases/share.ts | 12 +- shared/api-key-templates.ts | 4 +- shared/authorization.test.ts | 5 + shared/authorization.ts | 6 +- spec/quotas.feature | 10 +- 24 files changed, 836 insertions(+), 185 deletions(-) create mode 100644 server/adapters/repos/share.test.ts diff --git a/scripts/backfill-api-key-scopes.ts b/scripts/backfill-api-key-scopes.ts index 34c4d8a0..0c6dbc1f 100644 --- a/scripts/backfill-api-key-scopes.ts +++ b/scripts/backfill-api-key-scopes.ts @@ -30,7 +30,7 @@ const LEGACY_SCOPE_MAP: Record> = { }, webdav: { read: ['objects:read'], - write: ['objects:create', 'objects:update', 'objects:delete', 'objects:move'], + write: ['objects:create', 'objects:update', 'objects:delete'], }, remoteDownload: { read: ['download-tasks:read'], diff --git a/server/adapters/repos/share.integration.test.ts b/server/adapters/repos/share.integration.test.ts index c03612c3..cccea13c 100644 --- a/server/adapters/repos/share.integration.test.ts +++ b/server/adapters/repos/share.integration.test.ts @@ -21,6 +21,17 @@ const revokeShareByToken = (db: Database, token: string, creatorId: string) => createShareRepo(db).revokeByToken(token, creatorId) const listShareRecipientUserIds = (db: Database, shareId: string) => createShareRepo(db).listRecipientUserIds(shareId) const revokeByMatter = (db: Database, matterId: string) => createShareRepo(db).revokeByMatter(matterId) +const listSharesForApi = ( + db: Database, + creatorId: string, + opts: Parameters['listForApi']>[1], +) => createShareRepo(db).listForApi(creatorId, opts) +const listReceivedSharesForApi = ( + db: Database, + userId: string, + userEmail: string | null, + opts: Parameters['listReceivedForApi']>[2], +) => createShareRepo(db).listReceivedForApi(userId, userEmail, opts) const verifyPassword = (share: { passwordHash: string | null }, plaintext: string): boolean => share.passwordHash ? verifyPasswordHash(share.passwordHash, plaintext) : false @@ -305,6 +316,60 @@ describe('isAccessibleByUser', () => { }) }) +// ─── listForApi ────────────────────────────────────────────────────────────── + +describe('listForApi', () => { + it('filters creator shares to the requested org', async () => { + const { db } = await createTestApp() + const creatorId = 'creator-list-filter' + const orgA = `org-${nanoid()}` + const orgB = `org-${nanoid()}` + const matterA = await seedMatter(db, { orgId: orgA }) + const matterB = await seedMatter(db, { orgId: orgB }) + const shareA = await createShare(db, { matterId: matterA.id, orgId: orgA, creatorId, kind: 'landing' }) + const shareB = await createShare(db, { matterId: matterB.id, orgId: orgB, creatorId, kind: 'landing' }) + + const filtered = await listSharesForApi(db, creatorId, { pageSize: 10, orgId: orgA }) + const unfiltered = await listSharesForApi(db, creatorId, { pageSize: 10 }) + + expect(filtered.items.map((item) => item.id)).toEqual([shareA.id]) + expect(unfiltered.items.map((item) => item.id).sort()).toEqual([shareA.id, shareB.id].sort()) + }) +}) + +// ─── listReceivedForApi ────────────────────────────────────────────────────── + +describe('listReceivedForApi', () => { + it('filters received shares to the requested org', async () => { + const { db } = await createTestApp() + const recipientId = 'recipient-list-filter' + const orgA = `org-${nanoid()}` + const orgB = `org-${nanoid()}` + const matterA = await seedMatter(db, { orgId: orgA }) + const matterB = await seedMatter(db, { orgId: orgB }) + const shareA = await createShare(db, { + matterId: matterA.id, + orgId: orgA, + creatorId: 'creator-a', + kind: 'landing', + recipients: [{ recipientUserId: recipientId }], + }) + const shareB = await createShare(db, { + matterId: matterB.id, + orgId: orgB, + creatorId: 'creator-b', + kind: 'landing', + recipients: [{ recipientUserId: recipientId }], + }) + + const filtered = await listReceivedSharesForApi(db, recipientId, null, { pageSize: 10, orgId: orgA }) + const unfiltered = await listReceivedSharesForApi(db, recipientId, null, { pageSize: 10 }) + + expect(filtered.items.map((item) => item.id)).toEqual([shareA.id]) + expect(unfiltered.items.map((item) => item.id).sort()).toEqual([shareA.id, shareB.id].sort()) + }) +}) + // ─── incrementViews ────────────────────────────────────────────────────────── describe('incrementViews', () => { diff --git a/server/adapters/repos/share.test.ts b/server/adapters/repos/share.test.ts new file mode 100644 index 00000000..adb78790 --- /dev/null +++ b/server/adapters/repos/share.test.ts @@ -0,0 +1,82 @@ +import { nanoid } from 'nanoid' +import { describe, expect, it } from 'vitest' +import { DirType } from '../../../shared/constants' +import type { CreateShareInput } from '../../../shared/schemas/share' +import { matters } from '../../db/schema' +import type { Database } from '../../platform/interface' +import { createTestApp } from '../../test/setup.js' +import { createShareRepo } from './share.js' + +async function seedMatter(db: Awaited>['db'], opts: { orgId: string }) { + const now = new Date() + const matter = { + id: nanoid(), + orgId: opts.orgId, + alias: nanoid(10), + name: `share-repo-${nanoid(6)}`, + type: 'application/pdf', + size: 0, + dirtype: DirType.FILE, + parent: '', + object: `objects/${nanoid()}`, + storageId: 'storage-1', + status: 'active', + trashedAt: null, + createdAt: now, + updatedAt: now, + } + await db.insert(matters).values(matter) + return matter +} + +function createShare(db: Database, input: CreateShareInput) { + return createShareRepo(db).create(input) +} + +describe('createShareRepo API listing filters', () => { + it('filters creator shares to the requested org', async () => { + const { db } = await createTestApp() + const creatorId = 'creator-list-filter' + const orgA = `org-${nanoid()}` + const orgB = `org-${nanoid()}` + const matterA = await seedMatter(db, { orgId: orgA }) + const matterB = await seedMatter(db, { orgId: orgB }) + const shareA = await createShare(db, { matterId: matterA.id, orgId: orgA, creatorId, kind: 'landing' }) + const shareB = await createShare(db, { matterId: matterB.id, orgId: orgB, creatorId, kind: 'landing' }) + + const filtered = await createShareRepo(db).listForApi(creatorId, { pageSize: 10, orgId: orgA }) + const unfiltered = await createShareRepo(db).listForApi(creatorId, { pageSize: 10 }) + + expect(filtered.items.map((item) => item.id)).toEqual([shareA.id]) + expect(unfiltered.items.map((item) => item.id).sort()).toEqual([shareA.id, shareB.id].sort()) + }) + + it('filters received shares to the requested org', async () => { + const { db } = await createTestApp() + const recipientId = 'recipient-list-filter' + const orgA = `org-${nanoid()}` + const orgB = `org-${nanoid()}` + const matterA = await seedMatter(db, { orgId: orgA }) + const matterB = await seedMatter(db, { orgId: orgB }) + const shareA = await createShare(db, { + matterId: matterA.id, + orgId: orgA, + creatorId: 'creator-a', + kind: 'landing', + recipients: [{ recipientUserId: recipientId }], + }) + const shareB = await createShare(db, { + matterId: matterB.id, + orgId: orgB, + creatorId: 'creator-b', + kind: 'landing', + recipients: [{ recipientUserId: recipientId }], + }) + + const filtered = await createShareRepo(db).listReceivedForApi(recipientId, null, { pageSize: 10, orgId: orgA }) + const unfiltered = await createShareRepo(db).listReceivedForApi(recipientId, null, { pageSize: 10 }) + + expect(filtered.items.map((item) => item.id)).toEqual([shareA.id]) + expect(unfiltered.items.map((item) => item.id).sort()).toEqual([shareA.id, shareB.id].sort()) + }) +}) diff --git a/server/adapters/repos/share.ts b/server/adapters/repos/share.ts index dbdf5bad..e81fc6ad 100644 --- a/server/adapters/repos/share.ts +++ b/server/adapters/repos/share.ts @@ -265,10 +265,11 @@ export function createShareRepo(db: Database): ShareRepo { async listForApi( creatorId: string, - opts: { pageSize: number; status?: string; after?: { createdAt: Date; id: string } }, + opts: { pageSize: number; status?: string; orgId?: string; after?: { createdAt: Date; id: string } }, ): Promise<{ items: ShareListItem[]; nextBoundary: { createdAt: Date; id: string } | null }> { const conditions = [eq(shares.creatorId, creatorId)] if (opts.status) conditions.push(eq(shares.status, opts.status)) + if (opts.orgId) conditions.push(eq(shares.orgId, opts.orgId)) if (opts.after) { conditions.push( or( @@ -327,7 +328,7 @@ export function createShareRepo(db: Database): ShareRepo { async listReceivedForApi( userId: string, userEmail: string | null, - opts: { pageSize: number; after?: { createdAt: Date; id: string } }, + opts: { pageSize: number; orgId?: string; after?: { createdAt: Date; id: string } }, ): Promise<{ items: ShareListItem[]; nextBoundary: { createdAt: Date; id: string } | null }> { const recipientMatch = userEmail ? or(eq(shareRecipients.recipientUserId, userId), eq(shareRecipients.recipientEmail, userEmail)) @@ -338,7 +339,12 @@ export function createShareRepo(db: Database): ShareRepo { and(eq(shares.createdAt, opts.after.createdAt), lt(shares.id, opts.after.id)), ) : undefined - const where = and(eq(shares.status, 'active'), recipientMatch, cursor) + const where = and( + eq(shares.status, 'active'), + recipientMatch, + opts.orgId ? eq(shares.orgId, opts.orgId) : undefined, + cursor, + ) const rows = await db .select({ diff --git a/server/http/objects.integration.test.ts b/server/http/objects.integration.test.ts index c141a4e4..724e91fd 100644 --- a/server/http/objects.integration.test.ts +++ b/server/http/objects.integration.test.ts @@ -1405,6 +1405,25 @@ describe('Objects API — name conflict (409 responses)', () => { type TestDb = Awaited>['db'] type TestApp = Awaited>['app'] +type TestAuth = Awaited>['auth'] + +async function createWorkspaceApiKey( + auth: TestAuth, + orgId: string, + userId: string, + permissions: Record, +): Promise { + // biome-ignore lint/suspicious/noExplicitAny: better-auth plugin API is not fully typed + const result = (await (auth.api as any).createApiKey({ + body: { + configId: 'ihost', + organizationId: orgId, + userId, + permissions, + }, + })) as { key: string } + return result.key +} async function getUserIdByEmail(db: TestDb, email: string): Promise { const rows = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE email = ${email}`) @@ -1453,6 +1472,85 @@ function transferRequest( } describe('POST /api/objects/:id/transfers', () => { + it('allows a workspace API key with object scopes to list, read, and create objects', async () => { + const { app, db, auth } = await createTestApp() + await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + const userId = await getUserIdByEmail(db, 'test@example.com') + await insertFile(db, orgId, { id: 'api-key-read', name: 'readme.txt' }) + const key = await createWorkspaceApiKey(auth, orgId, userId, { objects: ['read', 'create'] }) + const headers = { Authorization: `Bearer ${key}` } + + const list = await app.request('/api/objects', { headers }) + expect(list.status).toBe(200) + const listBody = (await list.json()) as { items: Array<{ id: string }> } + expect(listBody.items.map((item) => item.id)).toContain('api-key-read') + + const read = await app.request('/api/objects/api-key-read', { headers }) + expect(read.status).toBe(200) + const readBody = (await read.json()) as { id: string; name: string } + expect(readBody).toMatchObject({ id: 'api-key-read', name: 'readme.txt' }) + + const create = await app.request('/api/objects', { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Api Folder', type: 'folder', dirtype: 1, parent: '' }), + }) + expect(create.status).toBe(201) + const createBody = (await create.json()) as { name: string; dirtype: number } + expect(createBody).toMatchObject({ name: 'Api Folder', dirtype: 1 }) + }) + + it('returns 403 when a workspace API key is missing the required object scope', async () => { + const { app, db, auth } = await createTestApp() + await authedHeaders(app) + const orgId = await getOrgId(db) + const userId = await getUserIdByEmail(db, 'test@example.com') + const key = await createWorkspaceApiKey(auth, orgId, userId, { objects: ['create'] }) + + const res = await app.request('/api/objects', { + headers: { Authorization: `Bearer ${key}` }, + }) + expect(res.status).toBe(403) + }) + + it('rejects orgId override for a fixed-workspace API key', async () => { + const { app, db, auth } = await createTestApp() + await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + const userId = await getUserIdByEmail(db, 'test@example.com') + await insertTeamOrg(db, 'team-api-override') + await insertMember(db, 'team-api-override', userId, 'viewer') + await insertFile(db, 'team-api-override', { id: 'team-api-file', name: 'team.txt' }) + const key = await createWorkspaceApiKey(auth, orgId, userId, { objects: ['read'] }) + + const res = await app.request('/api/objects?orgId=team-api-override', { + headers: { Authorization: `Bearer ${key}` }, + }) + expect(res.status).toBe(403) + }) + + it('rejects cross-space transfer for a fixed-workspace API key', async () => { + const { app, db, auth } = await createTestApp() + await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + const userId = await getUserIdByEmail(db, 'test@example.com') + await insertTeamOrg(db, 'team-api-transfer') + await insertMember(db, 'team-api-transfer', userId, 'editor') + await insertStorageEntitlement(db, 'team-api-transfer', 10_000_000) + await insertFile(db, orgId, { id: 'src-api-transfer', name: 'doc.txt' }) + const key = await createWorkspaceApiKey(auth, orgId, userId, { objects: ['update'] }) + + const res = await transferRequest(app, { Authorization: `Bearer ${key}` }, 'src-api-transfer', { + targetOrgId: 'team-api-transfer', + mode: 'copy', + }) + expect(res.status).toBe(403) + }) + it('copies a file into a team space the user can edit [spec: objects/transfer-copy]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) @@ -2439,15 +2537,21 @@ describe('object multipart upload API with S3-compatible storage', () => { // resolution, the download-task-upload confirm guards, and the editor-access // gate for an API key principal on session-only object routes. -// Creates an API key via the real better-auth plugin. A `webdav` config-id key -// is user-owned but is not a browser/session principal. +// Creates an API key via the real better-auth plugin. Workspace-scoped keys use +// a workspace-capable template; user-workspace keys use WebDAV. async function createUserApiKey( auth: Awaited>['auth'], userId: string, + opts: { orgId?: string; permissions?: Record } = {}, ): Promise { // biome-ignore lint/suspicious/noExplicitAny: better-auth plugin API not fully typed const result = (await (auth.api as any).createApiKey({ - body: { configId: 'webdav', userId }, + body: { + configId: opts.orgId ? 'ihost' : 'webdav', + userId, + ...(opts.orgId ? { organizationId: opts.orgId } : {}), + permissions: opts.permissions, + }, })) as { key: string } return result.key } @@ -2561,22 +2665,76 @@ describe('Objects API — error branches', () => { }) }) - it('returns 401 for an API key on a session-only object write', async () => { + it('creates a folder with a workspace API key that has objects:create', async () => { const { app, db, auth } = await createTestApp() await authedHeaders(app) await insertStorage(db) const userId = await getUserIdByEmail(db, 'test@example.com') - const key = await createUserApiKey(auth, userId) + const orgId = await getOrgId(db) + const key = await createUserApiKey(auth, userId, { orgId, permissions: { objects: ['create'] } }) const res = await app.request('/api/objects', { method: 'POST', headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'denied.txt', type: 'text/plain', size: 1 }), + body: JSON.stringify({ name: 'api-key-folder', type: 'folder', dirtype: 1, parent: '' }), }) - expect(res.status).toBe(401) + expect(res.status).toBe(201) + await expect(res.json()).resolves.toMatchObject({ name: 'api-key-folder', orgId }) + }) + + it('returns 403 when an object API key is missing the route scope', async () => { + const { app, db, auth } = await createTestApp() + await authedHeaders(app) + const userId = await getUserIdByEmail(db, 'test@example.com') + const orgId = await getOrgId(db) + const key = await createUserApiKey(auth, userId, { orgId, permissions: { objects: ['create'] } }) + + const res = await app.request('/api/objects', { headers: { Authorization: `Bearer ${key}` } }) + + expect(res.status).toBe(403) const body = (await res.json()) as { error: { message: string; status: string } } - expect(body.error.message).toBe('Unauthorized') - expect(body.error.status).toBe('UNAUTHENTICATED') + expect(body.error.message).toBe('Forbidden') + expect(body.error.status).toBe('PERMISSION_DENIED') + }) + + it('denies a fixed-workspace API key list orgId override before cross-org lookup', async () => { + const { app, db, auth } = await createTestApp() + await authedHeaders(app) + const userId = await getUserIdByEmail(db, 'test@example.com') + const orgId = await getOrgId(db) + await insertTeamOrg(db, 'team-fixed-list') + await insertMember(db, 'team-fixed-list', userId, 'owner') + const key = await createUserApiKey(auth, userId, { orgId, permissions: { objects: ['read'] } }) + + const res = await app.request('/api/objects?orgId=team-fixed-list', { headers: { Authorization: `Bearer ${key}` } }) + + expect(res.status).toBe(403) + }) + + it('allows same-workspace copy but denies cross-space transfer for a fixed-workspace API key', async () => { + const { app, db, auth } = await createTestApp() + await authedHeaders(app) + await insertStorage(db) + const userId = await getUserIdByEmail(db, 'test@example.com') + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'fixed-copy-source', name: 'copy.txt' }) + await insertTeamOrg(db, 'team-fixed-transfer') + await insertMember(db, 'team-fixed-transfer', userId, 'editor') + const key = await createUserApiKey(auth, userId, { orgId, permissions: { objects: ['update'] } }) + const headers = { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' } + + const copy = await app.request('/api/objects/fixed-copy-source/copies', { + method: 'POST', + headers, + body: JSON.stringify({ parent: '' }), + }) + expect(copy.status).toBe(201) + + const transfer = await transferRequest(app, headers, 'fixed-copy-source', { + targetOrgId: 'team-fixed-transfer', + mode: 'copy', + }) + expect(transfer.status).toBe(403) }) it('returns 403 when listing an org the user cannot read via orgId override', async () => { @@ -2641,13 +2799,13 @@ describe('Objects API — error branches', () => { const { uploadToken, orgId } = await mintTaskUploadContext(app, db, { targetFolder: 'Remote' }) await insertFile(db, orgId, { id: 'm-task-trash', name: 'file.txt', parent: 'Remote' }) - // DELETE /objects/:id is editor-gated; a download-task-upload token has no - // team role (userId is null) so it is rejected — it may only finalize uploads. + // DELETE /objects/:id requires objects:delete; a download-task-upload token + // is authenticated but only carries upload lifecycle scopes. const res = await app.request('/api/objects/m-task-trash', { method: 'DELETE', headers: { Authorization: `Bearer ${uploadToken}` }, }) - expect(res.status).toBe(401) + expect(res.status).toBe(403) }) it('rejects a download-task-upload completion outside the task target folder', async () => { diff --git a/server/http/objects.ts b/server/http/objects.ts index 63233386..5cbf0995 100644 --- a/server/http/objects.ts +++ b/server/http/objects.ts @@ -1,4 +1,5 @@ import { OpenAPIHono, z } from '@hono/zod-openapi' +import { AuthorizationScope } from '@shared/authorization' import { completeObjectUploadSchema, copyObjectBodySchema, @@ -12,10 +13,8 @@ import { transferMatterSchema, } from '@shared/schemas' import type { Context } from 'hono' -import { createMiddleware } from 'hono/factory' import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants' import { transferAuditActor } from '../middleware/audit-transfers' -import { requireTeamRole } from '../middleware/auth' import type { Env } from '../middleware/platform' import { abortUpload, @@ -24,7 +23,6 @@ import { copyObject, createObject, getObject, - hasEditorAccess, listObjects, type ObjectActor, ObjectUploadSessionError, @@ -33,7 +31,7 @@ import { trashObject, updateObject, } from '../usecases/object' -import { badRequest, forbidden, type Matter, type MatterListItem, unauthorized } from '../usecases/ports' +import { badRequest, forbidden, type Matter, type MatterListItem } from '../usecases/ports' import { recordDownloadIssued } from '../usecases/transfer-activity' import { authRoute, errorResponse, jsonBody, jsonContent } from './openapi' import { decodeOptionalPageToken, directoryCursorCodec, encodeNextPageToken, pageQueryFingerprint } from './page-token' @@ -150,33 +148,18 @@ function actorId(c: Context): string { const cloudBaseUrl = (c: Context) => c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT -const requireObjectWriteAccess = createMiddleware(async (c, next) => { - const principal = c.get('principal') - if (principal?.kind === 'download-task-upload') { - await next() - return - } - if (!(await hasEditorAccess(c.get('deps'), { orgId: c.get('orgId'), userId: c.get('userId') }))) { - if (c.get('userId')) throw forbidden() - throw unauthorized() - } - await next() -}) - -const objectWriteAuth = { - access: 'anyOf', - policies: [{ access: 'session', minTeamRole: 'editor' }, { access: 'task-upload-token' }], -} as const - const listRoute = authRoute( - { access: 'session', minTeamRole: 'viewer' }, + { + access: 'protected', + scopes: [AuthorizationScope.OBJECTS_READ], + minTeamRole: 'viewer', + }, { operationId: 'listObjects', summary: 'List objects', tags: ['Objects'], method: 'get', path: '/', - middleware: [requireTeamRole('viewer')] as const, request: { query: listObjectsQuerySchema }, responses: { 200: jsonContent(objectPageSchema, 'Objects'), @@ -186,84 +169,111 @@ const listRoute = authRoute( }, ) -const createObjectRoute = authRoute(objectWriteAuth, { - operationId: 'createObject', - summary: 'Create object', - tags: ['Objects'], - method: 'post', - path: '/', - middleware: [requireObjectWriteAccess] as const, - request: jsonBody(createMatterSchema), - responses: { - 201: jsonContent(objectCreateResultSchema, 'Created object (folder, or file draft with upload instructions)'), - 400: errorResponse('No active organization or file too large'), - 403: errorResponse('Forbidden'), - 409: errorResponse('Name conflict'), - 503: errorResponse('No storage configured'), +const createObjectRoute = authRoute( + { + access: 'protected', + scopes: [AuthorizationScope.OBJECTS_CREATE], + minTeamRole: 'editor', }, -}) + { + operationId: 'createObject', + summary: 'Create object', + tags: ['Objects'], + method: 'post', + path: '/', + request: jsonBody(createMatterSchema), + responses: { + 201: jsonContent(objectCreateResultSchema, 'Created object (folder, or file draft with upload instructions)'), + 400: errorResponse('No active organization or file too large'), + 403: errorResponse('Forbidden'), + 409: errorResponse('Name conflict'), + 503: errorResponse('No storage configured'), + }, + }, +) -const presignPartsRoute = authRoute(objectWriteAuth, { - operationId: 'presignObjectUploadParts', - summary: 'Re-presign upload parts', - tags: ['Objects'], - method: 'post', - path: '/{id}/uploads/{uploadSessionId}/parts', - middleware: [requireObjectWriteAccess] as const, - request: { params: sessionParams, ...jsonBody(presignObjectUploadPartsSchema) }, - responses: { - 200: jsonContent(presignObjectUploadPartsResponseSchema, 'Presigned multipart upload parts'), - 400: errorResponse('Invalid upload session'), - 403: errorResponse('Forbidden'), - 404: errorResponse('Not found'), - 502: errorResponse('Storage failure'), +const presignPartsRoute = authRoute( + { + access: 'protected', + scopes: [AuthorizationScope.OBJECTS_CREATE], + minTeamRole: 'editor', }, -}) + { + operationId: 'presignObjectUploadParts', + summary: 'Re-presign upload parts', + tags: ['Objects'], + method: 'post', + path: '/{id}/uploads/{uploadSessionId}/parts', + request: { params: sessionParams, ...jsonBody(presignObjectUploadPartsSchema) }, + responses: { + 200: jsonContent(presignObjectUploadPartsResponseSchema, 'Presigned multipart upload parts'), + 400: errorResponse('Invalid upload session'), + 403: errorResponse('Forbidden'), + 404: errorResponse('Not found'), + 502: errorResponse('Storage failure'), + }, + }, +) -const completionsRoute = authRoute(objectWriteAuth, { - operationId: 'completeObjectUpload', - summary: 'Complete upload', - tags: ['Objects'], - method: 'post', - path: '/{id}/uploads/{uploadSessionId}/completions', - middleware: [requireObjectWriteAccess] as const, - request: { params: sessionParams, ...jsonBody(completeObjectUploadSchema) }, - responses: { - 200: jsonContent(matterSchema, 'Finalized live object'), - 400: errorResponse('Invalid upload session'), - 403: errorResponse('Forbidden'), - 404: errorResponse('Not found'), - 422: errorResponse('Quota exceeded'), - 502: errorResponse('Storage failure'), +const completionsRoute = authRoute( + { + access: 'protected', + scopes: [AuthorizationScope.OBJECTS_CREATE], + minTeamRole: 'editor', }, -}) + { + operationId: 'completeObjectUpload', + summary: 'Complete upload', + tags: ['Objects'], + method: 'post', + path: '/{id}/uploads/{uploadSessionId}/completions', + request: { params: sessionParams, ...jsonBody(completeObjectUploadSchema) }, + responses: { + 200: jsonContent(matterSchema, 'Finalized live object'), + 400: errorResponse('Invalid upload session'), + 403: errorResponse('Forbidden'), + 404: errorResponse('Not found'), + 422: errorResponse('Quota exceeded'), + 502: errorResponse('Storage failure'), + }, + }, +) -const abortUploadRoute = authRoute(objectWriteAuth, { - operationId: 'abortObjectUpload', - summary: 'Abort upload', - tags: ['Objects'], - method: 'delete', - path: '/{id}/uploads/{uploadSessionId}', - middleware: [requireObjectWriteAccess] as const, - request: { params: sessionParams, query: abortUploadQuerySchema }, - responses: { - 204: { description: 'Aborted upload and discarded the draft' }, - 400: errorResponse('Invalid upload session'), - 403: errorResponse('Forbidden'), - 404: errorResponse('Not found'), - 502: errorResponse('Storage cleanup failed'), +const abortUploadRoute = authRoute( + { + access: 'protected', + scopes: [AuthorizationScope.OBJECTS_CREATE], + minTeamRole: 'editor', }, -}) + { + operationId: 'abortObjectUpload', + summary: 'Abort upload', + tags: ['Objects'], + method: 'delete', + path: '/{id}/uploads/{uploadSessionId}', + request: { params: sessionParams, query: abortUploadQuerySchema }, + responses: { + 204: { description: 'Aborted upload and discarded the draft' }, + 400: errorResponse('Invalid upload session'), + 403: errorResponse('Forbidden'), + 404: errorResponse('Not found'), + 502: errorResponse('Storage cleanup failed'), + }, + }, +) const getObjectRoute = authRoute( - { access: 'session', minTeamRole: 'viewer' }, + { + access: 'protected', + scopes: [AuthorizationScope.OBJECTS_READ], + minTeamRole: 'viewer', + }, { operationId: 'getObject', summary: 'Get object', tags: ['Objects'], method: 'get', path: '/{id}', - middleware: [requireTeamRole('viewer')] as const, request: { params: idParam }, responses: { 200: jsonContent(objectWithDownloadSchema, 'Object'), @@ -275,30 +285,39 @@ const getObjectRoute = authRoute( }, ) -const patchObjectRoute = authRoute(objectWriteAuth, { - operationId: 'updateObject', - summary: 'Update object', - tags: ['Objects'], - method: 'patch', - path: '/{id}', - middleware: [requireObjectWriteAccess] as const, - request: { params: idParam, ...jsonBody(patchMatterSchema) }, - responses: { - 200: jsonContent(matterSchema, 'Updated object'), - 400: errorResponse('Bad request'), - 404: errorResponse('Not found'), +const patchObjectRoute = authRoute( + { + access: 'protected', + scopes: [AuthorizationScope.OBJECTS_UPDATE], + minTeamRole: 'editor', }, -}) + { + operationId: 'updateObject', + summary: 'Update object', + tags: ['Objects'], + method: 'patch', + path: '/{id}', + request: { params: idParam, ...jsonBody(patchMatterSchema) }, + responses: { + 200: jsonContent(matterSchema, 'Updated object'), + 400: errorResponse('Bad request'), + 404: errorResponse('Not found'), + }, + }, +) const deleteObjectRoute = authRoute( - { access: 'session', minTeamRole: 'editor' }, + { + access: 'protected', + scopes: [AuthorizationScope.OBJECTS_DELETE], + minTeamRole: 'editor', + }, { operationId: 'deleteObject', summary: 'Delete object', tags: ['Objects'], method: 'delete', path: '/{id}', - middleware: [requireTeamRole('editor')] as const, request: { params: idParam }, responses: { // Soft delete: the object moves to trash (GET /trash/objects). Permanent @@ -311,14 +330,17 @@ const deleteObjectRoute = authRoute( ) const copyObjectRoute = authRoute( - { access: 'session', minTeamRole: 'editor' }, + { + access: 'protected', + scopes: [AuthorizationScope.OBJECTS_UPDATE], + minTeamRole: 'editor', + }, { operationId: 'copyObject', summary: 'Copy object', tags: ['Objects'], method: 'post', path: '/{id}/copies', - middleware: [requireTeamRole('editor')] as const, request: { params: idParam, ...jsonBody(copyObjectBodySchema) }, responses: { 201: jsonContent(matterSchema, 'Copied object'), @@ -329,14 +351,17 @@ const copyObjectRoute = authRoute( ) const transferObjectRoute = authRoute( - { access: 'session', minTeamRole: 'viewer' }, + { + access: 'protected', + scopes: [AuthorizationScope.OBJECTS_UPDATE], + minTeamRole: 'viewer', + }, { operationId: 'transferObject', summary: 'Transfer object to another space', tags: ['Objects'], method: 'post', path: '/{id}/transfers', - middleware: [requireTeamRole('viewer')] as const, request: { params: idParam, ...jsonBody(transferMatterSchema) }, responses: { 201: jsonContent( @@ -358,16 +383,6 @@ const transferObjectRoute = authRoute( ) const app = new OpenAPIHono() -// Blanket auth gate for every object route. Applied as a statement, not chained, -// because `.use()` returns the base Hono type and would strip `.openapi()`. -app.use(async (c, next) => { - const principal = c.get('principal') - if (principal?.kind === 'user' || principal?.kind === 'download-task-upload') { - await next() - return - } - throw unauthorized() -}) const objects = app .openapi(listRoute, async (c) => { @@ -389,6 +404,7 @@ const objects = app const result = await listObjects(c.get('deps'), { orgId, userId: c.get('userId')!, + fixedOrgId: c.get('authzContext').fixedOrgId, orgOverride: query.orgId, filters: { parent: query.path ?? query.parent ?? '', @@ -550,6 +566,7 @@ const objects = app .openapi(transferObjectRoute, async (c) => { const orgId = c.get('orgId') if (!orgId) throw badRequest('No active organization') + if (c.get('authzContext').fixedOrgId) throw forbidden() const result = await transferObject(c.get('deps'), { orgId, diff --git a/server/http/quotas.integration.test.ts b/server/http/quotas.integration.test.ts index 357f790c..d8d8138b 100644 --- a/server/http/quotas.integration.test.ts +++ b/server/http/quotas.integration.test.ts @@ -187,21 +187,45 @@ describe('User Quotas API — /api/quotas', () => { expect(body.orgId).toBeTruthy() }) - it('GET /api/quotas/me rejects an API key because the route requires a user session [spec: quotas/me-no-org]', async () => { + it('GET /api/quotas/me returns quota for a workspace API key with quota:read [spec: quotas/me-api-key]', async () => { const { app, auth, db } = await createTestApp() await authedHeaders(app, 'noorg@example.com') const [user] = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE email = 'noorg@example.com'`) + const [org] = await db.all<{ id: string }>( + sql`SELECT id FROM organization WHERE metadata LIKE '%"type":"personal"%' LIMIT 1`, + ) // biome-ignore lint/suspicious/noExplicitAny: better-auth plugin API not fully typed const apiKey = (await (auth.api as any).createApiKey({ - body: { configId: 'webdav', userId: user.id }, + body: { configId: 'ihost', userId: user.id, organizationId: org.id, permissions: { quota: ['read'] } }, })) as { key: string } - await db.run(sql`DELETE FROM member WHERE user_id = ${user.id}`) const res = await app.request('/api/quotas/me', { headers: { Authorization: `Bearer ${apiKey.key}` }, }) - expect(res.status).toBe(401) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toMatchObject({ orgId: org.id, quota: 10485760 }) + }) + + it('GET /api/quotas/me returns 403 for a workspace API key without quota:read', async () => { + const { app, auth, db } = await createTestApp() + await authedHeaders(app, 'quota-missing-scope@example.com') + const [user] = await db.all<{ id: string }>( + sql`SELECT id FROM user WHERE email = 'quota-missing-scope@example.com'`, + ) + const [org] = await db.all<{ id: string }>( + sql`SELECT id FROM organization WHERE metadata LIKE '%"type":"personal"%' LIMIT 1`, + ) + // biome-ignore lint/suspicious/noExplicitAny: better-auth plugin API not fully typed + const apiKey = (await (auth.api as any).createApiKey({ + body: { configId: 'ihost', userId: user.id, organizationId: org.id, permissions: { objects: ['read'] } }, + })) as { key: string } + + const res = await app.request('/api/quotas/me', { + headers: { Authorization: `Bearer ${apiKey.key}` }, + }) + + expect(res.status).toBe(403) }) it('GET /api/quotas/me returns base quota plus active entitlements and labels [spec: quotas/me-effective]', async () => { diff --git a/server/http/quotas.ts b/server/http/quotas.ts index 92534e53..9b902661 100644 --- a/server/http/quotas.ts +++ b/server/http/quotas.ts @@ -1,6 +1,7 @@ import { OpenAPIHono, z } from '@hono/zod-openapi' +import { AuthorizationScope } from '@shared/authorization' import { pageSchema } from '@shared/schemas' -import { requireAdmin, requireAuth } from '../middleware/auth' +import { requireAdmin } from '../middleware/auth' import type { Env } from '../middleware/platform' import { notFound } from '../usecases/ports' import { getUserQuota, listQuotaOverview } from '../usecases/quota' @@ -59,14 +60,17 @@ const listQuotaOverviewRoute = authRoute( ) const getMyQuotaRoute = authRoute( - { access: 'session' }, + { + access: 'protected', + scopes: [AuthorizationScope.QUOTA_READ], + minTeamRole: 'viewer', + }, { operationId: 'getMyQuota', summary: "Get the current user's effective quota", tags: ['Quotas'], method: 'get', path: '/me', - middleware: [requireAuth] as const, responses: { 200: jsonContent(effectiveQuotaSchema, 'Effective quota'), 404: errorResponse('No organization found'), diff --git a/server/http/shares.integration.test.ts b/server/http/shares.integration.test.ts index 91893cc2..4a68dc75 100644 --- a/server/http/shares.integration.test.ts +++ b/server/http/shares.integration.test.ts @@ -9,6 +9,7 @@ import { authedHeaders, createTestApp, seedProLicense } from '../test/setup.js' type TestApp = Awaited>['app'] type TestDb = Awaited>['db'] +type TestAuth = Awaited>['auth'] // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -75,6 +76,40 @@ async function getOrgId(db: TestDb): Promise { return rows[0].id } +async function getUserIdByEmail(db: TestDb, email: string): Promise { + const rows = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE email = ${email}`) + return rows[0].id +} + +async function createWorkspaceApiKey( + auth: TestAuth, + orgId: string, + userId: string, + permissions: Record, +): Promise { + // biome-ignore lint/suspicious/noExplicitAny: better-auth plugin API is not fully typed + const result = (await (auth.api as any).createApiKey({ + body: { + configId: 'ihost', + organizationId: orgId, + userId, + permissions, + }, + })) as { key: string } + return result.key +} + +async function createWebDavApiKey(auth: TestAuth, userId: string): Promise { + // biome-ignore lint/suspicious/noExplicitAny: better-auth plugin API is not fully typed + const result = (await (auth.api as any).createApiKey({ + body: { + configId: 'webdav', + userId, + }, + })) as { key: string } + return result.key +} + async function createShare(app: TestApp, headers: Record, body: Record) { return app.request('/api/shares', { method: 'POST', @@ -420,6 +455,24 @@ describe('share privacy mutation', () => { expect(body.error.details[0]?.reason).toBe('SHARE_PRIVACY_INELIGIBLE') } }) + + it('does not let user-wide WebDAV app passwords mutate share privacy', async () => { + const { app, db, auth } = await createTestApp() + const ownerHeaders = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + const userId = await getUserIdByEmail(db, 'test@example.com') + await insertFile(db, orgId, { id: 'webdav-share-privacy', name: 'webdav-share-privacy.txt' }) + const created = await createShare(app, ownerHeaders, { matterId: 'webdav-share-privacy', kind: 'landing' }) + const token = ((await created.json()) as { token: string }).token + const key = await createWebDavApiKey(auth, userId) + + const res = await privacyRequest(app, token, true, { Authorization: `Bearer ${key}` }) + + expect(res.status).toBe(403) + const rows = await db.select({ private: shares.private }).from(shares).where(eq(shares.token, token)) + expect(rows[0]?.private).toBe(false) + }) }) // ─── GET /api/shares auth guard ─────────────────────────────────────────────── @@ -545,6 +598,37 @@ describe('GET /api/shares', () => { const revokedBody = (await resRevoked.json()) as { items: unknown[] } expect(revokedBody.items).toHaveLength(1) }) + + it('filters a fixed-workspace API key share list to its workspace', async () => { + const { app, db, auth } = await createTestApp() + await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + const userId = await getUserIdByEmail(db, 'test@example.com') + const teamOrgId = `team-share-list-${nanoid()}` + const now = Date.now() + await db.run(sql` + INSERT INTO organization (id, name, slug, metadata, created_at) + VALUES (${teamOrgId}, 'Team Share List', ${teamOrgId}, '{"type":"team"}', ${now}) + `) + await insertFile(db, orgId, { id: 'api-share-personal', name: 'personal.txt' }) + await insertFile(db, teamOrgId, { id: 'api-share-team', name: 'team.txt' }) + await db.run(sql` + INSERT INTO shares (id, token, kind, matter_id, org_id, creator_id, views, downloads, status, created_at) + VALUES + ('api-share-personal-row', 'api-share-personal-token', 'landing', 'api-share-personal', ${orgId}, ${userId}, 0, 0, 'active', ${now}), + ('api-share-team-row', 'api-share-team-token', 'landing', 'api-share-team', ${teamOrgId}, ${userId}, 0, 0, 'active', ${now + 1}) + `) + const key = await createWorkspaceApiKey(auth, orgId, userId, { shares: ['read'] }) + + const res = await app.request('/api/shares', { + headers: { Authorization: `Bearer ${key}` }, + }) + expect(res.status).toBe(200) + const body = (await res.json()) as { items: Array<{ orgId: string; matter: { name: string } }> } + expect(body.items).toHaveLength(1) + expect(body.items[0]).toMatchObject({ orgId, matter: { name: 'personal.txt' } }) + }) }) // ─── GET /api/shares/:token (creator vs visitor views) ─────────────────────── @@ -806,6 +890,35 @@ describe('POST /api/shares/:token/objects', () => { expect(res.status).toBe(403) }) + it('rejects save-share targetOrgId escape for a fixed-workspace API key', async () => { + const { app, db, auth } = await createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + const userId = await getUserIdByEmail(db, 'test@example.com') + await insertFile(db, orgId, { id: 'sv-api-fixed', name: 'fixed-key.txt' }) + const teamOrgId = `team-save-api-${nanoid()}` + const now = Date.now() + await db.run(sql` + INSERT INTO organization (id, name, slug, metadata, created_at) + VALUES (${teamOrgId}, 'Team Save API', ${teamOrgId}, '{"type":"team"}', ${now}) + `) + await db.run(sql` + INSERT INTO member (id, organization_id, user_id, role, created_at) + VALUES (${nanoid()}, ${teamOrgId}, ${userId}, 'editor', ${now}) + `) + const createRes = await createShare(app, headers, { matterId: 'sv-api-fixed', kind: 'landing' }) + const token = ((await createRes.json()) as Record).token as string + const key = await createWorkspaceApiKey(auth, orgId, userId, { objects: ['create'] }) + + const res = await app.request(`/api/shares/${token}/objects`, { + method: 'POST', + headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetOrgId: teamOrgId, targetParent: '' }), + }) + expect(res.status).toBe(403) + }) + it("returns 403 when targetOrgId is another user's personal org", async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) @@ -1088,6 +1201,24 @@ describe('PUT /api/shares/:token/status', () => { const rows = await db.select({ status: shares.status }).from(shares).where(eq(shares.token, token)) expect(rows[0]?.status).toBe('revoked') }) + + it('does not let user-wide WebDAV app passwords revoke shares', async () => { + const { app, db, auth } = await createTestApp() + const ownerHeaders = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + const userId = await getUserIdByEmail(db, 'test@example.com') + await insertFile(db, orgId, { id: 'webdav-share-revoke', name: 'webdav-share-revoke.txt' }) + const created = await createShare(app, ownerHeaders, { matterId: 'webdav-share-revoke', kind: 'landing' }) + const token = ((await created.json()) as { token: string }).token + const key = await createWebDavApiKey(auth, userId) + + const res = await revokeRequest(app, token, { Authorization: `Bearer ${key}` }) + + expect(res.status).toBe(403) + const rows = await db.select({ status: shares.status }).from(shares).where(eq(shares.token, token)) + expect(rows[0]?.status).toBe('active') + }) }) describe('GET /api/shares?box=received', () => { diff --git a/server/http/shares.ts b/server/http/shares.ts index 6ebd7963..ab68557c 100644 --- a/server/http/shares.ts +++ b/server/http/shares.ts @@ -1,4 +1,5 @@ import { OpenAPIHono, z } from '@hono/zod-openapi' +import { AuthorizationScope } from '@shared/authorization' import type { Context } from 'hono' import { getCookie, setCookie } from 'hono/cookie' import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants' @@ -12,7 +13,6 @@ import { shareRecipientViewSchema, } from '../../shared/schemas/share' import { transferAuditActor } from '../middleware/audit-transfers' -import { requireAuth, requireTeamRole } from '../middleware/auth' import type { Env } from '../middleware/platform' import type { Matter, ShareListItem } from '../usecases/ports' import { @@ -409,28 +409,34 @@ export const publicShares = pub // ─── AUTHED SEGMENT ───────────────────────────────────────────────────────── const listSharesRoute = authRoute( - { access: 'session' }, + { + access: 'protected', + scopes: [AuthorizationScope.SHARES_READ], + minTeamRole: 'viewer', + }, { operationId: 'listShares', summary: 'List my shares', tags: ['Shares'], method: 'get', path: '/', - middleware: [requireAuth] as const, request: { query: listSharesQuerySchema }, responses: { 200: jsonContent(shareListSchema, 'Shares') }, }, ) const createShareRoute = authRoute( - { access: 'session', minTeamRole: 'editor' }, + { + access: 'protected', + scopes: [AuthorizationScope.SHARES_CREATE], + minTeamRole: 'editor', + }, { operationId: 'createShare', summary: 'Create a share', tags: ['Shares'], method: 'post', path: '/', - middleware: [requireAuth, requireTeamRole('editor')] as const, request: jsonBody(createShareRequestSchema), responses: { 201: jsonContent(createdShareSchema, 'Created share'), @@ -441,14 +447,16 @@ const createShareRoute = authRoute( ) const revokeShareRoute = authRoute( - { access: 'session' }, + { + access: 'protected', + scopes: [AuthorizationScope.SHARES_DELETE], + }, { operationId: 'revokeShare', summary: 'Revoke a share', tags: ['Shares'], method: 'put', path: '/{token}/status', - middleware: [requireAuth] as const, request: { params: z.object({ token: z.string() }), ...jsonBody(z.object({ status: z.literal('revoked') })), @@ -464,14 +472,16 @@ const revokeShareRoute = authRoute( const sharePrivacySchema = z.object({ private: z.boolean() }).openapi('SharePrivacy') const putSharePrivacyRoute = authRoute( - { access: 'session' }, + { + access: 'protected', + scopes: [AuthorizationScope.SHARES_CREATE], + }, { operationId: 'putSharePrivacy', summary: 'Set whether a share is hidden from the owner public profile', tags: ['Shares'], method: 'put', path: '/{token}/privacy', - middleware: [requireAuth] as const, request: { params: z.object({ token: z.string() }), ...jsonBody(sharePrivacySchema), @@ -486,14 +496,17 @@ const putSharePrivacyRoute = authRoute( ) const saveShareRoute = authRoute( - { access: 'session' }, + { + access: 'protected', + scopes: [AuthorizationScope.OBJECTS_CREATE], + minTeamRole: 'editor', + }, { operationId: 'saveShare', summary: 'Save a share to my drive', tags: ['Shares'], method: 'post', path: '/{token}/objects', - middleware: [requireAuth] as const, request: { params: z.object({ token: z.string() }), ...jsonBody(saveShareRequestSchema) }, responses: { 201: jsonContent(saveShareResultSchema, 'Saved'), @@ -518,7 +531,14 @@ export const authedShares = authedApp query: fingerprint, codec: createdAtIdCursorCodec, }) - const result = await listShares(c.get('deps'), { userId, box, pageSize, status, after }) + const result = await listShares(c.get('deps'), { + userId, + box, + pageSize, + status, + fixedOrgId: c.get('authzContext').fixedOrgId, + after, + }) return c.json( { items: result.items.map(toShareListItemDTO), @@ -555,6 +575,7 @@ export const authedShares = authedApp const out = await setSharePrivacy(c.get('deps'), { token: c.req.valid('param').token, userId: c.get('userId')!, + fixedOrgId: c.get('authzContext').fixedOrgId, private: c.req.valid('json').private, }) if (out.ok) return c.json({ private: out.private }, 200) @@ -564,6 +585,7 @@ export const authedShares = authedApp const out = await revokeShare(c.get('deps'), { token: c.req.valid('param').token, userId: c.get('userId')!, + fixedOrgId: c.get('authzContext').fixedOrgId, }) if (out.ok) return c.json(toShareViewDTO(out.dto), 200) throw out.error @@ -575,6 +597,7 @@ export const authedShares = authedApp token, currentUserId: c.get('userId')!, targetOrgId, + fixedTargetOrgId: c.get('authzContext').fixedOrgId, targetParent, accessCookie: getCookie(c, cookieName(token)), }) diff --git a/server/http/storage-usage.integration.test.ts b/server/http/storage-usage.integration.test.ts index 27516015..7be7d9ed 100644 --- a/server/http/storage-usage.integration.test.ts +++ b/server/http/storage-usage.integration.test.ts @@ -1,3 +1,4 @@ +import { sql } from 'drizzle-orm' import { describe, expect, it } from 'vitest' import { authedHeaders, createTestApp } from '../test/setup' @@ -21,4 +22,46 @@ describe('storage usage API', () => { expect((await app.request('/api/storage/scans', { method: 'POST', headers })).status).toBe(404) }) + + it('allows a workspace API key with storage-usage:read', async () => { + const { app, auth, db } = await createTestApp() + await authedHeaders(app, 'storage-api-key@example.com') + const [user] = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE email = 'storage-api-key@example.com'`) + const [org] = await db.all<{ id: string }>( + sql`SELECT id FROM organization WHERE metadata LIKE '%"type":"personal"%' LIMIT 1`, + ) + // biome-ignore lint/suspicious/noExplicitAny: better-auth plugin API not fully typed + const apiKey = (await (auth.api as any).createApiKey({ + body: { + configId: 'ihost', + userId: user.id, + organizationId: org.id, + permissions: { 'storage-usage': ['read'] }, + }, + })) as { key: string } + + const res = await app.request('/api/storage', { headers: { Authorization: `Bearer ${apiKey.key}` } }) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toMatchObject({ usedBytes: 0, quotaBytes: 10485760 }) + }) + + it('returns 403 for a workspace API key without storage-usage:read', async () => { + const { app, auth, db } = await createTestApp() + await authedHeaders(app, 'storage-api-key-denied@example.com') + const [user] = await db.all<{ id: string }>( + sql`SELECT id FROM user WHERE email = 'storage-api-key-denied@example.com'`, + ) + const [org] = await db.all<{ id: string }>( + sql`SELECT id FROM organization WHERE metadata LIKE '%"type":"personal"%' LIMIT 1`, + ) + // biome-ignore lint/suspicious/noExplicitAny: better-auth plugin API not fully typed + const apiKey = (await (auth.api as any).createApiKey({ + body: { configId: 'ihost', userId: user.id, organizationId: org.id, permissions: { quota: ['read'] } }, + })) as { key: string } + + const res = await app.request('/api/storage', { headers: { Authorization: `Bearer ${apiKey.key}` } }) + + expect(res.status).toBe(403) + }) }) diff --git a/server/http/storage-usage.ts b/server/http/storage-usage.ts index 67d88ee4..fcc2a1fc 100644 --- a/server/http/storage-usage.ts +++ b/server/http/storage-usage.ts @@ -1,6 +1,6 @@ import { OpenAPIHono, z } from '@hono/zod-openapi' +import { AuthorizationScope } from '@shared/authorization' import { STORAGE_USAGE_CATEGORIES, STORAGE_USAGE_SORT_FIELDS } from '@shared/storage-usage' -import { requireAuth } from '../middleware/auth' import type { Env } from '../middleware/platform' import { notFound } from '../usecases/ports' import { getStorageUsage, listStorageUsageItems } from '../usecases/storage-usage-dashboard' @@ -35,27 +35,33 @@ const itemSchema = z.object({ }) const getUsageRoute = authRoute( - { access: 'session' }, + { + access: 'protected', + scopes: [AuthorizationScope.STORAGE_USAGE_READ], + minTeamRole: 'viewer', + }, { operationId: 'getStorageUsage', summary: 'Get current storage usage by category', tags: ['Storage Usage'], method: 'get', path: '/', - middleware: [requireAuth] as const, responses: { 200: jsonContent(usageSchema, 'Storage usage') }, }, ) const listItemsRoute = authRoute( - { access: 'session' }, + { + access: 'protected', + scopes: [AuthorizationScope.STORAGE_USAGE_READ], + minTeamRole: 'viewer', + }, { operationId: 'listStorageUsageItems', summary: 'List files in a storage usage category', tags: ['Storage Usage'], method: 'get', path: '/items', - middleware: [requireAuth] as const, request: { query: z.object({ category: categorySchema, diff --git a/server/http/trash.ts b/server/http/trash.ts index c691d37d..91d20e7f 100644 --- a/server/http/trash.ts +++ b/server/http/trash.ts @@ -1,6 +1,6 @@ import { OpenAPIHono, z } from '@hono/zod-openapi' +import { AuthorizationScope } from '@shared/authorization' import { cursorPageQuerySchema, cursorPageSchema, restoreObjectSchema } from '@shared/schemas' -import { requireAuth, requireTeamRole } from '../middleware/auth' import type { Env } from '../middleware/platform' import { deleteObject, getTrashObject, listTrashedObjects, restoreObject } from '../usecases/object' import { badRequest, type Matter, notFound } from '../usecases/ports' @@ -53,14 +53,17 @@ const trashPageSchema = cursorPageSchema(matterSchema, 'TrashObjectPage') const idParam = z.object({ id: z.string() }) const listTrashRoute = authRoute( - { access: 'session', minTeamRole: 'viewer' }, + { + access: 'protected', + scopes: [AuthorizationScope.OBJECTS_READ], + minTeamRole: 'viewer', + }, { operationId: 'listTrashObjects', summary: 'List trashed objects', tags: ['Trash'], method: 'get', path: '/objects', - middleware: [requireAuth, requireTeamRole('viewer')] as const, request: { query: cursorPageQuerySchema }, responses: { 200: jsonContent(trashPageSchema, 'Trashed objects (roots only)'), @@ -70,14 +73,17 @@ const listTrashRoute = authRoute( ) const getTrashObjectRoute = authRoute( - { access: 'session', minTeamRole: 'viewer' }, + { + access: 'protected', + scopes: [AuthorizationScope.OBJECTS_READ], + minTeamRole: 'viewer', + }, { operationId: 'getTrashObject', summary: 'Get trashed object', tags: ['Trash'], method: 'get', path: '/objects/{id}', - middleware: [requireAuth, requireTeamRole('viewer')] as const, request: { params: idParam }, responses: { 200: jsonContent(matterSchema, 'Trashed object'), @@ -88,14 +94,17 @@ const getTrashObjectRoute = authRoute( ) const restoreObjectRoute = authRoute( - { access: 'session', minTeamRole: 'editor' }, + { + access: 'protected', + scopes: [AuthorizationScope.OBJECTS_UPDATE], + minTeamRole: 'editor', + }, { operationId: 'restoreObject', summary: 'Restore trashed object', tags: ['Trash'], method: 'post', path: '/objects/{id}/restorations', - middleware: [requireAuth, requireTeamRole('editor')] as const, request: { params: idParam, ...jsonBody(restoreObjectSchema) }, responses: { 200: jsonContent(matterSchema, 'Restored object'), @@ -107,14 +116,17 @@ const restoreObjectRoute = authRoute( ) const purgeObjectRoute = authRoute( - { access: 'session', minTeamRole: 'editor' }, + { + access: 'protected', + scopes: [AuthorizationScope.OBJECTS_PURGE], + minTeamRole: 'editor', + }, { operationId: 'purgeTrashObject', summary: 'Permanently delete trashed object', tags: ['Trash'], method: 'delete', path: '/objects/{id}', - middleware: [requireAuth, requireTeamRole('editor')] as const, request: { params: idParam }, responses: { 204: { description: 'Permanently removed (recursive subtree purge)' }, diff --git a/server/http/webdav.ts b/server/http/webdav.ts index 6ce01ec5..763c90d0 100644 --- a/server/http/webdav.ts +++ b/server/http/webdav.ts @@ -69,7 +69,7 @@ import { const WEBDAV_METHOD_SCOPES: Record< string, - Array<{ resource: 'objects'; action: 'read' | 'create' | 'update' | 'delete' | 'move' }> + Array<{ resource: 'objects'; action: 'read' | 'create' | 'update' | 'delete' }> > = { OPTIONS: [{ resource: 'objects', action: 'read' }], PROPFIND: [{ resource: 'objects', action: 'read' }], @@ -88,7 +88,7 @@ const WEBDAV_METHOD_SCOPES: Record< LOCK: [{ resource: 'objects', action: 'update' }], UNLOCK: [{ resource: 'objects', action: 'update' }], DELETE: [{ resource: 'objects', action: 'delete' }], - MOVE: [{ resource: 'objects', action: 'move' }], + MOVE: [{ resource: 'objects', action: 'update' }], } type DavContext = Context diff --git a/server/scripts/backfill-api-key-scopes.test.ts b/server/scripts/backfill-api-key-scopes.test.ts index 35cfea0c..12933640 100644 --- a/server/scripts/backfill-api-key-scopes.test.ts +++ b/server/scripts/backfill-api-key-scopes.test.ts @@ -68,7 +68,7 @@ describe('API key scope backfill', () => { permissions: '{"images":["upload"]}', }) expect(db.prepare('SELECT permissions FROM apikey WHERE id = ?').get('webdav')).toEqual({ - permissions: '{"objects":["create","delete","move","read","update"]}', + permissions: '{"objects":["create","delete","read","update"]}', }) expect(db.prepare('SELECT permissions FROM apikey WHERE id = ?').get('remote')).toEqual({ permissions: '{"download-tasks":["cancel","create","read"]}', @@ -176,7 +176,7 @@ describe('API key scope backfill', () => { { id: 'legacy', before: '{"webdav":["write"]}', - after: '{"objects":["create","delete","move","update"]}', + after: '{"objects":["create","delete","update"]}', }, ]) diff --git a/server/usecases/object.test.ts b/server/usecases/object.test.ts index 2e0fb21d..776b7cab 100644 --- a/server/usecases/object.test.ts +++ b/server/usecases/object.test.ts @@ -239,6 +239,22 @@ describe('object usecase', () => { expect(list).toHaveBeenCalledWith('o2', expect.anything()) }) + it('forbids an override org when the actor is fixed to a workspace', async () => { + const list = vi.fn() + const canReadOrg = vi.fn() + const { deps } = makeDeps({ matter: { list }, org: { canReadOrg } }) + const out = await listObjects(deps, { + orgId: 'o1', + userId: 'u1', + fixedOrgId: 'o1', + orgOverride: 'o2', + filters: { parent: '', pageSize: 20 }, + }) + expectError(out, 403, 'Forbidden') + expect(canReadOrg).not.toHaveBeenCalled() + expect(list).not.toHaveBeenCalled() + }) + it('forbids an override org the user cannot read', async () => { const list = vi.fn() const { deps } = makeDeps({ matter: { list }, org: { canReadOrg: async () => false } }) diff --git a/server/usecases/object.ts b/server/usecases/object.ts index aa9d606b..308cb203 100644 --- a/server/usecases/object.ts +++ b/server/usecases/object.ts @@ -122,6 +122,7 @@ export async function listObjects( params: { orgId: string userId: string + fixedOrgId?: string | null orgOverride?: string filters: MatterListFilters }, @@ -130,6 +131,7 @@ export async function listObjects( // Optional org override so pickers (e.g. cross-space transfer) can browse // folders of another space the user has access to. if (params.orgOverride && params.orgOverride !== orgId) { + if (params.fixedOrgId) return { ok: false, error: forbidden() } if (!(await deps.org.canReadOrg(params.userId, params.orgOverride))) { return { ok: false, error: forbidden() } } diff --git a/server/usecases/ports/share.ts b/server/usecases/ports/share.ts index 963a26a3..16a88d39 100644 --- a/server/usecases/ports/share.ts +++ b/server/usecases/ports/share.ts @@ -85,12 +85,12 @@ export interface ShareRepo { listPublicProfileShares(username: string, now: Date): Promise listForApi( creatorId: string, - opts: { pageSize: number; status?: string; after?: { createdAt: Date; id: string } }, + opts: { pageSize: number; status?: string; orgId?: string; after?: { createdAt: Date; id: string } }, ): Promise<{ items: ShareListItem[]; nextBoundary: { createdAt: Date; id: string } | null }> listReceivedForApi( userId: string, userEmail: string | null, - opts: { pageSize: number; after?: { createdAt: Date; id: string } }, + opts: { pageSize: number; orgId?: string; after?: { createdAt: Date; id: string } }, ): Promise<{ items: ShareListItem[]; nextBoundary: { createdAt: Date; id: string } | null }> // Matter reads supporting the save-to-drive flow. They read the matters table // and are co-located in the share repo while matter remains unmigrated. diff --git a/server/usecases/share.test.ts b/server/usecases/share.test.ts index 384f68cc..a041bf11 100644 --- a/server/usecases/share.test.ts +++ b/server/usecases/share.test.ts @@ -30,6 +30,7 @@ import { revokeShare, type ShareDeps, saveShare, + setSharePrivacy, verifySharePassword, viewShare, } from './share' @@ -845,8 +846,15 @@ describe('listShares', () => { const listForApi = vi.fn(async () => ({ items: [sentItem], nextBoundary })) const { deps } = makeDeps({ share: { listForApi } }) const after = { createdAt: new Date('2025-02-01'), id: 's-2' } - const out = await listShares(deps, { userId: 'u1', box: 'sent', pageSize: 10, status: 'active', after }) - expect(listForApi).toHaveBeenCalledWith('u1', { pageSize: 10, status: 'active', after }) + const out = await listShares(deps, { + userId: 'u1', + box: 'sent', + pageSize: 10, + status: 'active', + fixedOrgId: 'o-1', + after, + }) + expect(listForApi).toHaveBeenCalledWith('u1', { pageSize: 10, status: 'active', orgId: 'o-1', after }) expect(out).toEqual({ items: [sentItem], nextBoundary }) }) @@ -865,7 +873,11 @@ describe('listShares', () => { const { deps } = makeDeps({ share: { getUserEmail, listReceivedForApi } }) const out = await listShares(deps, { userId: 'u1', box: 'received', pageSize: 20 }) expect(getUserEmail).toHaveBeenCalledWith('u1') - expect(listReceivedForApi).toHaveBeenCalledWith('u1', 'me@example.com', { pageSize: 20, after: undefined }) + expect(listReceivedForApi).toHaveBeenCalledWith('u1', 'me@example.com', { + pageSize: 20, + orgId: undefined, + after: undefined, + }) expect(out).toEqual({ items: [sentItem], nextBoundary: null }) }) }) @@ -981,6 +993,22 @@ describe('createShare', () => { }) }) +// ─── setSharePrivacy ───────────────────────────────────────────────────────── + +describe('setSharePrivacy', () => { + it('returns forbidden when a fixed org does not match the share org', async () => { + const setPrivacy = vi.fn() + const { deps } = makeDeps({ share: { setPrivacy } }) + expectError( + await setSharePrivacy(deps, { token: 'sk_token1', userId: 'creator-1', fixedOrgId: 'other-org', private: true }), + 403, + undefined, + 'Forbidden', + ) + expect(setPrivacy).not.toHaveBeenCalled() + }) +}) + // ─── revokeShare ───────────────────────────────────────────────────────────── describe('revokeShare', () => { @@ -1001,6 +1029,18 @@ describe('revokeShare', () => { expectError(await revokeShare(deps, { token: 't', userId: 'someone-else' }), 403, undefined, 'Forbidden') }) + it('returns forbidden when a fixed org does not match the share org', async () => { + const revokeByToken = vi.fn() + const { deps } = makeDeps({ share: { revokeByToken } }) + expectError( + await revokeShare(deps, { token: 'sk_token1', userId: 'creator-1', fixedOrgId: 'other-org' }), + 403, + undefined, + 'Forbidden', + ) + expect(revokeByToken).not.toHaveBeenCalled() + }) + it('returns not_found when the scoped revoke loses the race', async () => { const { deps } = makeDeps({ share: { revokeByToken: async () => false } }) expectError(await revokeShare(deps, { token: 'sk_token1', userId: 'creator-1' }), 404, undefined, 'Not found') @@ -1093,6 +1133,13 @@ describe('saveShare', () => { expectError(await saveShare(deps, baseParams), 403, undefined, 'Forbidden') }) + it('returns forbidden before org write checks when a fixed target org does not match', async () => { + const canWriteToOrg = vi.fn() + const { deps } = makeDeps({ org: { canWriteToOrg } }) + expectError(await saveShare(deps, { ...baseParams, fixedTargetOrgId: 'other-org' }), 403, undefined, 'Forbidden') + expect(canWriteToOrg).not.toHaveBeenCalled() + }) + it('returns quota_exceeded when the target org lacks quota', async () => { const computeSourceBytes = vi.fn(async () => 5000) const hasQuotaForBytes = vi.fn(async () => false) diff --git a/server/usecases/share.ts b/server/usecases/share.ts index 213427d9..88d15bab 100644 --- a/server/usecases/share.ts +++ b/server/usecases/share.ts @@ -456,6 +456,7 @@ export type ListSharesParams = { box: 'received' | 'sent' | undefined pageSize: number status?: string + fixedOrgId?: string | null after?: { createdAt: Date; id: string } } @@ -464,10 +465,10 @@ export async function listShares(deps: ShareDeps, params: ListSharesParams) { if (box === 'received') { const email = await deps.share.getUserEmail(userId) - return deps.share.listReceivedForApi(userId, email, { pageSize, after }) + return deps.share.listReceivedForApi(userId, email, { pageSize, orgId: params.fixedOrgId ?? undefined, after }) } - return deps.share.listForApi(userId, { pageSize, status, after }) + return deps.share.listForApi(userId, { pageSize, status, orgId: params.fixedOrgId ?? undefined, after }) } // ─── POST / — create a share (notify + activity; map create errors) ────────── @@ -563,6 +564,7 @@ export async function createShare( export type SetSharePrivacyParams = { token: string userId: string + fixedOrgId?: string | null private: boolean } @@ -576,6 +578,7 @@ export async function setSharePrivacy(deps: ShareDeps, params: SetSharePrivacyPa } const { share, recipients } = resolved + if (params.fixedOrgId && share.orgId !== params.fixedOrgId) return { ok: false, error: forbidden() } if (share.creatorId !== userId) return { ok: false, error: forbidden() } if (share.kind !== 'landing' || recipients.length > 0) { return { @@ -595,7 +598,7 @@ export function listPublicProfileShares(deps: ShareDeps, username: string, now = // ─── PUT /:token/status — revoke (ownership-scoped) ────────────────────────── -export type RevokeShareParams = { token: string; userId: string; now?: Date } +export type RevokeShareParams = { token: string; userId: string; fixedOrgId?: string | null; now?: Date } export type RevokeShareOutcome = { ok: true; dto: ShareViewerDto | ShareCreatorDto } | { ok: false; error: AppError } @@ -610,6 +613,7 @@ export async function revokeShare(deps: ShareDeps, params: RevokeShareParams): P // to shares), so this path stays revocable. const resolved = await deps.share.resolveByToken(token) if (resolved.status === 'not_found' || resolved.status === 'revoked') return { ok: false, error: notFound() } + if (params.fixedOrgId && resolved.share.orgId !== params.fixedOrgId) return { ok: false, error: forbidden() } if (resolved.share.creatorId !== userId) return { ok: false, error: forbidden() } // Race-safe: revokeByToken scopes the UPDATE to (token, creatorId). An @@ -633,6 +637,7 @@ export type SaveShareParams = { token: string currentUserId: string targetOrgId: string + fixedTargetOrgId?: string | null targetParent: string accessCookie: string | undefined } @@ -662,6 +667,7 @@ export async function saveShare(deps: ShareDeps, params: SaveShareParams): Promi if (checkAccessGate(share.passwordHash, recipients, currentUserId, accessCookie) === 'password_required') return { ok: false, error: passwordRequired('Authentication required for password-protected share') } + if (params.fixedTargetOrgId && targetOrgId !== params.fixedTargetOrgId) return { ok: false, error: forbidden() } if (!(await deps.org.canWriteToOrg(currentUserId, targetOrgId))) return { ok: false, error: forbidden() } const totalBytes = await deps.share.computeSourceBytes(matter) diff --git a/shared/api-key-templates.ts b/shared/api-key-templates.ts index bc5cc5f8..9c258823 100644 --- a/shared/api-key-templates.ts +++ b/shared/api-key-templates.ts @@ -47,7 +47,9 @@ export const WEBDAV_API_KEY_PERMISSIONS = scopePermissions([ AuthorizationScope.OBJECTS_CREATE, AuthorizationScope.OBJECTS_UPDATE, AuthorizationScope.OBJECTS_DELETE, - AuthorizationScope.OBJECTS_MOVE, + AuthorizationScope.SHARES_READ, + AuthorizationScope.QUOTA_READ, + AuthorizationScope.STORAGE_USAGE_READ, ]) export const REMOTE_DOWNLOAD_API_KEY_PERMISSIONS = { ...scopePermissions([ diff --git a/shared/authorization.test.ts b/shared/authorization.test.ts index 024b99ca..8c2b152e 100644 --- a/shared/authorization.test.ts +++ b/shared/authorization.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { WEBDAV_API_KEY_PERMISSIONS } from './api-key-templates' import { AGENT_GRANTABLE_AUTHORIZATION_SCOPES, AuthorizationScope, @@ -24,4 +25,8 @@ describe('authorization scope registry', () => { expect(AGENT_GRANTABLE_AUTHORIZATION_SCOPES).not.toContain(AuthorizationScope.OBJECTS_PURGE) expect(scopePermissions([AuthorizationScope.OBJECTS_DELETE])).toEqual({ objects: ['delete'] }) }) + + it('does not grant share mutation scopes to user-wide WebDAV app passwords', () => { + expect(WEBDAV_API_KEY_PERMISSIONS.shares).toEqual(['read']) + }) }) diff --git a/shared/authorization.ts b/shared/authorization.ts index ec2436ed..40dcba38 100644 --- a/shared/authorization.ts +++ b/shared/authorization.ts @@ -3,10 +3,12 @@ export const AuthorizationScope = { 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', + SHARES_CREATE: 'shares:create', + SHARES_DELETE: 'shares:delete', + QUOTA_READ: 'quota:read', + STORAGE_USAGE_READ: 'storage-usage:read', IMAGES_UPLOAD: 'images:upload', DOWNLOAD_TASKS_READ: 'download-tasks:read', DOWNLOAD_TASKS_CREATE: 'download-tasks:create', diff --git a/spec/quotas.feature b/spec/quotas.feature index 1b0012cd..5f019045 100644 --- a/spec/quotas.feature +++ b/spec/quotas.feature @@ -57,11 +57,11 @@ Feature: Quotas When an authenticated user reads their quota Then the built-in 10MB default is returned - @quotas/me-no-org @api - Scenario: A user with no org has no quota - Given an authenticated user with no org - When they read their quota - Then the API responds 404 + @quotas/me-api-key @api + Scenario: Workspace API keys can read their bound workspace quota + Given a workspace API key with quota read scope + When it reads the personal quota API + Then the API returns the quota for the key workspace @quotas/me-effective @api Scenario: My quota includes my active entitlements