refactor(auth): unify route authorization around scopes (#544)

This commit is contained in:
Jasper Van
2026-07-29 23:26:07 -04:00
committed by GitHub
parent e50c19051a
commit a890e7bba2
45 changed files with 680 additions and 731 deletions
+12 -11
View File
@@ -259,7 +259,10 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
if (!path.startsWith('/api/auth/') || !pathItem || typeof pathItem !== 'object') continue
for (const operation of Object.values(pathItem)) {
if (!operation || typeof operation !== 'object') continue
Object.assign(operation, { 'x-mcp-ignore': true })
Object.assign(operation, {
'x-zpan-auth': { public: true, scopes: [] },
'x-mcp-ignore': true,
})
if (path.includes('/callback')) Object.assign(operation, { 'x-cli-ignore': true })
}
}
@@ -279,11 +282,10 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
const skipsPrincipalResolution = (path: string) =>
path === '/api/configz' || path === '/api/configz/' || path === '/api/health'
// authMiddleware is
// soft-fail: it populates userId/orgId/principal (or null) and never rejects an
// anonymous caller — per-route guards (requireAuth/requireAdmin/requireTeamRole,
// or a shared-secret/signature check) do the gating. Running it ahead of every
// /api route lets one router per resource mix public and authed endpoints.
// authMiddleware is soft-fail: it resolves the protocol-specific credential
// into a principal and protocol-neutral authorization context. Each authRoute
// declaration then performs the runtime authorization, so one router can mix
// public and protected endpoints without a second guard mapping.
app.use('/api/*', async (c, next) => {
if (skipsPrincipalResolution(c.req.path)) {
await next()
@@ -319,8 +321,8 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
// Mount routes separately to avoid deep type chain accumulation.
// Each .route() call is independent — TypeScript doesn't stack types.
// Authorization is per-route (requireAuth/requireAdmin/requireTeamRole), so a
// single resource path serves both public/user and admin callers.
// Authorization comes from each authRoute declaration, so a single resource
// path can serve public, user, and admin callers.
app.route('/api/objects', objects)
app.route('/api/shares', authedShares)
app.route('/api/trash', trash)
@@ -333,9 +335,8 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
app.route('/api/site/settings/email', emailConfig)
app.route('/api/site/settings/image-domains', imageDomainProvider)
app.route('/api/site/auth-providers', authProviders)
// Public/user router mounts BEFORE the admin router on a shared path: a sub-app's
// blanket `.use(requireAdmin)` becomes prefix-wide middleware, so mounting admin
// first would gate the public routes too.
// Public/user routes mount before admin routes on this shared path to preserve
// route matching order.
app.route('/api/site/invite-codes', publicInviteCodes)
app.route('/api/site/invite-codes', adminInviteCodes)
app.route('/api/site/invitations', adminSiteInvitations)
+2 -3
View File
@@ -1,19 +1,18 @@
import { OpenAPIHono } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { adminOverviewSchema } from '@shared/schemas'
import { requireAdmin } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import { getAdminOverview } from '../usecases/admin-overview'
import { authRoute, jsonContent } from './openapi'
const route = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.SITE_ANALYTICS_READ], siteRole: 'admin' },
{
operationId: 'getSiteAnalytics',
summary: 'Get site analytics',
tags: ['Site Analytics'],
method: 'get',
path: '/',
middleware: [requireAdmin] as const,
responses: { 200: jsonContent(adminOverviewSchema, 'Site analytics') },
},
)
+3 -6
View File
@@ -1,4 +1,5 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import {
adminAnalyticsGrowthSchema,
adminAnalyticsOperationsSchema,
@@ -8,7 +9,6 @@ import {
adminAnalyticsTrafficSchema,
} from '@shared/schemas'
import { addCalendarDays, utcDateStart } from '../domain/admin-stats-time'
import { requireAdmin } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import { requireFeature } from '../middleware/require-feature'
import {
@@ -61,17 +61,14 @@ function analyticsRoute(
gated = true,
) {
return authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.SITE_ANALYTICS_READ], siteRole: 'admin' },
{
operationId,
summary,
tags: ['Site Analytics'],
method: 'get',
path,
middleware: (gated ? [requireAdmin, requireFeature('analytics')] : [requireAdmin]) as [
typeof requireAdmin,
...Array<ReturnType<typeof requireFeature>>,
],
middleware: gated ? [requireFeature('analytics')] : [],
request: { query: rangeQuerySchema },
responses: { 200: jsonContent(schema, summary) },
},
+5 -9
View File
@@ -1,11 +1,11 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import {
agentApiKeyCreatedSchema,
agentApiKeyCreateSchema,
agentApiKeyListSchema,
agentApiKeyRotateSchema,
} from '@shared/schemas'
import { requireAuth } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import { createAgentApiKey, listAgentApiKeys, revokeAgentApiKey, rotateAgentApiKey } from '../usecases/agent-api-keys'
import { authRoute, errorResponse, jsonBody, jsonContent } from './openapi'
@@ -18,14 +18,13 @@ const listQuerySchema = z.object({
})
const listRoute = authRoute(
{ access: 'session' },
{ scopes: [AuthorizationScope.AGENT_API_KEYS_READ] },
{
operationId: 'listWorkspaceAgentApiKeys',
summary: 'List Agent API keys for a workspace',
tags: ['Agent Access'],
method: 'get',
path: '/{orgId}/agent-api-keys',
middleware: [requireAuth] as const,
request: { params: workspaceParamsSchema, query: listQuerySchema },
responses: {
200: jsonContent(agentApiKeyListSchema, 'Agent API keys'),
@@ -35,14 +34,13 @@ const listRoute = authRoute(
)
const createRoute = authRoute(
{ access: 'session' },
{ scopes: [AuthorizationScope.AGENT_API_KEYS_CREATE] },
{
operationId: 'createWorkspaceAgentApiKey',
summary: 'Create an Agent API key for a workspace',
tags: ['Agent Access'],
method: 'post',
path: '/{orgId}/agent-api-keys',
middleware: [requireAuth] as const,
request: { params: workspaceParamsSchema, ...jsonBody(agentApiKeyCreateSchema) },
responses: {
201: jsonContent(agentApiKeyCreatedSchema, 'Created Agent API key'),
@@ -53,14 +51,13 @@ const createRoute = authRoute(
)
const rotateRoute = authRoute(
{ access: 'session' },
{ scopes: [AuthorizationScope.AGENT_API_KEYS_UPDATE] },
{
operationId: 'rotateWorkspaceAgentApiKey',
summary: 'Rotate an Agent API key for a workspace',
tags: ['Agent Access'],
method: 'post',
path: '/{orgId}/agent-api-keys/{keyId}/rotations',
middleware: [requireAuth] as const,
request: { params: keyParamsSchema, ...jsonBody(agentApiKeyRotateSchema) },
responses: {
201: jsonContent(agentApiKeyCreatedSchema, 'Rotated Agent API key'),
@@ -73,14 +70,13 @@ const rotateRoute = authRoute(
)
const revokeRoute = authRoute(
{ access: 'session' },
{ scopes: [AuthorizationScope.AGENT_API_KEYS_DELETE] },
{
operationId: 'revokeWorkspaceAgentApiKey',
summary: 'Revoke an Agent API key for a workspace',
tags: ['Agent Access'],
method: 'delete',
path: '/{orgId}/agent-api-keys/{keyId}',
middleware: [requireAuth] as const,
request: { params: keyParamsSchema },
responses: {
204: { description: 'Revoked' },
+5 -9
View File
@@ -1,11 +1,11 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import {
agentOAuthConsentContextSchema,
agentOAuthConsentResultSchema,
agentOAuthConsentSubmitSchema,
agentOAuthGrantListSchema,
} from '@shared/schemas'
import { requireAuth } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import { getAgentOAuthConsentContext } from '../usecases/agent-oauth-consent'
import { listAgentOAuthGrants, revokeAgentOAuthGrant } from '../usecases/agent-oauth-grants'
@@ -15,14 +15,13 @@ const paramsSchema = z.object({ grantId: z.string().min(1) })
const consentContextQuerySchema = z.object({ oauthQuery: z.string().min(1) })
const consentContextRoute = authRoute(
{ access: 'session' },
{ scopes: [AuthorizationScope.AGENT_OAUTH_GRANTS_CREATE] },
{
operationId: 'getAgentOAuthConsentContext',
summary: 'Get pending Agent OAuth consent context',
tags: ['Agent Access'],
method: 'get',
path: '/agent-oauth-consent',
middleware: [requireAuth] as const,
request: { query: consentContextQuerySchema },
responses: {
200: jsonContent(agentOAuthConsentContextSchema, 'Agent OAuth consent context'),
@@ -33,14 +32,13 @@ const consentContextRoute = authRoute(
)
const consentSubmitRoute = authRoute(
{ access: 'session' },
{ scopes: [AuthorizationScope.AGENT_OAUTH_GRANTS_CREATE] },
{
operationId: 'submitAgentOAuthConsent',
summary: 'Submit Agent OAuth consent decision',
tags: ['Agent Access'],
method: 'post',
path: '/agent-oauth-consent',
middleware: [requireAuth] as const,
request: jsonBody(agentOAuthConsentSubmitSchema),
responses: {
200: jsonContent(agentOAuthConsentResultSchema, 'Agent OAuth consent result'),
@@ -51,14 +49,13 @@ const consentSubmitRoute = authRoute(
)
const listRoute = authRoute(
{ access: 'session' },
{ scopes: [AuthorizationScope.AGENT_OAUTH_GRANTS_READ] },
{
operationId: 'listAgentOAuthGrants',
summary: 'List Agent OAuth grants',
tags: ['Agent Access'],
method: 'get',
path: '/agent-oauth-grants',
middleware: [requireAuth] as const,
responses: {
200: jsonContent(agentOAuthGrantListSchema, 'Agent OAuth grants'),
},
@@ -66,14 +63,13 @@ const listRoute = authRoute(
)
const revokeRoute = authRoute(
{ access: 'session' },
{ scopes: [AuthorizationScope.AGENT_OAUTH_GRANTS_DELETE] },
{
operationId: 'revokeAgentOAuthGrant',
summary: 'Revoke an Agent OAuth grant',
tags: ['Agent Access'],
method: 'delete',
path: '/agent-oauth-grants/{grantId}',
middleware: [requireAuth] as const,
request: { params: paramsSchema },
responses: {
204: { description: 'Revoked' },
+6 -19
View File
@@ -1,7 +1,6 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { createBackgroundJobRequestSchema, cursorPageSchema, listBackgroundJobsQuerySchema } from '../../shared/schemas'
import { requireAuth } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import {
cancelBackgroundJob,
@@ -66,10 +65,7 @@ function requireOrg(c: { get(key: 'orgId'): string | null }): string {
}
const listRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ],
},
{ scopes: [AuthorizationScope.BACKGROUND_JOBS_READ] },
{
operationId: 'listBackgroundJobs',
summary: 'List background jobs',
@@ -85,14 +81,13 @@ const listRoute = authRoute(
)
const createJobRoute = authRoute(
{ access: 'session' },
{ scopes: [AuthorizationScope.BACKGROUND_JOBS_CREATE] },
{
operationId: 'createBackgroundJob',
summary: 'Create background job',
tags: ['Background Jobs'],
method: 'post',
path: '/',
middleware: [requireAuth] as const,
request: jsonBody(createBackgroundJobRequestSchema),
responses: {
201: jsonContent(backgroundJobSchema, 'Created background job'),
@@ -102,10 +97,7 @@ const createJobRoute = authRoute(
)
const statsRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ],
},
{ scopes: [AuthorizationScope.BACKGROUND_JOBS_READ] },
{
operationId: 'getBackgroundJobStats',
summary: 'Get active background job count',
@@ -120,10 +112,7 @@ const statsRoute = authRoute(
)
const getJobRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ],
},
{ scopes: [AuthorizationScope.BACKGROUND_JOBS_READ] },
{
operationId: 'getBackgroundJob',
summary: 'Get background job',
@@ -139,14 +128,13 @@ const getJobRoute = authRoute(
)
const cancelJobRoute = authRoute(
{ access: 'session' },
{ scopes: [AuthorizationScope.BACKGROUND_JOBS_UPDATE] },
{
operationId: 'cancelBackgroundJob',
summary: 'Cancel background job',
tags: ['Background Jobs'],
method: 'put',
path: '/{id}/status',
middleware: [requireAuth] as const,
request: { params: z.object({ id: z.string() }), ...jsonBody(cancelJobSchema) },
responses: {
200: jsonContent(backgroundJobSchema, 'Canceled background job'),
@@ -157,14 +145,13 @@ const cancelJobRoute = authRoute(
)
const retryJobRoute = authRoute(
{ access: 'session' },
{ scopes: [AuthorizationScope.BACKGROUND_JOBS_UPDATE] },
{
operationId: 'retryBackgroundJob',
summary: 'Retry background job',
tags: ['Background Jobs'],
method: 'post',
path: '/{id}/retries',
middleware: [requireAuth] as const,
request: { params: z.object({ id: z.string() }) },
responses: {
201: jsonContent(backgroundJobSchema, 'Retried background job'),
+1 -1
View File
@@ -7,7 +7,7 @@ import { getSiteConfig } from '../usecases/site/configz'
import { authRoute, jsonContent } from './openapi'
const getRoute = authRoute(
{ access: 'public' },
{ public: true },
{
operationId: 'getSiteConfig',
summary: 'Get public site configuration',
+9 -39
View File
@@ -105,10 +105,7 @@ const taskErrorResponses = {
}
const listRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ],
},
{ scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ] },
{
operationId: 'listDownloadTasks',
summary: 'List download tasks',
@@ -125,11 +122,7 @@ const listRoute = authRoute(
)
const downloaderTaskListRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ],
allowDownloader: true,
},
{ scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ] },
{
operationId: 'listDownloaderTasks',
summary: 'List tasks owned by the authenticated downloader',
@@ -146,11 +139,7 @@ const downloaderTaskListRoute = authRoute(
)
const createRouteDoc = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.DOWNLOAD_TASKS_CREATE],
minTeamRole: 'editor',
},
{ scopes: [AuthorizationScope.DOWNLOAD_TASKS_CREATE], minTeamRole: 'editor' },
{
operationId: 'createDownloadTask',
summary: 'Create download task',
@@ -166,10 +155,7 @@ const createRouteDoc = authRoute(
)
const getRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ],
},
{ scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ] },
{
operationId: 'getDownloadTask',
summary: 'Get download task',
@@ -185,10 +171,7 @@ const getRoute = authRoute(
)
const eventsRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ],
},
{ scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ] },
{
operationId: 'listDownloadTaskEvents',
summary: 'List download task timeline events',
@@ -204,11 +187,7 @@ const eventsRoute = authRoute(
)
const updateRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.DOWNLOAD_TASKS_CANCEL],
allowDownloader: true,
},
{ scopes: [AuthorizationScope.DOWNLOAD_TASKS_CANCEL] },
{
operationId: 'updateDownloadTask',
summary: 'Update download task',
@@ -224,10 +203,7 @@ const updateRoute = authRoute(
)
const statusRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.DOWNLOAD_TASKS_CANCEL],
},
{ scopes: [AuthorizationScope.DOWNLOAD_TASKS_CANCEL] },
{
operationId: 'setDownloadTaskStatus',
summary: 'Pause, resume, or cancel a task',
@@ -243,10 +219,7 @@ const statusRoute = authRoute(
)
const attemptRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.DOWNLOAD_TASKS_CANCEL],
},
{ scopes: [AuthorizationScope.DOWNLOAD_TASKS_CANCEL] },
{
operationId: 'retryDownloadTask',
summary: 'Retry or restart a task',
@@ -262,10 +235,7 @@ const attemptRoute = authRoute(
)
const deleteRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.DOWNLOAD_TASKS_CANCEL],
},
{ scopes: [AuthorizationScope.DOWNLOAD_TASKS_CANCEL] },
{
operationId: 'deleteDownloadTask',
summary: 'Delete download task',
+10 -13
View File
@@ -1,4 +1,5 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import {
createDownloaderResponseSchema,
createDownloaderSchema,
@@ -11,7 +12,6 @@ import {
} from '@shared/schemas'
import { FREE_DOWNLOADER_LIMIT } from '../../../shared/constants'
import { hasFeature } from '../../domain/licensing'
import { requireAdmin, requireDownloader, requireDownloaderRegistration } from '../../middleware/auth'
import type { Env } from '../../middleware/platform'
import {
createDownloader,
@@ -29,14 +29,13 @@ import { authRoute, errorResponse, jsonBody, jsonContent } from '../openapi'
const downloaderListSchema = pageSchema(downloaderSchema, 'DownloaderList')
const listRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.DOWNLOADERS_READ], siteRole: 'admin' },
{
operationId: 'listDownloaders',
summary: 'List downloaders',
tags: ['Downloaders'],
method: 'get',
path: '/',
middleware: [requireAdmin] as const,
responses: {
200: jsonContent(downloaderListSchema, 'Downloaders'),
401: errorResponse('Unauthorized'),
@@ -45,14 +44,16 @@ const listRoute = authRoute(
)
const createRouteDoc = authRoute(
{ access: 'anyOf', policies: [{ access: 'admin' }, { access: 'downloader-bootstrap' }] },
{
scopes: [AuthorizationScope.DOWNLOADERS_CREATE],
siteRole: 'admin',
},
{
operationId: 'createDownloader',
summary: 'Register downloader',
tags: ['Downloaders'],
method: 'post',
path: '/',
middleware: [requireDownloaderRegistration] as const,
request: jsonBody(createDownloaderSchema),
responses: {
201: jsonContent(createDownloaderResponseSchema, 'Downloader registration'),
@@ -63,14 +64,13 @@ const createRouteDoc = authRoute(
)
const updateRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.DOWNLOADERS_UPDATE], siteRole: 'admin' },
{
operationId: 'updateDownloader',
summary: 'Update downloader',
tags: ['Downloaders'],
method: 'patch',
path: '/{id}',
middleware: [requireAdmin] as const,
request: { params: z.object({ id: z.string() }), ...jsonBody(updateDownloaderSchema) },
responses: {
200: jsonContent(downloaderSchema, 'Updated downloader'),
@@ -81,14 +81,13 @@ const updateRoute = authRoute(
)
const updateCreditBillingRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.DOWNLOADERS_UPDATE], siteRole: 'admin' },
{
operationId: 'updateDownloaderCreditBilling',
summary: 'Update downloader credit billing',
tags: ['Downloaders'],
method: 'put',
path: '/{id}/credit-billing',
middleware: [requireAdmin] as const,
request: { params: z.object({ id: z.string() }), ...jsonBody(updateDownloaderCreditBillingSchema) },
responses: {
200: jsonContent(downloaderSchema, 'Updated downloader'),
@@ -99,14 +98,13 @@ const updateCreditBillingRoute = authRoute(
)
const deleteRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.DOWNLOADERS_DELETE], siteRole: 'admin' },
{
operationId: 'deleteDownloader',
summary: 'Delete downloader',
tags: ['Downloaders'],
method: 'delete',
path: '/{id}',
middleware: [requireAdmin] as const,
request: { params: z.object({ id: z.string() }) },
responses: {
204: { description: 'Deleted downloader' },
@@ -116,14 +114,13 @@ const deleteRoute = authRoute(
)
const heartbeatRoute = authRoute(
{ access: 'downloader' },
{ scopes: [AuthorizationScope.DOWNLOADERS_UPDATE] },
{
operationId: 'recordDownloaderHeartbeat',
summary: 'Send downloader heartbeat',
tags: ['Downloaders'],
method: 'post',
path: '/me/heartbeats',
middleware: [requireDownloader] as const,
request: jsonBody(downloaderHeartbeatSchema),
responses: {
200: jsonContent(downloaderHeartbeatResultSchema, 'Updated downloader and task commands'),
+1 -4
View File
@@ -27,10 +27,7 @@ const requireEventsAccess = createMiddleware<Env>(async (c, next) => {
// 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 = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ],
},
{ scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ] },
{
operationId: 'streamEvents',
tags: ['Events'],
+4 -7
View File
@@ -1,7 +1,7 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { putIhostConfigSchema } from '../../../shared/schemas'
import type { IhostConfigResponse } from '../../../shared/types'
import { requireAuth, requireTeamRole } from '../../middleware/auth'
import type { Env } from '../../middleware/platform'
import {
deleteImageHostingConfig,
@@ -82,14 +82,13 @@ function buildResponse(
}
const getRoute = authRoute(
{ access: 'session' },
{ scopes: [AuthorizationScope.IMAGE_HOSTING_CONFIG_READ] },
{
operationId: 'getImageHostingConfig',
summary: 'Get image-hosting config',
tags: ['Image Hosting'],
method: 'get',
path: '/',
middleware: [requireAuth] as const,
responses: {
200: jsonContent(ihostConfigSchema, 'Image-hosting config'),
401: errorResponse('Unauthorized'),
@@ -98,14 +97,13 @@ const getRoute = authRoute(
)
const putRoute = authRoute(
{ access: 'session', minTeamRole: 'owner' },
{ scopes: [AuthorizationScope.IMAGE_HOSTING_CONFIG_UPDATE], minTeamRole: 'owner' },
{
operationId: 'updateImageHostingConfig',
summary: 'Update image-hosting config',
tags: ['Image Hosting'],
method: 'put',
path: '/',
middleware: [requireAuth, requireTeamRole('owner')] as const,
request: jsonBody(putIhostConfigSchema),
responses: {
200: jsonContent(ihostConfigSchema, 'Updated config'),
@@ -117,14 +115,13 @@ const putRoute = authRoute(
)
const deleteRoute = authRoute(
{ access: 'session', minTeamRole: 'owner' },
{ scopes: [AuthorizationScope.IMAGE_HOSTING_CONFIG_DELETE], minTeamRole: 'owner' },
{
operationId: 'deleteImageHostingConfig',
summary: 'Delete image-hosting config',
tags: ['Image Hosting'],
method: 'delete',
path: '/',
middleware: [requireAuth, requireTeamRole('owner')] as const,
responses: {
204: { description: 'Deleted' },
401: errorResponse('Unauthorized'),
@@ -243,7 +243,7 @@ describe('POST /api/image-hosting/images/presign (JSON two-stage)', () => {
expect(res.status).toBe(401)
})
it('returns 401 for API key (presign requires session auth) [spec: image-hosting/presign-session-only]', async () => {
it('returns 403 for an API key missing images:create scope [spec: image-hosting/presign-scope-required]', async () => {
const { app, db, auth } = await createTestApp()
await insertStorage(db)
await authedHeaders(app)
@@ -257,7 +257,7 @@ describe('POST /api/image-hosting/images/presign (JSON two-stage)', () => {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
body: JSON.stringify({ path: 'test.png', mime: 'image/png', size: 1024 }),
})
expect(res.status).toBe(401)
expect(res.status).toBe(403)
})
it('returns 403 when org has no image_hosting_configs row [spec: image-hosting/requires-config]', async () => {
+8 -13
View File
@@ -1,4 +1,5 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { nanoid } from 'nanoid'
import {
ALLOWED_IMAGE_MIMES,
@@ -10,8 +11,7 @@ import {
import { buildImageUrl, validatePath } from '../../domain/image-hosting'
import { mapDomainError } from '../../lib/http-errors'
import { mimeToExt } from '../../lib/mime-utils'
import { requireAuth, requireTeamRole } from '../../middleware/auth'
import { requirePermission } from '../../middleware/authz'
import { authorize } from '../../middleware/authz'
import type { Env } from '../../middleware/platform'
import {
confirmImageHosting,
@@ -118,14 +118,13 @@ function detectMimeFromBytes(bytes: Uint8Array): string | null {
}
const presignRoute = authRoute(
{ access: 'session', minTeamRole: 'editor' },
{ scopes: [AuthorizationScope.IMAGES_CREATE], minTeamRole: 'editor' },
{
operationId: 'presignImageHostingUpload',
summary: 'Presign an image upload',
tags: ['Image Hosting'],
method: 'post',
path: '/images/presign',
middleware: [requireAuth, requireTeamRole('editor')] as const,
request: jsonBody(createIhostImageSchema),
responses: {
201: jsonContent(imageDraftSchema, 'Image upload draft'),
@@ -138,14 +137,13 @@ const presignRoute = authRoute(
)
const listRoute = authRoute(
{ access: 'session', minTeamRole: 'viewer' },
{ scopes: [AuthorizationScope.IMAGES_READ], minTeamRole: 'viewer' },
{
operationId: 'listImageHostings',
summary: 'List hosted images',
tags: ['Image Hosting'],
method: 'get',
path: '/images',
middleware: [requireAuth, requireTeamRole('viewer')] as const,
request: { query: listIhostImagesSchema },
responses: {
200: jsonContent(imageListSchema, 'Hosted images'),
@@ -156,14 +154,13 @@ const listRoute = authRoute(
)
const getRoute = authRoute(
{ access: 'session', minTeamRole: 'viewer' },
{ scopes: [AuthorizationScope.IMAGES_READ], minTeamRole: 'viewer' },
{
operationId: 'getImageHosting',
summary: 'Get a hosted image',
tags: ['Image Hosting'],
method: 'get',
path: '/images/{id}',
middleware: [requireAuth, requireTeamRole('viewer')] as const,
request: { params: z.object({ id: z.string() }) },
responses: {
200: jsonContent(imageHostingSchema, 'Hosted image'),
@@ -175,14 +172,13 @@ const getRoute = authRoute(
)
const confirmRoute = authRoute(
{ access: 'session', minTeamRole: 'editor' },
{ scopes: [AuthorizationScope.IMAGES_UPDATE], minTeamRole: 'editor' },
{
operationId: 'confirmImageHosting',
summary: 'Confirm an uploaded image',
tags: ['Image Hosting'],
method: 'put',
path: '/images/{id}/status',
middleware: [requireAuth, requireTeamRole('editor')] as const,
request: { params: z.object({ id: z.string() }) },
responses: {
200: jsonContent(imageHostingSchema, 'Confirmed image'),
@@ -195,14 +191,13 @@ const confirmRoute = authRoute(
)
const deleteRoute = authRoute(
{ access: 'session', minTeamRole: 'editor' },
{ scopes: [AuthorizationScope.IMAGES_DELETE], minTeamRole: 'editor' },
{
operationId: 'deleteImageHosting',
summary: 'Delete a hosted image',
tags: ['Image Hosting'],
method: 'delete',
path: '/images/{id}',
middleware: [requireAuth, requireTeamRole('editor')] as const,
request: { params: z.object({ id: z.string() }) },
responses: {
204: { description: 'Deleted' },
@@ -219,7 +214,7 @@ const app = new OpenAPIHono<Env>()
// 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('images', 'upload'), async (c) => {
app.post('/images', authorize({ scopes: [AuthorizationScope.IMAGES_UPLOAD] }), async (c) => {
const orgId = c.get('orgId')
if (!orgId) throw unauthorized()
+5 -9
View File
@@ -1,6 +1,6 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { cursorPageSchema, listNotificationsQuerySchema } from '@shared/schemas'
import { requireAuth } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import {
getUnreadCount,
@@ -56,41 +56,38 @@ function toNotificationDTO(n: NotificationRecord): NotificationDTO {
const notificationPageSchema = cursorPageSchema(notificationSchema, 'NotificationPage')
const listRoute = authRoute(
{ access: 'session' },
{ scopes: [AuthorizationScope.NOTIFICATIONS_READ] },
{
operationId: 'listNotifications',
summary: 'List notifications',
tags: ['Notifications'],
method: 'get',
path: '/',
middleware: [requireAuth] as const,
request: { query: listNotificationsQuerySchema },
responses: { 200: jsonContent(notificationPageSchema, 'Notifications') },
},
)
const statsRoute = authRoute(
{ access: 'session' },
{ scopes: [AuthorizationScope.NOTIFICATIONS_READ] },
{
operationId: 'getNotificationStats',
summary: 'Get unread notification count',
tags: ['Notifications'],
method: 'get',
path: '/stats',
middleware: [requireAuth] as const,
responses: { 200: jsonContent(z.object({ count: z.number().int() }), 'Unread count') },
},
)
const markReadRoute = authRoute(
{ access: 'session' },
{ scopes: [AuthorizationScope.NOTIFICATIONS_UPDATE] },
{
operationId: 'markNotificationRead',
summary: 'Mark a notification read',
tags: ['Notifications'],
method: 'patch',
path: '/{id}',
middleware: [requireAuth] as const,
request: { params: z.object({ id: z.string() }) },
responses: {
204: { description: 'Marked read' },
@@ -100,14 +97,13 @@ const markReadRoute = authRoute(
)
const markAllReadRoute = authRoute(
{ access: 'session' },
{ scopes: [AuthorizationScope.NOTIFICATIONS_UPDATE] },
{
operationId: 'markAllNotificationsRead',
summary: 'Mark all notifications read',
tags: ['Notifications'],
method: 'patch',
path: '/',
middleware: [requireAuth] as const,
responses: { 200: jsonContent(z.object({ count: z.number().int() }), 'Number marked read') },
},
)
+10 -50
View File
@@ -180,11 +180,7 @@ async function authorizeUploadSessionControl(
const cloudBaseUrl = (c: Context<Env>) => c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT
const listRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.OBJECTS_READ],
minTeamRole: 'viewer',
},
{ scopes: [AuthorizationScope.OBJECTS_READ], minTeamRole: 'viewer' },
{
operationId: 'listObjects',
summary: 'List objects',
@@ -201,11 +197,7 @@ const listRoute = authRoute(
)
const createObjectRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.OBJECTS_CREATE],
minTeamRole: 'editor',
},
{ scopes: [AuthorizationScope.OBJECTS_CREATE], minTeamRole: 'editor' },
{
operationId: 'createObject',
summary: 'Create object',
@@ -224,11 +216,7 @@ const createObjectRoute = authRoute(
)
const presignPartsRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.OBJECTS_CREATE],
minTeamRole: 'editor',
},
{ scopes: [AuthorizationScope.OBJECTS_CREATE], minTeamRole: 'editor' },
{
operationId: 'presignObjectUploadParts',
summary: 'Re-presign upload parts',
@@ -247,11 +235,7 @@ const presignPartsRoute = authRoute(
)
const completionsRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.OBJECTS_CREATE],
minTeamRole: 'editor',
},
{ scopes: [AuthorizationScope.OBJECTS_CREATE], minTeamRole: 'editor' },
{
operationId: 'completeObjectUpload',
summary: 'Complete upload',
@@ -271,11 +255,7 @@ const completionsRoute = authRoute(
)
const abortUploadRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.OBJECTS_CREATE],
minTeamRole: 'editor',
},
{ scopes: [AuthorizationScope.OBJECTS_CREATE], minTeamRole: 'editor' },
{
operationId: 'abortObjectUpload',
summary: 'Abort upload',
@@ -294,11 +274,7 @@ const abortUploadRoute = authRoute(
)
const getObjectRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.OBJECTS_READ],
minTeamRole: 'viewer',
},
{ scopes: [AuthorizationScope.OBJECTS_READ], minTeamRole: 'viewer' },
{
operationId: 'getObject',
summary: 'Get object',
@@ -317,11 +293,7 @@ const getObjectRoute = authRoute(
)
const patchObjectRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.OBJECTS_UPDATE],
minTeamRole: 'editor',
},
{ scopes: [AuthorizationScope.OBJECTS_UPDATE], minTeamRole: 'editor' },
{
operationId: 'updateObject',
summary: 'Update object',
@@ -338,11 +310,7 @@ const patchObjectRoute = authRoute(
)
const deleteObjectRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.OBJECTS_DELETE],
minTeamRole: 'editor',
},
{ scopes: [AuthorizationScope.OBJECTS_DELETE], minTeamRole: 'editor' },
{
operationId: 'deleteObject',
summary: 'Delete object',
@@ -361,11 +329,7 @@ const deleteObjectRoute = authRoute(
)
const copyObjectRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.OBJECTS_UPDATE],
minTeamRole: 'editor',
},
{ scopes: [AuthorizationScope.OBJECTS_UPDATE], minTeamRole: 'editor' },
{
operationId: 'copyObject',
summary: 'Copy object',
@@ -382,11 +346,7 @@ const copyObjectRoute = authRoute(
)
const transferObjectRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.OBJECTS_UPDATE],
minTeamRole: 'viewer',
},
{ scopes: [AuthorizationScope.OBJECTS_UPDATE], minTeamRole: 'viewer' },
{
operationId: 'transferObject',
summary: 'Transfer object to another space',
+37 -28
View File
@@ -1,7 +1,7 @@
import { createRoute, type RouteConfig, type z } from '@hono/zod-openapi'
import { AGENT_GRANTABLE_API_KEY_SCOPES } from '@shared/api-key-templates'
import { errorResponseSchema } from '@shared/schemas'
import { authorize, type RouteAuthorizationDeclaration } from '../middleware/authz'
import { authorize, type RouteAuthorizationDeclaration, type ScopedAuthorizationPolicy } 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(...)`
@@ -30,8 +30,7 @@ export function authRoute<P extends string, T extends Omit<RouteConfig, 'path'>
auth: RouteAuthorizationDeclaration,
config: T,
): T & { getRoutingPath(): string } {
const middleware =
auth.access === 'protected' ? [authorize(auth), ...((config.middleware ?? []) as [])] : config.middleware
const middleware = [authorize(auth), ...((config.middleware ?? []) as [])]
return createRoute({
...config,
middleware,
@@ -48,44 +47,54 @@ export function findOperationsMissingAuthContract(paths: Record<string, Record<s
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
if (hasValidAuthContract(operation)) continue
missing.push(`${method.toUpperCase()} ${path}`)
}
}
return missing
}
function hasValidAuthContract(operation: object): boolean {
if (!('x-zpan-auth' in operation)) return false
const auth = operation['x-zpan-auth']
if (!auth || typeof auth !== 'object' || !('public' in auth) || !('scopes' in auth)) return false
if (typeof auth.public !== 'boolean' || !Array.isArray(auth.scopes)) return false
return auth.public ? auth.scopes.length === 0 : auth.scopes.length > 0
}
function openApiSecurity(auth: RouteAuthorizationDeclaration): Record<string, string[]>[] {
if (auth.access === 'anyOf') return auth.policies.flatMap(openApiSecurity)
if (auth.access === 'public' || auth.access === 'internal' || auth.access === 'signed-webhook') return []
if (auth.access === 'admin' || auth.access === 'session') return [{ cookieAuth: [] }]
if (auth.access === 'downloader' || auth.access === 'downloader-bootstrap' || auth.access === 'task-upload-token') {
return [{ bearerAuth: [] }]
}
if (!auth.scopes?.length) return [{ bearerAuth: [] }, { cookieAuth: [] }]
return auth.scopes.every((scope) => AGENT_GRANTABLE_SCOPE_SET.has(scope))
? [{ agentOAuth2: [...auth.scopes] }, { agentApiKey: [...auth.scopes] }, { cookieAuth: [] }]
: [{ bearerAuth: [...auth.scopes] }, { cookieAuth: [] }]
if ('public' in auth) return []
return openApiPolicySecurity(auth)
}
function openApiCliMetadata(auth: RouteAuthorizationDeclaration): Record<string, boolean> {
if (auth.access === 'anyOf') {
return auth.policies.every((policy) => openApiCliMetadata(policy)['x-mcp-ignore']) ? { 'x-mcp-ignore': true } : {}
}
if (auth.access === 'public' || auth.access === 'protected') return {}
if (auth.access === 'internal') return { 'x-cli-ignore': true, 'x-mcp-ignore': true }
return { 'x-mcp-ignore': true }
if ('public' in auth) return {}
return isAgentCallablePolicy(auth) ? {} : { 'x-mcp-ignore': true }
}
function openApiAuthMetadata(auth: RouteAuthorizationDeclaration): Record<string, unknown> {
if (auth.access === 'anyOf') return { access: auth.access, policies: auth.policies.map(openApiAuthMetadata) }
if (auth.access === 'session') return { access: auth.access, minTeamRole: auth.minTeamRole ?? null }
if (auth.access !== 'protected') return { access: auth.access }
if ('public' in auth) return { public: true, scopes: [] }
return {
access: auth.access,
scopes: auth.scopes ?? [],
minTeamRole: auth.minTeamRole ?? null,
allowDownloader: auth.allowDownloader ?? false,
auditDenied: auth.auditDenied !== false,
public: false,
...openApiPolicyMetadata(auth),
}
}
function openApiPolicySecurity(policy: ScopedAuthorizationPolicy): Record<string, string[]>[] {
return policy.scopes.every((scope) => AGENT_GRANTABLE_SCOPE_SET.has(scope))
? [{ agentOAuth2: [...policy.scopes] }, { agentApiKey: [...policy.scopes] }, { cookieAuth: [] }]
: [{ bearerAuth: [...policy.scopes] }, { cookieAuth: [] }]
}
function isAgentCallablePolicy(policy: ScopedAuthorizationPolicy): boolean {
return policy.scopes.every((scope) => AGENT_GRANTABLE_SCOPE_SET.has(scope))
}
function openApiPolicyMetadata(policy: ScopedAuthorizationPolicy): Record<string, unknown> {
return {
scopes: [...policy.scopes],
minTeamRole: policy.minTeamRole ?? null,
siteRole: policy.siteRole ?? null,
auditDenied: policy.auditDenied !== false,
}
}
+2 -8
View File
@@ -1,7 +1,6 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { pageSchema } from '@shared/schemas'
import { requireAdmin } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import { notFound } from '../usecases/ports'
import { getUserQuota, listQuotaOverview } from '../usecases/quota'
@@ -47,24 +46,19 @@ const quotaOverviewItemSchema = effectiveQuotaSchema
const quotaOverviewSchema = pageSchema(quotaOverviewItemSchema, 'QuotaOverview')
const listQuotaOverviewRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.QUOTA_READ], siteRole: 'admin' },
{
operationId: 'listQuotaOverview',
summary: 'List quota overview across all spaces',
tags: ['Quotas'],
method: 'get',
path: '/',
middleware: [requireAdmin] as const,
responses: { 200: jsonContent(quotaOverviewSchema, 'Quota overview') },
},
)
const getMyQuotaRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.QUOTA_READ],
minTeamRole: 'viewer',
},
{ scopes: [AuthorizationScope.QUOTA_READ], minTeamRole: 'viewer' },
{
operationId: 'getMyQuota',
summary: "Get the current user's effective quota",
+1 -1
View File
@@ -93,7 +93,7 @@ async function insertFolder(
`)
}
describe('[CF] Public share routes — no requireAuth', () => {
describe('[CF] Public share routes — no session authorization', () => {
it('GET /api/shares/:token returns share metadata without auth', async () => {
const { app, db } = await buildApp()
const { orgId, userId } = await signUpAndGetIds(app, db)
+9 -29
View File
@@ -199,7 +199,7 @@ const verifyPasswordSchema = z.object({ password: z.string() })
// ─── PUBLIC SEGMENT ──────────────────────────────────────────────────────────
const viewShareRoute = authRoute(
{ access: 'public' },
{ public: true },
{
operationId: 'getShare',
summary: 'View a share',
@@ -216,7 +216,7 @@ const viewShareRoute = authRoute(
)
const verifyShareRoute = authRoute(
{ access: 'public' },
{ public: true },
{
operationId: 'verifySharePassword',
summary: 'Verify a share password',
@@ -233,7 +233,7 @@ const verifyShareRoute = authRoute(
)
const listShareObjectsRoute = authRoute(
{ access: 'public' },
{ public: true },
{
operationId: 'listShareObjects',
summary: 'List objects in a folder share',
@@ -252,7 +252,7 @@ const listShareObjectsRoute = authRoute(
)
const readShareReadmeRoute = authRoute(
{ access: 'public' },
{ public: true },
{
operationId: 'readShareReadme',
summary: 'Read a shared folder README',
@@ -409,11 +409,7 @@ export const publicShares = pub
// ─── AUTHED SEGMENT ─────────────────────────────────────────────────────────
const listSharesRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.SHARES_READ],
minTeamRole: 'viewer',
},
{ scopes: [AuthorizationScope.SHARES_READ], minTeamRole: 'viewer' },
{
operationId: 'listShares',
summary: 'List my shares',
@@ -426,11 +422,7 @@ const listSharesRoute = authRoute(
)
const createShareRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.SHARES_CREATE],
minTeamRole: 'editor',
},
{ scopes: [AuthorizationScope.SHARES_CREATE], minTeamRole: 'editor' },
{
operationId: 'createShare',
summary: 'Create a share',
@@ -447,11 +439,7 @@ const createShareRoute = authRoute(
)
const revokeShareRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.SHARES_DELETE],
minTeamRole: 'editor',
},
{ scopes: [AuthorizationScope.SHARES_DELETE], minTeamRole: 'editor' },
{
operationId: 'revokeShare',
summary: 'Revoke a share',
@@ -473,11 +461,7 @@ const revokeShareRoute = authRoute(
const sharePrivacySchema = z.object({ private: z.boolean() }).openapi('SharePrivacy')
const putSharePrivacyRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.SHARES_CREATE],
minTeamRole: 'editor',
},
{ scopes: [AuthorizationScope.SHARES_CREATE], minTeamRole: 'editor' },
{
operationId: 'putSharePrivacy',
summary: 'Set whether a share is hidden from the owner public profile',
@@ -498,11 +482,7 @@ const putSharePrivacyRoute = authRoute(
)
const saveShareRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.OBJECTS_CREATE],
minTeamRole: 'editor',
},
{ scopes: [AuthorizationScope.OBJECTS_CREATE], minTeamRole: 'editor' },
{
operationId: 'saveShare',
summary: 'Save a share to my drive',
+11 -11
View File
@@ -1,6 +1,6 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { announcementInputSchema, announcementStatusSchema, pageQuerySchema, pageSchema } from '@shared/schemas'
import { requireAdmin, requireAuth } from '../../middleware/auth'
import type { Env } from '../../middleware/platform'
import { requireFeature } from '../../middleware/require-feature'
import { type AnnouncementRecord, forbidden, notFound } from '../../usecases/ports'
@@ -51,14 +51,14 @@ const listAnnouncementsQuerySchema = pageQuerySchema.extend({
})
const listRoute = authRoute(
{ access: 'session' },
{ scopes: [AuthorizationScope.ANNOUNCEMENTS_READ] },
{
operationId: 'listAnnouncements',
summary: 'List announcements',
tags: ['Announcements'],
method: 'get',
path: '/',
middleware: [requireAuth, requireFeature('site_announcements')] as const,
middleware: [requireFeature('site_announcements')] as const,
request: { query: listAnnouncementsQuerySchema },
responses: {
200: jsonContent(announcementListSchema, 'Announcements'),
@@ -68,28 +68,28 @@ const listRoute = authRoute(
)
const createAnnouncementRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.ANNOUNCEMENTS_CREATE], siteRole: 'admin' },
{
operationId: 'createAnnouncement',
summary: 'Create announcement',
tags: ['Announcements'],
method: 'post',
path: '/',
middleware: [requireAdmin, requireFeature('site_announcements')] as const,
middleware: [requireFeature('site_announcements')] as const,
request: jsonBody(announcementInputSchema),
responses: { 201: jsonContent(announcementSchema, 'Created announcement') },
},
)
const getAnnouncementRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.ANNOUNCEMENTS_READ], siteRole: 'admin' },
{
operationId: 'getAnnouncement',
summary: 'Get announcement',
tags: ['Announcements'],
method: 'get',
path: '/{id}',
middleware: [requireAdmin, requireFeature('site_announcements')] as const,
middleware: [requireFeature('site_announcements')] as const,
request: { params: z.object({ id: z.string() }) },
responses: {
200: jsonContent(announcementSchema, 'Announcement'),
@@ -99,14 +99,14 @@ const getAnnouncementRoute = authRoute(
)
const updateAnnouncementRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.ANNOUNCEMENTS_UPDATE], siteRole: 'admin' },
{
operationId: 'updateAnnouncement',
summary: 'Update announcement',
tags: ['Announcements'],
method: 'put',
path: '/{id}',
middleware: [requireAdmin, requireFeature('site_announcements')] as const,
middleware: [requireFeature('site_announcements')] as const,
request: { params: z.object({ id: z.string() }), ...jsonBody(announcementInputSchema) },
responses: {
200: jsonContent(announcementSchema, 'Updated announcement'),
@@ -116,14 +116,14 @@ const updateAnnouncementRoute = authRoute(
)
const deleteAnnouncementRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.ANNOUNCEMENTS_DELETE], siteRole: 'admin' },
{
operationId: 'deleteAnnouncement',
summary: 'Delete announcement',
tags: ['Announcements'],
method: 'delete',
path: '/{id}',
middleware: [requireAdmin, requireFeature('site_announcements')] as const,
middleware: [requireFeature('site_announcements')] as const,
request: { params: z.object({ id: z.string() }) },
responses: {
204: { description: 'Deleted announcement' },
+3 -3
View File
@@ -1,6 +1,6 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { pageQuerySchema, pageSchema } from '@shared/schemas'
import { requireAdmin } from '../../middleware/auth'
import type { Env } from '../../middleware/platform'
import { requireFeature } from '../../middleware/require-feature'
import type { AdminAuditEventWithOrg } from '../../usecases/ports'
@@ -44,14 +44,14 @@ const listAuditQuerySchema = pageQuerySchema.extend({
})
const listRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.AUDIT_EVENTS_READ], siteRole: 'admin' },
{
operationId: 'listAuditEvents',
summary: 'List audit events',
tags: ['Audit'],
method: 'get',
path: '/',
middleware: [requireAdmin, requireFeature('audit_log')] as const,
middleware: [requireFeature('audit_log')] as const,
request: { query: listAuditQuerySchema },
responses: { 200: jsonContent(auditPageSchema, 'Audit events'), 400: errorResponse('Invalid query') },
},
+4 -7
View File
@@ -1,5 +1,5 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { requireAdmin } from '../../middleware/auth'
import { AuthorizationScope } from '@shared/authorization'
import type { Env } from '../../middleware/platform'
import { deleteAuthProvider, listAuthProviders, upsertAuthProvider } from '../../usecases/site/auth-provider'
import { authRoute, errorResponse, jsonBody, jsonContent } from '../openapi'
@@ -41,27 +41,25 @@ const upsertSchema = z.object({
})
const listRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.AUTH_PROVIDERS_READ], siteRole: 'admin' },
{
operationId: 'listAuthProviders',
summary: 'List auth providers',
tags: ['Auth Providers'],
method: 'get',
path: '/',
middleware: [requireAdmin] as const,
responses: { 200: jsonContent(authProviderListSchema, 'Auth providers') },
},
)
const upsertRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.AUTH_PROVIDERS_UPDATE], siteRole: 'admin' },
{
operationId: 'upsertAuthProvider',
summary: 'Create or update an auth provider',
tags: ['Auth Providers'],
method: 'put',
path: '/{providerId}',
middleware: [requireAdmin] as const,
request: { params: z.object({ providerId: z.string() }), ...jsonBody(upsertSchema) },
responses: {
200: jsonContent(authProviderSchema, 'Upserted auth provider'),
@@ -72,14 +70,13 @@ const upsertRoute = authRoute(
)
const deleteProviderRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.AUTH_PROVIDERS_DELETE], siteRole: 'admin' },
{
operationId: 'deleteAuthProvider',
summary: 'Delete an auth provider',
tags: ['Auth Providers'],
method: 'delete',
path: '/{providerId}',
middleware: [requireAdmin] as const,
request: { params: z.object({ providerId: z.string() }) },
responses: {
204: { description: 'Deleted auth provider' },
+5 -5
View File
@@ -1,6 +1,6 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { type BrandingField, type BrandingThemeMode, isBrandingThemePresetId } from '../../../shared/types'
import { requireAdmin } from '../../middleware/auth'
import type { Env } from '../../middleware/platform'
import { requireFeature } from '../../middleware/require-feature'
import { AppError, badRequest, payloadTooLarge, unsupportedMediaType } from '../../usecases/ports'
@@ -90,14 +90,14 @@ function parseThemeUpdate(form: FormData): { ok: true; values: ThemeUpdate } | {
}
const updateRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.BRANDING_UPDATE], siteRole: 'admin' },
{
operationId: 'updateBranding',
summary: 'Update branding',
tags: ['Branding'],
method: 'put',
path: '/',
middleware: [requireAdmin, requireFeature('white_label')] as const,
middleware: [requireFeature('white_label')] as const,
// Body is multipart/form-data (logo/favicon files + theme fields); parsed
// directly in the handler rather than via a request schema (the form validator
// conflicts with formData()).
@@ -112,14 +112,14 @@ const updateRoute = authRoute(
)
const resetRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.BRANDING_UPDATE], siteRole: 'admin' },
{
operationId: 'resetBrandingField',
summary: 'Reset a branding field',
tags: ['Branding'],
method: 'delete',
path: '/{field}',
middleware: [requireAdmin, requireFeature('white_label')] as const,
middleware: [requireFeature('white_label')] as const,
request: { params: z.object({ field: z.string() }) },
responses: {
204: { description: 'Reset field' },
+4 -7
View File
@@ -1,6 +1,6 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { createTestEmailSchema, emailSettingsSchema, updateEmailSettingsSchema } from '@shared/schemas'
import { requireAdmin } from '../../middleware/auth'
import type { Env } from '../../middleware/platform'
import { getEmailConfig, saveEmailConfig, sendTestEmail } from '../../usecases/site/email-config'
import { authRoute, errorResponse, jsonContent } from '../openapi'
@@ -8,27 +8,25 @@ import { authRoute, errorResponse, jsonContent } from '../openapi'
const successSchema = z.object({ success: z.boolean() })
const getRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.EMAIL_CONFIG_READ], siteRole: 'admin' },
{
operationId: 'getEmailConfig',
summary: 'Get email configuration',
tags: ['Email Config'],
method: 'get',
path: '/',
middleware: [requireAdmin] as const,
responses: { 200: jsonContent(emailSettingsSchema, 'Email settings') },
},
)
const saveRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.EMAIL_CONFIG_UPDATE], siteRole: 'admin' },
{
operationId: 'saveEmailConfig',
summary: 'Save email configuration',
tags: ['Email Config'],
method: 'put',
path: '/',
middleware: [requireAdmin] as const,
request: { body: { content: { 'application/json': { schema: updateEmailSettingsSchema } }, required: true } },
responses: {
200: jsonContent(successSchema, 'Saved'),
@@ -38,14 +36,13 @@ const saveRoute = authRoute(
)
const testRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.EMAIL_CONFIG_TEST], siteRole: 'admin' },
{
operationId: 'sendTestEmail',
summary: 'Send a test email',
tags: ['Email Config'],
method: 'post',
path: '/test-messages',
middleware: [requireAdmin] as const,
request: { body: { content: { 'application/json': { schema: createTestEmailSchema } }, required: true } },
responses: {
200: jsonContent(successSchema, 'Sent'),
+6 -7
View File
@@ -1,6 +1,6 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { imageDomainProviderResponseSchema, updateImageDomainSettingsSchema } from '@shared/schemas'
import { requireAdmin } from '../../middleware/auth'
import type { Env } from '../../middleware/platform'
import { requireFeature } from '../../middleware/require-feature'
import {
@@ -13,27 +13,26 @@ import { authRoute, errorResponse, jsonContent } from '../openapi'
const successSchema = z.object({ success: z.literal(true) })
const getRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.IMAGE_DOMAIN_PROVIDER_READ], siteRole: 'admin' },
{
operationId: 'getImageDomainProvider',
summary: 'Get image custom-domain provider',
tags: ['Image Domain Provider'],
method: 'get',
path: '/',
middleware: [requireAdmin] as const,
responses: { 200: jsonContent(imageDomainProviderResponseSchema, 'Image custom-domain provider') },
},
)
const saveRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.IMAGE_DOMAIN_PROVIDER_UPDATE], siteRole: 'admin' },
{
operationId: 'saveImageDomainProvider',
summary: 'Save image custom-domain provider',
tags: ['Image Domain Provider'],
method: 'put',
path: '/',
middleware: [requireAdmin, requireFeature('image_custom_domains')] as const,
middleware: [requireFeature('image_custom_domains')] as const,
request: {
body: { content: { 'application/json': { schema: updateImageDomainSettingsSchema } }, required: true },
},
@@ -45,14 +44,14 @@ const saveRoute = authRoute(
)
const testRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.IMAGE_DOMAIN_PROVIDER_TEST], siteRole: 'admin' },
{
operationId: 'testImageDomainProvider',
summary: 'Test image custom-domain provider and reconcile domains',
tags: ['Image Domain Provider'],
method: 'post',
path: '/tests',
middleware: [requireAdmin, requireFeature('image_custom_domains')] as const,
middleware: [requireFeature('image_custom_domains')] as const,
responses: {
200: jsonContent(successSchema, 'Provider ready'),
400: errorResponse('Provider test failed'),
+6 -10
View File
@@ -1,6 +1,6 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { pageQuerySchema, pageSchema } from '@shared/schemas'
import { requireAdmin } from '../../middleware/auth'
import type { Env } from '../../middleware/platform'
import { notFound, unauthorized } from '../../usecases/ports'
import {
@@ -36,28 +36,26 @@ const siteInvitationListSchema = pageSchema(siteInvitationSchema, 'SiteInvitatio
const createSchema = z.object({ email: z.string().email() })
const listRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.SITE_INVITATIONS_READ], siteRole: 'admin' },
{
operationId: 'listSiteInvitations',
summary: 'List site invitations',
tags: ['Invitations'],
method: 'get',
path: '/',
middleware: [requireAdmin] as const,
request: { query: pageQuerySchema },
responses: { 200: jsonContent(siteInvitationListSchema, 'Invitations') },
},
)
const createRouteDoc = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.SITE_INVITATIONS_CREATE], siteRole: 'admin' },
{
operationId: 'createSiteInvitation',
summary: 'Create site invitation',
tags: ['Invitations'],
method: 'post',
path: '/',
middleware: [requireAdmin] as const,
request: jsonBody(createSchema),
responses: {
201: jsonContent(siteInvitationSchema, 'Created invitation'),
@@ -68,14 +66,13 @@ const createRouteDoc = authRoute(
)
const resendRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.SITE_INVITATIONS_CREATE], siteRole: 'admin' },
{
operationId: 'resendSiteInvitation',
summary: 'Resend a site invitation',
tags: ['Invitations'],
method: 'post',
path: '/{id}/deliveries',
middleware: [requireAdmin] as const,
request: { params: z.object({ id: z.string() }) },
responses: {
200: jsonContent(siteInvitationSchema, 'Resent invitation'),
@@ -86,14 +83,13 @@ const resendRoute = authRoute(
)
const revokeRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.SITE_INVITATIONS_DELETE], siteRole: 'admin' },
{
operationId: 'revokeSiteInvitation',
summary: 'Revoke a site invitation',
tags: ['Invitations'],
method: 'delete',
path: '/{id}',
middleware: [requireAdmin] as const,
request: { params: z.object({ id: z.string() }) },
responses: {
204: { description: 'Revoked invitation' },
@@ -105,7 +101,7 @@ const revokeRoute = authRoute(
)
const getByTokenRoute = authRoute(
{ access: 'public' },
{ public: true },
{
operationId: 'getSiteInvitation',
summary: 'Get a site invitation by token',
+5 -8
View File
@@ -1,6 +1,6 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { pageQuerySchema, pageSchema } from '@shared/schemas'
import { requireAdmin } from '../../middleware/auth'
import type { Env } from '../../middleware/platform'
import { type InviteCodeRecord, unauthorized } from '../../usecases/ports'
import {
@@ -49,28 +49,26 @@ const validateSchema = z.object({
})
const listRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.INVITE_CODES_READ], siteRole: 'admin' },
{
operationId: 'listInviteCodes',
summary: 'List invite codes',
tags: ['Invite Codes'],
method: 'get',
path: '/',
middleware: [requireAdmin] as const,
request: { query: pageQuerySchema },
responses: { 200: jsonContent(inviteCodeListSchema, 'Invite codes') },
},
)
const generateRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.INVITE_CODES_CREATE], siteRole: 'admin' },
{
operationId: 'generateInviteCodes',
summary: 'Generate invite codes',
tags: ['Invite Codes'],
method: 'post',
path: '/',
middleware: [requireAdmin] as const,
request: jsonBody(generateSchema),
responses: {
201: jsonContent(z.object({ codes: z.array(inviteCodeSchema) }), 'Generated invite codes'),
@@ -80,14 +78,13 @@ const generateRoute = authRoute(
)
const deleteRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.INVITE_CODES_DELETE], siteRole: 'admin' },
{
operationId: 'deleteInviteCode',
summary: 'Delete invite code',
tags: ['Invite Codes'],
method: 'delete',
path: '/{id}',
middleware: [requireAdmin] as const,
request: { params: z.object({ id: z.string() }) },
responses: {
204: { description: 'Deleted invite code' },
@@ -98,7 +95,7 @@ const deleteRoute = authRoute(
)
const validateRoute = authRoute(
{ access: 'public' },
{ public: true },
{
operationId: 'validateInviteCode',
summary: 'Validate an invite code',
+7 -13
View File
@@ -1,9 +1,9 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import type { Context } from 'hono'
import { ZPAN_CLOUD_URL_DEFAULT } from '../../../shared/constants'
import type { BindingState } from '../../../shared/types'
import { originFromRequestUrl } from '../../domain/site-public-origin'
import { requireAdmin, requireAuth } from '../../middleware/auth'
import type { Env } from '../../middleware/platform'
import { runtimeInfo } from '../../usecases/site/instance-info'
import {
@@ -79,53 +79,49 @@ const pairingStatusSchema = z
.openapi('LicensePairingStatus')
const entitlementsRoute = authRoute(
{ access: 'session' },
{ scopes: [AuthorizationScope.LICENSING_READ] },
{
operationId: 'getLicenseEntitlements',
summary: 'Get the current user-visible license entitlements',
tags: ['Licensing'],
method: 'get',
path: '/entitlements',
middleware: [requireAuth] as const,
responses: { 200: jsonContent(licenseEntitlementsSchema, 'License entitlements') },
},
)
const bindingRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.LICENSING_READ], siteRole: 'admin' },
{
operationId: 'getLicenseBinding',
summary: 'Get license binding details',
tags: ['Licensing'],
method: 'get',
path: '/binding',
middleware: [requireAdmin] as const,
responses: { 200: jsonContent(bindingStateSchema, 'License binding') },
},
)
const initiatePairingRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.LICENSING_UPDATE], siteRole: 'admin' },
{
operationId: 'initiateLicensePairing',
summary: 'Initiate cloud pairing',
tags: ['Licensing'],
method: 'post',
path: '/pairings',
middleware: [requireAdmin] as const,
responses: { 200: jsonContent(pairingSchema, 'Pairing') },
},
)
const pollPairingRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.LICENSING_READ], siteRole: 'admin' },
{
operationId: 'pollLicensePairing',
summary: 'Poll cloud pairing status',
tags: ['Licensing'],
method: 'get',
path: '/pairings/{code}',
middleware: [requireAdmin] as const,
request: { params: z.object({ code: z.string() }) },
responses: {
200: jsonContent(pairingStatusSchema, 'Pairing status'),
@@ -135,14 +131,13 @@ const pollPairingRoute = authRoute(
)
const refreshRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.LICENSING_UPDATE], siteRole: 'admin' },
{
operationId: 'refreshLicense',
summary: 'Refresh the license',
tags: ['Licensing'],
method: 'post',
path: '/refresh-runs',
middleware: [requireAdmin] as const,
responses: {
200: jsonContent(z.object({ success: z.boolean(), last_refresh_at: z.number().int().nullable() }), 'Refreshed'),
},
@@ -150,14 +145,13 @@ const refreshRoute = authRoute(
)
const unbindRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.LICENSING_UPDATE], siteRole: 'admin' },
{
operationId: 'unbindLicense',
summary: 'Unbind the license',
tags: ['Licensing'],
method: 'delete',
path: '/binding',
middleware: [requireAdmin] as const,
responses: {
204: { description: 'Unbound' },
502: errorResponse('Cloud unbind failed'),
+8 -15
View File
@@ -1,4 +1,5 @@
import { OpenAPIHono } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import {
siteCaptchaSettingsSchema,
siteIdentitySettingsSchema,
@@ -12,7 +13,6 @@ import {
updateSiteRegistrationSchema,
updateSiteWebDavSchema,
} from '@shared/schemas'
import { requireAdmin } from '../../middleware/auth'
import type { Env } from '../../middleware/platform'
import {
getSiteSettings,
@@ -26,27 +26,25 @@ import {
import { authRoute, errorResponse, jsonBody, jsonContent } from '../openapi'
const getRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.SITE_SETTINGS_READ], siteRole: 'admin' },
{
operationId: 'getSiteSettings',
summary: 'Get editable site settings',
tags: ['Site Settings'],
method: 'get',
path: '/',
middleware: [requireAdmin] as const,
responses: { 200: jsonContent(siteSettingsSchema, 'Editable site settings') },
},
)
const updateIdentityRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.SITE_SETTINGS_UPDATE], siteRole: 'admin' },
{
operationId: 'updateSiteIdentity',
summary: 'Update site identity settings',
tags: ['Site Settings'],
method: 'put',
path: '/identity',
middleware: [requireAdmin] as const,
request: jsonBody(updateSiteIdentitySchema),
responses: {
200: jsonContent(siteIdentitySettingsSchema, 'Updated site identity settings'),
@@ -57,14 +55,13 @@ const updateIdentityRoute = authRoute(
)
const updateRegistrationRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.SITE_SETTINGS_UPDATE], siteRole: 'admin' },
{
operationId: 'updateSiteRegistration',
summary: 'Update registration settings',
tags: ['Site Settings'],
method: 'put',
path: '/registration',
middleware: [requireAdmin] as const,
request: jsonBody(updateSiteRegistrationSchema),
responses: {
200: jsonContent(siteRegistrationSettingsSchema, 'Updated registration settings'),
@@ -74,14 +71,13 @@ const updateRegistrationRoute = authRoute(
)
const updateCaptchaRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.SITE_SETTINGS_UPDATE], siteRole: 'admin' },
{
operationId: 'updateSiteCaptcha',
summary: 'Update captcha settings',
tags: ['Site Settings'],
method: 'put',
path: '/captcha',
middleware: [requireAdmin] as const,
request: jsonBody(updateSiteCaptchaSchema),
responses: {
200: jsonContent(siteCaptchaSettingsSchema, 'Updated captcha settings'),
@@ -91,41 +87,38 @@ const updateCaptchaRoute = authRoute(
)
const updateQuotasRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.SITE_SETTINGS_UPDATE], siteRole: 'admin' },
{
operationId: 'updateSiteQuotas',
summary: 'Update default quota settings',
tags: ['Site Settings'],
method: 'put',
path: '/quotas',
middleware: [requireAdmin] as const,
request: jsonBody(updateSiteQuotasSchema),
responses: { 200: jsonContent(siteQuotaSettingsSchema, 'Updated quota settings') },
},
)
const verifyWebDavRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.SITE_SETTINGS_UPDATE], siteRole: 'admin' },
{
operationId: 'verifySiteWebDav',
summary: 'Verify the configured or derived WebDAV domain',
tags: ['Site Settings'],
method: 'post',
path: '/webdav/verifications',
middleware: [requireAdmin] as const,
responses: { 200: jsonContent(siteWebDavSettingsSchema, 'Current WebDAV verification status') },
},
)
const updateWebDavRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.SITE_SETTINGS_UPDATE], siteRole: 'admin' },
{
operationId: 'updateSiteWebDav',
summary: 'Update WebDAV settings',
tags: ['Site Settings'],
method: 'put',
path: '/webdav',
middleware: [requireAdmin] as const,
request: jsonBody(updateSiteWebDavSchema),
responses: {
200: jsonContent(siteWebDavSettingsSchema, 'Updated WebDAV settings'),
+8 -15
View File
@@ -1,4 +1,5 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import {
createStorageSchema,
pageSchema,
@@ -6,7 +7,6 @@ import {
replaceStorageSchema,
updateStorageEgressBillingSchema,
} from '@shared/schemas'
import { requireAdmin } from '../../middleware/auth'
import type { Env } from '../../middleware/platform'
import { type StorageRecord, storageNotFound } from '../../usecases/ports'
import {
@@ -64,27 +64,25 @@ function toStorageDTO(s: StorageRecord): StorageDTO {
const storageListSchema = pageSchema(storageSchema, 'StorageList')
const listRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.STORAGES_READ], siteRole: 'admin' },
{
operationId: 'listStorages',
summary: 'List storages',
tags: ['Storages'],
method: 'get',
path: '/',
middleware: [requireAdmin] as const,
responses: { 200: jsonContent(storageListSchema, 'Storages') },
},
)
const createStorageRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.STORAGES_CREATE], siteRole: 'admin' },
{
operationId: 'createStorage',
summary: 'Create storage',
tags: ['Storages'],
method: 'post',
path: '/',
middleware: [requireAdmin] as const,
request: jsonBody(createStorageSchema),
responses: {
201: jsonContent(storageSchema, 'Created storage'),
@@ -94,14 +92,13 @@ const createStorageRoute = authRoute(
)
const getStorageRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.STORAGES_READ], siteRole: 'admin' },
{
operationId: 'getStorage',
summary: 'Get storage',
tags: ['Storages'],
method: 'get',
path: '/{id}',
middleware: [requireAdmin] as const,
request: { params: z.object({ id: z.string() }) },
responses: {
200: jsonContent(storageSchema, 'Storage'),
@@ -111,14 +108,13 @@ const getStorageRoute = authRoute(
)
const replaceStorageRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.STORAGES_UPDATE], siteRole: 'admin' },
{
operationId: 'replaceStorage',
summary: 'Replace storage',
tags: ['Storages'],
method: 'put',
path: '/{id}',
middleware: [requireAdmin] as const,
request: { params: z.object({ id: z.string() }), ...jsonBody(replaceStorageSchema) },
responses: {
200: jsonContent(storageSchema, 'Replaced storage'),
@@ -129,14 +125,13 @@ const replaceStorageRoute = authRoute(
)
const patchStorageRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.STORAGES_UPDATE], siteRole: 'admin' },
{
operationId: 'patchStorage',
summary: 'Patch storage',
tags: ['Storages'],
method: 'patch',
path: '/{id}',
middleware: [requireAdmin] as const,
request: { params: z.object({ id: z.string() }), ...jsonBody(patchStorageSchema) },
responses: {
200: jsonContent(storageSchema, 'Updated storage'),
@@ -147,14 +142,13 @@ const patchStorageRoute = authRoute(
)
const updateStorageEgressBillingRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.STORAGES_UPDATE], siteRole: 'admin' },
{
operationId: 'updateStorageEgressBilling',
summary: 'Update storage egress billing',
tags: ['Storages'],
method: 'put',
path: '/{id}/egress-billing',
middleware: [requireAdmin] as const,
request: { params: z.object({ id: z.string() }), ...jsonBody(updateStorageEgressBillingSchema) },
responses: {
200: jsonContent(storageSchema, 'Updated storage'),
@@ -165,14 +159,13 @@ const updateStorageEgressBillingRoute = authRoute(
)
const deleteStorageRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.STORAGES_DELETE], siteRole: 'admin' },
{
operationId: 'deleteStorage',
summary: 'Delete storage',
tags: ['Storages'],
method: 'delete',
path: '/{id}',
middleware: [requireAdmin] as const,
request: { params: z.object({ id: z.string() }) },
responses: {
204: { description: 'Deleted storage' },
+3 -5
View File
@@ -1,5 +1,5 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { requireAdmin } from '../../middleware/auth'
import { AuthorizationScope } from '@shared/authorization'
import type { Env } from '../../middleware/platform'
import { runtimeInfo } from '../../usecases/site/instance-info'
import { getChangelog, resolveInstanceInfo } from '../../usecases/site/system'
@@ -41,27 +41,25 @@ const changelogSchema = z
.openapi('Changelog')
const instanceRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.SYSTEM_READ], siteRole: 'admin' },
{
operationId: 'getInstanceInfo',
summary: 'Get instance info',
tags: ['System'],
method: 'get',
path: '/instance',
middleware: [requireAdmin] as const,
responses: { 200: jsonContent(instanceInfoSchema, 'Instance info') },
},
)
const changelogRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.SYSTEM_READ], siteRole: 'admin' },
{
operationId: 'getChangelog',
summary: 'Get changelog',
tags: ['System'],
method: 'get',
path: '/changelog',
middleware: [requireAdmin] as const,
request: { query: z.object({ refresh: z.string().optional() }) },
responses: { 200: jsonContent(changelogSchema, 'Changelog') },
},
+2 -10
View File
@@ -35,11 +35,7 @@ const itemSchema = z.object({
})
const getUsageRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.STORAGE_USAGE_READ],
minTeamRole: 'viewer',
},
{ scopes: [AuthorizationScope.STORAGE_USAGE_READ], minTeamRole: 'viewer' },
{
operationId: 'getStorageUsage',
summary: 'Get current storage usage by category',
@@ -51,11 +47,7 @@ const getUsageRoute = authRoute(
)
const listItemsRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.STORAGE_USAGE_READ],
minTeamRole: 'viewer',
},
{ scopes: [AuthorizationScope.STORAGE_USAGE_READ], minTeamRole: 'viewer' },
{
operationId: 'listStorageUsageItems',
summary: 'List files in a storage usage category',
+25 -25
View File
@@ -1,6 +1,6 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { checkoutInputSchema, discountQuoteInputSchema, redeemGiftCardInputSchema } from '@shared/schemas'
import { requireAuth, requireTeamRole } from '../../middleware/auth'
import type { Env } from '../../middleware/platform'
import { requireFeature } from '../../middleware/require-feature'
import { badGateway, badRequest, forbidden } from '../../usecases/ports'
@@ -29,14 +29,14 @@ const cloudValue = z.unknown().openapi('CloudStoreValue')
const cloudBody = (description: string) => jsonContent(cloudValue, description)
const packagesRoute = authRoute(
{ access: 'session' },
{ scopes: [AuthorizationScope.STORE_READ] },
{
operationId: 'listStorePackages',
summary: 'List store packages',
tags: ['Store'],
method: 'get',
path: '/packages',
middleware: [requireAuth, requireFeature('quota_store')] as const,
middleware: [requireFeature('quota_store')] as const,
responses: {
200: cloudBody('Packages'),
403: errorResponse('License not bound'),
@@ -46,14 +46,14 @@ const packagesRoute = authRoute(
)
const creditProductsRoute = authRoute(
{ access: 'session' },
{ scopes: [AuthorizationScope.STORE_READ] },
{
operationId: 'listCreditProducts',
summary: 'List credit products',
tags: ['Store'],
method: 'get',
path: '/credits/products',
middleware: [requireAuth, requireFeature('quota_store')] as const,
middleware: [requireFeature('quota_store')] as const,
responses: {
200: cloudBody('Credit products'),
403: errorResponse('License not bound'),
@@ -63,14 +63,14 @@ const creditProductsRoute = authRoute(
)
const targetsRoute = authRoute(
{ access: 'session' },
{ scopes: [AuthorizationScope.STORE_READ] },
{
operationId: 'listStoreTargets',
summary: 'List store targets',
tags: ['Store'],
method: 'get',
path: '/targets',
middleware: [requireAuth, requireFeature('quota_store')] as const,
middleware: [requireFeature('quota_store')] as const,
responses: {
200: cloudBody('Targets'),
403: errorResponse('License not bound'),
@@ -80,14 +80,14 @@ const targetsRoute = authRoute(
)
const creditsRoute = authRoute(
{ access: 'session', minTeamRole: 'owner' },
{ scopes: [AuthorizationScope.STORE_READ], minTeamRole: 'owner' },
{
operationId: 'getCreditBalance',
summary: 'Get credit balance',
tags: ['Store'],
method: 'get',
path: '/credits',
middleware: [requireAuth, requireFeature('quota_store'), requireTeamRole('owner')] as const,
middleware: [requireFeature('quota_store')] as const,
responses: {
200: cloudBody('Credit balance'),
400: errorResponse('No active organization'),
@@ -98,14 +98,14 @@ const creditsRoute = authRoute(
)
const ledgerRoute = authRoute(
{ access: 'session', minTeamRole: 'owner' },
{ scopes: [AuthorizationScope.STORE_READ], minTeamRole: 'owner' },
{
operationId: 'getCreditLedger',
summary: 'Get credit ledger',
tags: ['Store'],
method: 'get',
path: '/credits/ledger-entries',
middleware: [requireAuth, requireFeature('quota_store'), requireTeamRole('owner')] as const,
middleware: [requireFeature('quota_store')] as const,
responses: {
200: cloudBody('Credit ledger'),
400: errorResponse('No active organization'),
@@ -116,14 +116,14 @@ const ledgerRoute = authRoute(
)
const redeemRoute = authRoute(
{ access: 'session', minTeamRole: 'owner' },
{ scopes: [AuthorizationScope.STORE_CREATE], minTeamRole: 'owner' },
{
operationId: 'redeemGiftCard',
summary: 'Redeem a gift card',
tags: ['Store'],
method: 'post',
path: '/credits/redemptions',
middleware: [requireAuth, requireFeature('quota_store'), requireTeamRole('owner')] as const,
middleware: [requireFeature('quota_store')] as const,
request: jsonBody(redeemGiftCardInputSchema),
responses: {
200: cloudBody('Redemption result'),
@@ -135,14 +135,14 @@ const redeemRoute = authRoute(
)
const checkoutRoute = authRoute(
{ access: 'session', minTeamRole: 'owner' },
{ scopes: [AuthorizationScope.STORE_CREATE], minTeamRole: 'owner' },
{
operationId: 'createCheckout',
summary: 'Create a checkout',
tags: ['Store'],
method: 'post',
path: '/checkouts',
middleware: [requireAuth, requireFeature('quota_store'), requireTeamRole('owner')] as const,
middleware: [requireFeature('quota_store')] as const,
request: jsonBody(checkoutInputSchema),
responses: {
200: cloudBody('Checkout session'),
@@ -155,14 +155,14 @@ const checkoutRoute = authRoute(
)
const discountRoute = authRoute(
{ access: 'session' },
{ scopes: [AuthorizationScope.STORE_CREATE] },
{
operationId: 'getDiscountQuote',
summary: 'Get a discount quote',
tags: ['Store'],
method: 'post',
path: '/discount-quotes',
middleware: [requireAuth, requireFeature('quota_store')] as const,
middleware: [requireFeature('quota_store')] as const,
request: jsonBody(discountQuoteInputSchema),
responses: {
200: cloudBody('Discount quote'),
@@ -173,14 +173,14 @@ const discountRoute = authRoute(
)
const billingPortalRoute = authRoute(
{ access: 'session', minTeamRole: 'owner' },
{ scopes: [AuthorizationScope.STORE_CREATE], minTeamRole: 'owner' },
{
operationId: 'createBillingPortalSession',
summary: 'Create a billing portal session',
tags: ['Store'],
method: 'post',
path: '/billing-portal-sessions',
middleware: [requireAuth, requireFeature('quota_store'), requireTeamRole('owner')] as const,
middleware: [requireFeature('quota_store')] as const,
responses: {
200: cloudBody('Billing portal session'),
400: errorResponse('No active organization'),
@@ -191,14 +191,14 @@ const billingPortalRoute = authRoute(
)
const ordersRoute = authRoute(
{ access: 'session', minTeamRole: 'owner' },
{ scopes: [AuthorizationScope.STORE_READ], minTeamRole: 'owner' },
{
operationId: 'listOrders',
summary: 'List orders',
tags: ['Store'],
method: 'get',
path: '/orders',
middleware: [requireAuth, requireFeature('quota_store'), requireTeamRole('owner')] as const,
middleware: [requireFeature('quota_store')] as const,
request: { query: cloudStoreOrdersQuerySchema },
responses: {
200: cloudBody('Orders'),
@@ -210,14 +210,14 @@ const ordersRoute = authRoute(
)
const continuePaymentRoute = authRoute(
{ access: 'session', minTeamRole: 'owner' },
{ scopes: [AuthorizationScope.STORE_CREATE], minTeamRole: 'owner' },
{
operationId: 'continueOrderPayment',
summary: 'Continue an order payment',
tags: ['Store'],
method: 'post',
path: '/orders/{orderId}/payments',
middleware: [requireAuth, requireFeature('quota_store'), requireTeamRole('owner')] as const,
middleware: [requireFeature('quota_store')] as const,
request: { params: z.object({ orderId: z.string() }) },
responses: {
200: cloudBody('Payment continuation'),
@@ -230,14 +230,14 @@ const continuePaymentRoute = authRoute(
)
const cancelOrderRoute = authRoute(
{ access: 'session', minTeamRole: 'owner' },
{ scopes: [AuthorizationScope.STORE_UPDATE], minTeamRole: 'owner' },
{
operationId: 'cancelOrder',
summary: 'Cancel an order',
tags: ['Store'],
method: 'put',
path: '/orders/{orderId}/status',
middleware: [requireAuth, requireFeature('quota_store'), requireTeamRole('owner')] as const,
middleware: [requireFeature('quota_store')] as const,
request: { params: z.object({ orderId: z.string() }), ...jsonBody(z.object({ status: z.literal('canceled') })) },
responses: {
200: cloudBody('Canceled order'),
+14 -26
View File
@@ -1,6 +1,6 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { pageQuerySchema, pageSchema } from '@shared/schemas'
import { requireAdmin, requireAuth } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import {
type AuditEventWithUser,
@@ -147,7 +147,7 @@ function imageUploadError(status: 400 | 403 | 413 | 500 | 503, error: string) {
// ── publicTeams ──────────────────────────────────────────────────────────────
const inviteLinkInfoRoute = authRoute(
{ access: 'public' },
{ public: true },
{
operationId: 'getTeamInviteLink',
summary: 'Get team invite link info',
@@ -170,14 +170,13 @@ export const publicTeams = new OpenAPIHono<Env>().openapi(inviteLinkInfoRoute, a
// ── teams (member-scoped) ────────────────────────────────────────────────────
const createInviteLinkRoute = authRoute(
{ access: 'session' },
{ scopes: [AuthorizationScope.TEAM_INVITATIONS_CREATE] },
{
operationId: 'createTeamInviteLink',
summary: 'Create a team invite link',
tags: ['Teams'],
method: 'post',
path: '/{teamId}/invite-links',
middleware: [requireAuth] as const,
request: { params: z.object({ teamId: z.string() }), ...jsonBody(createLinkSchema) },
responses: {
201: jsonContent(inviteLinkCreatedSchema, 'Created invite link'),
@@ -187,14 +186,13 @@ const createInviteLinkRoute = authRoute(
)
const listInvitationsRoute = authRoute(
{ access: 'session' },
{ scopes: [AuthorizationScope.TEAM_INVITATIONS_READ] },
{
operationId: 'listTeamInvitations',
summary: 'List pending team invitations',
tags: ['Teams'],
method: 'get',
path: '/{teamId}/invitations',
middleware: [requireAuth] as const,
request: { params: z.object({ teamId: z.string() }) },
responses: {
200: jsonContent(pendingInvitationListSchema, 'Pending invitations'),
@@ -204,14 +202,13 @@ const listInvitationsRoute = authRoute(
)
const joinTeamRoute = authRoute(
{ access: 'session' },
{ scopes: [AuthorizationScope.TEAM_MEMBERS_CREATE] },
{
operationId: 'joinTeam',
summary: 'Join a team with an invite token',
tags: ['Teams'],
method: 'post',
path: '/{teamId}/members',
middleware: [requireAuth] as const,
request: { params: z.object({ teamId: z.string() }), ...jsonBody(joinSchema) },
responses: {
200: jsonContent(z.object({ ok: z.literal(true) }), 'Joined'),
@@ -223,14 +220,13 @@ const joinTeamRoute = authRoute(
)
const activityRoute = authRoute(
{ access: 'session' },
{ scopes: [AuthorizationScope.TEAMS_READ] },
{
operationId: 'listTeamActivity',
summary: 'List team activity',
tags: ['Teams'],
method: 'get',
path: '/{teamId}/activity',
middleware: [requireAuth] as const,
request: {
params: z.object({ teamId: z.string() }),
query: pageQuerySchema,
@@ -243,14 +239,13 @@ const activityRoute = authRoute(
)
const setLogoRoute = authRoute(
{ access: 'session' },
{ scopes: [AuthorizationScope.TEAMS_UPDATE] },
{
operationId: 'setTeamLogo',
summary: 'Set team logo',
tags: ['Teams'],
method: 'put',
path: '/{teamId}/logo',
middleware: [requireAuth] as const,
// Body is multipart/form-data (a `file` field); parsed directly in the handler
// rather than via a request schema (the form validator conflicts with formData()).
request: { params: z.object({ teamId: z.string() }) },
@@ -266,14 +261,13 @@ const setLogoRoute = authRoute(
)
const deleteLogoRoute = authRoute(
{ access: 'session' },
{ scopes: [AuthorizationScope.TEAMS_UPDATE] },
{
operationId: 'deleteTeamLogo',
summary: 'Delete team logo',
tags: ['Teams'],
method: 'delete',
path: '/{teamId}/logo',
middleware: [requireAuth] as const,
request: { params: z.object({ teamId: z.string() }) },
responses: {
204: { description: 'Deleted' },
@@ -356,27 +350,25 @@ export const teams = teamsApp
// ── adminTeams ───────────────────────────────────────────────────────────────
const listTeamsRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.TEAMS_READ], siteRole: 'admin' },
{
operationId: 'listTeams',
summary: 'List teams',
tags: ['Teams'],
method: 'get',
path: '/',
middleware: [requireAdmin] as const,
responses: { 200: jsonContent(teamListSchema, 'Teams') },
},
)
const getTeamRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.TEAMS_READ], siteRole: 'admin' },
{
operationId: 'getTeam',
summary: 'Get a team',
tags: ['Teams'],
method: 'get',
path: '/{teamId}',
middleware: [requireAdmin] as const,
request: { params: z.object({ teamId: z.string() }) },
responses: {
200: jsonContent(teamSummarySchema, 'Team'),
@@ -386,14 +378,13 @@ const getTeamRoute = authRoute(
)
const listEntitlementsRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.TEAM_ENTITLEMENTS_READ], siteRole: 'admin' },
{
operationId: 'listTeamEntitlements',
summary: 'List team quota entitlements',
tags: ['Teams'],
method: 'get',
path: '/{teamId}/entitlements',
middleware: [requireAdmin] as const,
request: { params: z.object({ teamId: z.string() }) },
responses: {
200: jsonContent(entitlementListSchema, 'Entitlements'),
@@ -404,14 +395,13 @@ const listEntitlementsRoute = authRoute(
)
const grantEntitlementRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.TEAM_ENTITLEMENTS_CREATE], siteRole: 'admin' },
{
operationId: 'grantTeamEntitlement',
summary: 'Grant a team entitlement',
tags: ['Teams'],
method: 'post',
path: '/{teamId}/entitlements',
middleware: [requireAdmin] as const,
request: { params: z.object({ teamId: z.string() }), ...jsonBody(grantEntitlementSchema) },
responses: {
201: jsonContent(entitlementResultSchema, 'Granted entitlement'),
@@ -422,14 +412,13 @@ const grantEntitlementRoute = authRoute(
)
const updateEntitlementRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.TEAM_ENTITLEMENTS_UPDATE], siteRole: 'admin' },
{
operationId: 'updateTeamEntitlement',
summary: 'Update a team entitlement',
tags: ['Teams'],
method: 'patch',
path: '/{teamId}/entitlements/{eid}',
middleware: [requireAdmin] as const,
request: { params: z.object({ teamId: z.string(), eid: z.string() }), ...jsonBody(updateEntitlementSchema) },
responses: {
200: jsonContent(entitlementResultSchema, 'Updated entitlement'),
@@ -440,14 +429,13 @@ const updateEntitlementRoute = authRoute(
)
const revokeEntitlementRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.TEAM_ENTITLEMENTS_DELETE], siteRole: 'admin' },
{
operationId: 'revokeTeamEntitlement',
summary: 'Revoke a team entitlement',
tags: ['Teams'],
method: 'delete',
path: '/{teamId}/entitlements/{eid}',
middleware: [requireAdmin] as const,
request: { params: z.object({ teamId: z.string(), eid: z.string() }) },
responses: {
204: { description: 'Revoked entitlement' },
+4 -20
View File
@@ -53,11 +53,7 @@ const trashPageSchema = cursorPageSchema(matterSchema, 'TrashObjectPage')
const idParam = z.object({ id: z.string() })
const listTrashRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.OBJECTS_READ],
minTeamRole: 'viewer',
},
{ scopes: [AuthorizationScope.OBJECTS_READ], minTeamRole: 'viewer' },
{
operationId: 'listTrashObjects',
summary: 'List trashed objects',
@@ -73,11 +69,7 @@ const listTrashRoute = authRoute(
)
const getTrashObjectRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.OBJECTS_READ],
minTeamRole: 'viewer',
},
{ scopes: [AuthorizationScope.OBJECTS_READ], minTeamRole: 'viewer' },
{
operationId: 'getTrashObject',
summary: 'Get trashed object',
@@ -94,11 +86,7 @@ const getTrashObjectRoute = authRoute(
)
const restoreObjectRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.OBJECTS_UPDATE],
minTeamRole: 'editor',
},
{ scopes: [AuthorizationScope.OBJECTS_UPDATE], minTeamRole: 'editor' },
{
operationId: 'restoreObject',
summary: 'Restore trashed object',
@@ -116,11 +104,7 @@ const restoreObjectRoute = authRoute(
)
const purgeObjectRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.OBJECTS_PURGE],
minTeamRole: 'editor',
},
{ scopes: [AuthorizationScope.OBJECTS_PURGE], minTeamRole: 'editor' },
{
operationId: 'purgeTrashObject',
summary: 'Permanently delete trashed object',
+9 -16
View File
@@ -1,6 +1,6 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { publicProfileSchema } from '@shared/schemas/profile'
import { requireAdmin, requireAuth } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import {
badRequest,
@@ -67,14 +67,13 @@ function imageUploadError(status: 400 | 403 | 413 | 500 | 503, error: string) {
}
const setAvatarRoute = authRoute(
{ access: 'session' },
{ scopes: [AuthorizationScope.USERS_UPDATE] },
{
operationId: 'setMyAvatar',
summary: 'Set my avatar',
tags: ['Users'],
method: 'put',
path: '/me/avatar',
middleware: [requireAuth] as const,
// Body is multipart/form-data (a `file` field); parsed directly in the handler
// rather than via a request schema (the form validator conflicts with formData()).
responses: {
@@ -88,20 +87,19 @@ const setAvatarRoute = authRoute(
)
const deleteAvatarRoute = authRoute(
{ access: 'session' },
{ scopes: [AuthorizationScope.USERS_UPDATE] },
{
operationId: 'deleteMyAvatar',
summary: 'Remove my avatar',
tags: ['Users'],
method: 'delete',
path: '/me/avatar',
middleware: [requireAuth] as const,
responses: { 204: { description: 'Removed' } },
},
)
const getUserRoute = authRoute(
{ access: 'public' },
{ public: true },
{
operationId: 'getUserProfile',
summary: 'Get a user public profile',
@@ -125,28 +123,26 @@ const userQuotaSchema = z
.openapi('AdminUserQuota')
const getUserQuotaRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.USERS_READ], siteRole: 'admin' },
{
operationId: 'getUserQuota',
summary: "Get a user's storage quota",
tags: ['Users'],
method: 'get',
path: '/{userId}/quota',
middleware: [requireAdmin] as const,
request: { params: z.object({ userId: z.string() }) },
responses: { 200: jsonContent(userQuotaSchema, 'User quota') },
},
)
const listUserEntitlementsRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.USER_ENTITLEMENTS_READ], siteRole: 'admin' },
{
operationId: 'listUserEntitlements',
summary: 'List a users entitlements',
tags: ['Users'],
method: 'get',
path: '/{userId}/entitlements',
middleware: [requireAdmin] as const,
request: { params: z.object({ userId: z.string() }) },
responses: {
200: jsonContent(entitlementListSchema, 'Entitlements'),
@@ -157,14 +153,13 @@ const listUserEntitlementsRoute = authRoute(
)
const grantUserEntitlementRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.USER_ENTITLEMENTS_CREATE], siteRole: 'admin' },
{
operationId: 'grantUserEntitlement',
summary: 'Grant a user entitlement',
tags: ['Users'],
method: 'post',
path: '/{userId}/entitlements',
middleware: [requireAdmin] as const,
request: { params: z.object({ userId: z.string() }), ...jsonBody(grantEntitlementSchema) },
responses: {
201: jsonContent(entitlementResultSchema, 'Granted'),
@@ -175,14 +170,13 @@ const grantUserEntitlementRoute = authRoute(
)
const updateUserEntitlementRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.USER_ENTITLEMENTS_UPDATE], siteRole: 'admin' },
{
operationId: 'updateUserEntitlement',
summary: 'Update a user entitlement',
tags: ['Users'],
method: 'patch',
path: '/{userId}/entitlements/{eid}',
middleware: [requireAdmin] as const,
request: { params: z.object({ userId: z.string(), eid: z.string() }), ...jsonBody(updateEntitlementSchema) },
responses: {
200: jsonContent(entitlementResultSchema, 'Updated'),
@@ -193,14 +187,13 @@ const updateUserEntitlementRoute = authRoute(
)
const revokeUserEntitlementRoute = authRoute(
{ access: 'admin' },
{ scopes: [AuthorizationScope.USER_ENTITLEMENTS_DELETE], siteRole: 'admin' },
{
operationId: 'revokeUserEntitlement',
summary: 'Revoke a user entitlement',
tags: ['Users'],
method: 'delete',
path: '/{userId}/entitlements/{eid}',
middleware: [requireAdmin] as const,
request: { params: z.object({ userId: z.string(), eid: z.string() }) },
responses: {
204: { description: 'Revoked' },
+35 -16
View File
@@ -1,9 +1,10 @@
import { AuthorizationScope } from '@shared/authorization'
import { and, eq, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { describe, expect, it, vi } from 'vitest'
import * as authSchema from '../db/auth-schema.js'
import { authedHeaders, createTestApp } from '../test/setup.js'
import { requireAdmin, requireTeamRole } from './auth.js'
import { authorize } from './authz.js'
type TestCtx = Awaited<ReturnType<typeof createTestApp>>
type TestDb = TestCtx['db']
@@ -11,8 +12,12 @@ type TestApp = TestCtx['app']
async function createAdminTestApp() {
const { app, db, auth } = await createTestApp()
app.get('/api/admin-only', requireAdmin, (c) => c.json({ ok: true }))
app.post('/api/admin-only', requireAdmin, (c) => c.json({ ok: true }))
const adminPolicy = {
scopes: [AuthorizationScope.SITE_ANALYTICS_READ],
siteRole: 'admin',
} as const
app.get('/api/admin-only', authorize(adminPolicy), (c) => c.json({ ok: true }))
app.post('/api/admin-only', authorize(adminPolicy), (c) => c.json({ ok: true }))
return { app, db, auth }
}
@@ -37,7 +42,7 @@ async function authedHeadersWithFreshSession(
return { Cookie: cookies.join('; ') }
}
describe('requireAdmin middleware', () => {
describe('admin authorization declaration', () => {
it('returns 403 when user is not authenticated', async () => {
const { app } = await createAdminTestApp()
const res = await app.request('/api/admin-only')
@@ -157,7 +162,7 @@ describe('authenticated user activity', () => {
})
})
// --- requireTeamRole helpers ---
// --- session role policy helpers ---
async function insertOrg(db: TestDb, slug: string) {
const id = nanoid()
@@ -224,13 +229,27 @@ async function setActiveOrg(app: TestApp, cookies: string, orgId: string): Promi
function createTeamRoleTestApp() {
return createTestApp().then(({ app, db, deps }) => {
app.get('/api/test/viewer', requireTeamRole('viewer'), (c) => c.json({ ok: true }))
app.post('/api/test/editor', requireTeamRole('editor'), (c) => c.json({ ok: true }))
app.get(
'/api/test/viewer',
authorize({
scopes: [AuthorizationScope.TEAMS_READ],
minTeamRole: 'viewer',
}),
(c) => c.json({ ok: true }),
)
app.post(
'/api/test/editor',
authorize({
scopes: [AuthorizationScope.TEAMS_READ],
minTeamRole: 'editor',
}),
(c) => c.json({ ok: true }),
)
return { app, db, deps }
})
}
describe('requireTeamRole — personal org bypass', () => {
describe('session role policy — personal org bypass', () => {
it('viewer-level route passes for personal org owner', async () => {
const { app } = await createTeamRoleTestApp()
const headers = await authedHeaders(app)
@@ -246,8 +265,8 @@ describe('requireTeamRole — personal org bypass', () => {
})
})
describe('requireTeamRole — team org with owner role', () => {
it('caches repeated safe-method membership checks but not writes', async () => {
describe('session role policy — team org with owner role', () => {
it('rechecks current membership on every request', async () => {
const { app, db, deps } = await createTeamRoleTestApp()
const { userId, cookies } = await signUpAndGetSession(app, db, 'owner-cache@example.com')
const teamOrgId = await insertOrg(db, `team-${nanoid()}`)
@@ -257,7 +276,7 @@ describe('requireTeamRole — team org with owner role', () => {
expect((await app.request('/api/test/viewer', { headers: { Cookie: updatedCookies } })).status).toBe(200)
expect((await app.request('/api/test/viewer', { headers: { Cookie: updatedCookies } })).status).toBe(200)
expect(getMemberRole).toHaveBeenCalledOnce()
expect(getMemberRole).toHaveBeenCalledTimes(2)
await db
.update(authSchema.member)
@@ -266,7 +285,7 @@ describe('requireTeamRole — team org with owner role', () => {
expect(
(await app.request('/api/test/editor', { method: 'POST', headers: { Cookie: updatedCookies } })).status,
).toBe(403)
expect(getMemberRole).toHaveBeenCalledTimes(2)
expect(getMemberRole).toHaveBeenCalledTimes(3)
})
it('viewer-level route passes for owner', async () => {
@@ -292,7 +311,7 @@ describe('requireTeamRole — team org with owner role', () => {
})
})
describe('requireTeamRole — team org with editor role', () => {
describe('session role policy — team org with editor role', () => {
it('editor-level route passes for editor', async () => {
const { app, db } = await createTeamRoleTestApp()
const { userId, cookies } = await signUpAndGetSession(app, db, 'editor@example.com')
@@ -316,7 +335,7 @@ describe('requireTeamRole — team org with editor role', () => {
})
})
describe('requireTeamRole — team org with viewer role', () => {
describe('session role policy — team org with viewer role', () => {
it('viewer-level route passes for viewer', async () => {
const { app, db } = await createTeamRoleTestApp()
const { userId, cookies } = await signUpAndGetSession(app, db, 'viewer@example.com')
@@ -352,7 +371,7 @@ describe('requireTeamRole — team org with viewer role', () => {
})
})
describe('requireTeamRole — team org with no membership', () => {
describe('session role policy — team org with no membership', () => {
it('viewer-level route returns 403 when user has no membership in the team org', async () => {
const { app, db } = await createTeamRoleTestApp()
const { userId, cookies } = await signUpAndGetSession(app, db, 'nomember@example.com')
@@ -383,7 +402,7 @@ describe('requireTeamRole — team org with no membership', () => {
})
})
describe('requireTeamRole — unknown role', () => {
describe('session role policy — unknown role', () => {
it('editor-level route returns 403 for a user with an unknown role', async () => {
const { app, db } = await createTeamRoleTestApp()
const { userId, cookies } = await signUpAndGetSession(app, db, 'contrib@example.com')
+8 -115
View File
@@ -1,4 +1,4 @@
import { isAuthorizationScope, permissionScopes } from '@shared/authorization'
import { AuthorizationScope, isAuthorizationScope, permissionScopes } from '@shared/authorization'
import { createMiddleware } from 'hono/factory'
import {
isDownloaderBootstrapRegistrationRequest,
@@ -6,29 +6,9 @@ import {
LEGACY_DOWNLOADER_CLIENT_ID,
LEGACY_DOWNLOADER_REGISTER_SCOPE,
} from '../domain/legacy-downloader-bootstrap'
import { ApiKeyRateLimitError, type CachePolicy, forbidden, rateLimited, unauthorized } from '../usecases/ports'
import { ApiKeyRateLimitError, rateLimited, unauthorized } from '../usecases/ports'
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.
const ROLE_LEVELS: Record<string, number> = {
owner: 3,
admin: 3,
editor: 2,
viewer: 1,
member: 1,
}
const MEMBER_ROLE_CACHE_POLICY: CachePolicy<string | null> = {
namespace: 'member-role',
version: 1,
ttlMs: 30_000,
negativeTtlMs: 5_000,
maxEntries: 1024,
distributed: false,
validate: (value): value is string | null => value === null || typeof value === 'string',
}
type SessionWithPlugins = {
user: { id: string; role?: string }
session: { activeOrganizationId?: string }
@@ -66,7 +46,11 @@ export const authMiddleware = createMiddleware<Env>(async (c, next) => {
userId: null,
orgId: null,
fixedOrgId: null,
grantedScopes: new Set(),
grantedScopes: new Set([
AuthorizationScope.DOWNLOAD_TASKS_READ,
AuthorizationScope.DOWNLOAD_TASKS_CANCEL,
AuthorizationScope.DOWNLOADERS_UPDATE,
]),
actor: { type: 'downloader', ref: downloader.downloaderId },
state: {},
})
@@ -161,7 +145,7 @@ export const authMiddleware = createMiddleware<Env>(async (c, next) => {
userId: bootstrap.userId,
orgId: null,
fixedOrgId: null,
grantedScopes: new Set(),
grantedScopes: new Set([AuthorizationScope.DOWNLOADERS_CREATE]),
actor: { type: 'user', ref: bootstrap.userId },
state: { clientId: LEGACY_DOWNLOADER_CLIENT_ID, scope: LEGACY_DOWNLOADER_REGISTER_SCOPE },
})
@@ -209,94 +193,3 @@ export const authMiddleware = createMiddleware<Env>(async (c, next) => {
await next()
})
export const requireDownloader = createMiddleware<Env>(async (c, next) => {
const principal = c.get('principal')
if (principal?.kind !== 'downloader') throw unauthorized('Unauthorized')
await next()
})
export const requireAuth = createMiddleware<Env>(async (c, next) => {
if (c.get('principal')?.kind !== 'user') {
throw unauthorized('Unauthorized')
}
await next()
})
export const requireAdmin = createMiddleware<Env>(async (c, next) => {
const method = c.req.method.toUpperCase()
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') {
const principal = c.get('principal')
if (principal?.kind !== 'user') throw unauthorized('Unauthorized')
if (principal.role !== 'admin') throw forbidden('Forbidden')
await next()
return
}
const freshSession = (await c.get('auth').api.getSession({
headers: c.req.raw.headers,
query: { disableCookieCache: true },
})) as SessionWithPlugins | null
if (!freshSession?.user?.id) throw unauthorized('Unauthorized')
if (freshSession.user.role !== 'admin') {
throw forbidden('Forbidden')
}
await next()
})
export const requireDownloaderRegistration = createMiddleware<Env>(async (c, next) => {
const principal = c.get('principal')
if (principal?.kind === 'downloader-bootstrap') {
await next()
return
}
if (principal?.kind !== 'user') throw unauthorized('Unauthorized')
const freshSession = (await c.get('auth').api.getSession({
headers: c.req.raw.headers,
query: { disableCookieCache: true },
})) as SessionWithPlugins | null
if (!freshSession?.user?.id) throw unauthorized('Unauthorized')
if (freshSession.user.role !== 'admin') throw forbidden('Forbidden')
await next()
})
// requireTeamRole enforces a minimum role level for the current org.
// Personal orgs bypass the check — the owner of a personal space has full access.
// Must be used after requireAuth so orgId and userId are guaranteed non-null.
export function requireTeamRole(minRole: 'viewer' | 'editor' | 'owner') {
return createMiddleware<Env>(async (c, next) => {
const orgId = c.get('orgId')
const userId = c.get('userId')
if (!orgId || !userId) {
throw unauthorized('Unauthorized')
}
const method = c.req.method.toUpperCase()
const role =
method === 'GET' || method === 'HEAD' || method === 'OPTIONS'
? (
await c
.get('deps')
.cache.getOrLoad(MEMBER_ROLE_CACHE_POLICY, `${orgId}:${userId}`, () =>
c.get('deps').org.getMemberRole(orgId, userId),
)
).value
: await c.get('deps').org.getMemberRole(orgId, userId)
if (role !== null) {
const userLevel = ROLE_LEVELS[role] ?? 0
if (userLevel < ROLE_LEVELS[minRole]) {
throw forbidden('Forbidden')
}
await next()
return
}
// No member row — could be a personal org accessed without a session refresh.
if (await c.get('deps').org.isPersonalOrg(orgId)) {
await next()
return
}
throw forbidden('Forbidden')
})
}
+148 -21
View File
@@ -2,23 +2,32 @@ 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 { evaluateAuthorization, requirePermission } from './authz.js'
import { authorize, evaluateAuthorization } from './authz.js'
type TestCtx = Awaited<ReturnType<typeof createTestApp>>
type TestApp = TestCtx['app']
type TestDb = TestCtx['db']
type TestAuth = TestCtx['auth']
// Mounts the permission-gated probe routes on a real app so requirePermission
// runs after the production authMiddleware (which resolves the principal,
// 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.
// Mounts directly declared scope policies after the production authMiddleware.
// The body is a sentinel proving that the declaration allowed the request.
function mountProbes(app: TestApp) {
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/api-perm',
authorize({
scopes: [AuthorizationScope.DOWNLOAD_TASKS_CREATE],
}),
(c) => c.json({ ok: true }),
)
app.get('/api/test-authz/no-downloader', authorize({ scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ] }), (c) =>
c.json({ ok: true }),
)
app.get(
'/api/test-authz/team-editor',
requirePermission('download-tasks', 'create', { minTeamRole: 'editor' }),
authorize({
scopes: [AuthorizationScope.DOWNLOAD_TASKS_CREATE],
minTeamRole: 'editor',
}),
(c) => c.json({ ok: true }),
)
}
@@ -110,7 +119,7 @@ async function registerDownloader(app: TestApp, name: string): Promise<string> {
return created.token
}
describe('requirePermission middleware', () => {
describe('direct protected scope declaration', () => {
it('returns 401 when there is no principal (unauthenticated)', async () => {
const { app } = await createTestApp()
mountProbes(app)
@@ -181,7 +190,7 @@ describe('requirePermission middleware', () => {
expect(res.status).toBe(403)
})
it('returns 401 for a downloader principal when allowDownloader is not set', async () => {
it('allows a downloader principal when its fixed scopes satisfy the route', async () => {
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
mountProbes(app)
const downloaderToken = await registerDownloader(app, 'authz-downloader')
@@ -189,13 +198,11 @@ describe('requirePermission middleware', () => {
const res = await app.request('/api/test-authz/no-downloader', {
headers: { Authorization: `Bearer ${downloaderToken}` },
})
expect(res.status).toBe(401)
const body = (await res.json()) as { error: { message: string; status: string } }
expect(body.error.message).toBe('Unauthorized')
expect(body.error.status).toBe('UNAUTHENTICATED')
expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({ ok: true })
})
it('returns 401 for a bootstrap bearer on routes that would allow a session principal', async () => {
it('returns 401 when a one-purpose bootstrap credential is used outside registration', async () => {
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
mountProbes(app)
const bootstrapToken = await issueBootstrapToken(app)
@@ -373,7 +380,7 @@ describe('requirePermission middleware', () => {
expect(rows[0]).toMatchObject({
action: 'authorization_denied',
actorType: 'api_key',
targetName: 'protected route',
targetName: 'scoped route',
})
expect(JSON.parse(rows[0].metadata)).toEqual({
credential: 'api_key',
@@ -404,8 +411,122 @@ describe('requirePermission middleware', () => {
})
describe('evaluateAuthorization', () => {
it('lets sessions bypass declared scopes but keeps role policy', async () => {
const deps = {
const deps = {
getMemberRole: async () => 'owner',
findPersonalOrg: async () => 'org-1',
}
const sessionContext = {
credential: 'session' as const,
userId: 'user-1',
orgId: 'org-1',
fixedOrgId: null,
grantedScopes: null,
actor: { type: 'user' as const, ref: 'user-1' },
state: { firstParty: true as const, role: 'admin' },
}
it('uses user roles for sessions without requiring token scopes', async () => {
await expect(
evaluateAuthorization({
context: sessionContext,
declaration: { scopes: [AuthorizationScope.TEAMS_READ] },
deps,
}),
).resolves.toMatchObject({ allowed: true })
await expect(
evaluateAuthorization({
context: sessionContext,
declaration: {
scopes: [AuthorizationScope.SITE_ANALYTICS_READ],
siteRole: 'admin',
},
deps,
}),
).resolves.toMatchObject({ allowed: true })
await expect(
evaluateAuthorization({
context: { ...sessionContext, state: { firstParty: true, role: 'member' } },
declaration: {
scopes: [AuthorizationScope.SITE_ANALYTICS_READ],
siteRole: 'admin',
},
deps,
}),
).resolves.toMatchObject({ allowed: false, status: 403, reason: 'insufficient_site_role' })
})
it('uses scopes for downloader and bootstrap credentials', async () => {
const downloaderContext = {
credential: 'downloader' as const,
userId: null,
orgId: null,
fixedOrgId: null,
grantedScopes: new Set([AuthorizationScope.DOWNLOADERS_UPDATE]),
actor: { type: 'downloader' as const, ref: 'downloader-1' },
state: {},
}
const bootstrapContext = {
credential: 'downloader-bootstrap' as const,
userId: 'user-1',
orgId: null,
fixedOrgId: null,
grantedScopes: new Set([AuthorizationScope.DOWNLOADERS_CREATE]),
actor: { type: 'user' as const, ref: 'user-1' },
state: { clientId: 'zpan-cli' as const, scope: 'downloader:register' as const },
}
await expect(
evaluateAuthorization({
context: downloaderContext,
declaration: { scopes: [AuthorizationScope.DOWNLOADERS_UPDATE] },
deps,
}),
).resolves.toMatchObject({ allowed: true })
await expect(
evaluateAuthorization({
context: bootstrapContext,
declaration: { scopes: [AuthorizationScope.DOWNLOADERS_CREATE], siteRole: 'admin' },
deps,
}),
).resolves.toMatchObject({ allowed: true })
await expect(
evaluateAuthorization({
context: downloaderContext,
declaration: { scopes: [AuthorizationScope.DOWNLOADERS_CREATE] },
deps,
}),
).resolves.toMatchObject({ allowed: false, status: 403, reason: 'missing_scope' })
})
it('uses task-upload token scopes', async () => {
const taskUploadContext = {
credential: 'download-task-upload' as const,
userId: 'user-1',
orgId: 'org-1',
fixedOrgId: 'org-1',
grantedScopes: new Set([AuthorizationScope.OBJECTS_CREATE]),
actor: { type: 'task-upload' as const, ref: 'task-1' },
state: { downloaderId: 'downloader-1', taskId: 'task-1' },
}
await expect(
evaluateAuthorization({
context: taskUploadContext,
declaration: { scopes: [AuthorizationScope.OBJECTS_CREATE] },
deps,
}),
).resolves.toMatchObject({ allowed: true })
await expect(
evaluateAuthorization({
context: { ...taskUploadContext, grantedScopes: new Set<AuthorizationScope>() },
declaration: { scopes: [AuthorizationScope.OBJECTS_CREATE] },
deps,
}),
).resolves.toMatchObject({ allowed: false, status: 403, reason: 'missing_scope' })
})
it('evaluates session team roles independently of scopes', async () => {
const viewerDeps = {
getMemberRole: async () => 'viewer',
findPersonalOrg: async () => 'personal-org',
}
@@ -421,8 +542,11 @@ describe('evaluateAuthorization', () => {
actor: { type: 'user', ref: 'user-1' },
state: { firstParty: true },
},
declaration: { access: 'protected', scopes: [AuthorizationScope.DOWNLOAD_TASKS_CREATE], minTeamRole: 'editor' },
deps,
declaration: {
scopes: [AuthorizationScope.DOWNLOAD_TASKS_CREATE],
minTeamRole: 'editor',
},
deps: viewerDeps,
}),
).resolves.toMatchObject({ allowed: false, status: 403, reason: 'insufficient_role' })
})
@@ -439,7 +563,10 @@ describe('evaluateAuthorization', () => {
actor: { type: 'api_key', ref: 'key-1' },
state: { configId: 'remote-download', enabled: true },
},
declaration: { access: 'protected', scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ], minTeamRole: 'viewer' },
declaration: {
scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ],
minTeamRole: 'viewer',
},
deps: {
getMemberRole: async () => 'owner',
findPersonalOrg: async () => null,
+3 -4
View File
@@ -37,7 +37,6 @@ describe('authorize Agent OAuth grant-use tracking', () => {
it('records actual Agent OAuth use for scoped protected routes', async () => {
const { app, recordGrantUse } = probeApp(context, {
access: 'protected',
scopes: [AuthorizationScope.OBJECTS_READ],
})
@@ -56,8 +55,8 @@ describe('authorize Agent OAuth grant-use tracking', () => {
)
})
it('does not record display-only unscoped protected access as grant use', async () => {
const { app, recordGrantUse } = probeApp(context, { access: 'protected' })
it('does not record public access as grant use', async () => {
const { app, recordGrantUse } = probeApp(context, { public: true })
const res = await app.request('/probe')
@@ -76,7 +75,7 @@ describe('authorize Agent OAuth grant-use tracking', () => {
actor: { type: 'user', ref: 'user-1' },
state: { firstParty: true },
},
{ access: 'protected', scopes: [AuthorizationScope.OBJECTS_READ] },
{ scopes: [AuthorizationScope.OBJECTS_READ] },
)
const res = await app.request('/probe')
+96 -63
View File
@@ -1,9 +1,9 @@
import { type AuthorizationScope, authorizationScope } from '@shared/authorization'
import type { 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 { AuthzContext, Env } from './platform'
import { type AuthzContext, anonymousAuthzContext, type Env } from './platform'
const ROLE_LEVELS: Record<string, number> = {
owner: 3,
@@ -14,24 +14,16 @@ const ROLE_LEVELS: Record<string, number> = {
}
export type TeamRole = 'viewer' | 'editor' | 'owner'
export type RequiredAuthorizationScopes = readonly [AuthorizationScope, ...AuthorizationScope[]]
export type RouteAuthorizationDeclaration =
| { access: 'public' }
| { access: 'internal' }
| { access: 'admin' }
| { access: 'session'; minTeamRole?: TeamRole }
| { access: 'downloader' }
| { access: 'downloader-bootstrap' }
| { access: 'signed-webhook' }
| { access: 'task-upload-token' }
| { access: 'anyOf'; policies: readonly RouteAuthorizationDeclaration[] }
| {
access: 'protected'
scopes?: readonly AuthorizationScope[]
minTeamRole?: TeamRole
allowDownloader?: boolean
auditDenied?: boolean
}
export type ScopedAuthorizationPolicy = {
scopes: RequiredAuthorizationScopes
minTeamRole?: TeamRole
siteRole?: 'admin'
auditDenied?: boolean
}
export type RouteAuthorizationDeclaration = { public: true } | ScopedAuthorizationPolicy
export type AuthzDenialReason =
| 'missing_credential'
@@ -40,6 +32,7 @@ export type AuthzDenialReason =
| 'workspace_required'
| 'workspace_mismatch'
| 'insufficient_role'
| 'insufficient_site_role'
export type AuthzDecision =
| { allowed: true; effectiveOrgId: string | null; reason: 'allowed' }
@@ -50,31 +43,47 @@ type AuthzDeps = {
findPersonalOrg(userId: string): Promise<string | null>
}
type SessionWithPlugins = {
user: { id: string; role?: string }
}
export async function evaluateAuthorization(input: {
context: AuthzContext
declaration: RouteAuthorizationDeclaration
deps: AuthzDeps
}): Promise<AuthzDecision> {
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 (declaration.access !== 'protected') 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)
}
if (context.credential === 'downloader-bootstrap') return deny(context, 401, 'actor_not_allowed', declaration)
if ('public' in declaration) return allow(context.orgId)
return evaluateScopedPolicy(context, declaration, deps)
}
const requiredScopes = declaration.scopes ?? []
if (context.grantedScopes) {
for (const scope of requiredScopes) {
if (!context.grantedScopes.has(scope)) return deny(context, 403, 'missing_scope', declaration)
async function evaluateScopedPolicy(
context: AuthzContext,
policy: ScopedAuthorizationPolicy,
deps: AuthzDeps,
): Promise<AuthzDecision> {
if (context.credential === 'anonymous') return deny(context, 401, 'missing_credential', policy)
if (context.credential !== 'session') {
for (const scope of policy.scopes) {
if (!context.grantedScopes.has(scope)) return deny(context, 403, 'missing_scope', policy)
}
}
if (!declaration.minTeamRole) return { allowed: true, effectiveOrgId: context.orgId, reason: 'allowed' }
if (context.credential === 'session' && policy.siteRole === 'admin' && context.state.role !== 'admin') {
return deny(context, 403, 'insufficient_site_role', policy)
}
return evaluateRole(context, policy.minTeamRole, policy, deps)
}
async function evaluateRole(
context: AuthzContext,
minTeamRole: TeamRole | undefined,
declaration: RouteAuthorizationDeclaration,
deps: AuthzDeps,
): Promise<AuthzDecision> {
if (!minTeamRole) return allow(context.orgId)
const userId = context.userId
if (!userId) return deny(context, 401, 'actor_not_allowed', declaration)
const orgId = context.fixedOrgId ?? context.orgId
@@ -85,21 +94,24 @@ export async function evaluateAuthorization(input: {
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' }
return (ROLE_LEVELS[role] ?? 0) >= ROLE_LEVELS[minTeamRole]
? allow(orgId)
: 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)
return personalOrgId === orgId ? allow(orgId) : deny(context, 403, 'insufficient_role', declaration)
}
export function authorize(declaration: RouteAuthorizationDeclaration) {
return createMiddleware<Env>(async (c, next) => {
if ('public' in declaration) {
await next()
return
}
const context = await currentAuthorizationContext(c, declaration)
const decision = await evaluateAuthorization({
context: c.get('authzContext'),
context,
declaration,
deps: {
getMemberRole: (orgId, userId) => c.get('deps').org.getMemberRole(orgId, userId),
@@ -113,27 +125,42 @@ export function authorize(declaration: RouteAuthorizationDeclaration) {
return
}
if (decision.audit) {
await recordAuditEffect('authorization_denied', () => recordDenialAudit(c, declaration, decision.reason))
await recordAuditEffect('authorization_denied', () => recordDenialAudit(c, decision.reason))
}
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,
})
async function currentAuthorizationContext(
c: Context<Env>,
declaration: RouteAuthorizationDeclaration,
): Promise<AuthzContext> {
const context = c.get('authzContext')
if (context.credential !== 'session' || isSafeMethod(c.req.method) || !containsSiteAdmin(declaration)) {
return context
}
const freshSession = (await c.get('auth').api.getSession({
headers: c.req.raw.headers,
query: { disableCookieCache: true },
})) as SessionWithPlugins | null
if (!freshSession?.user?.id) return anonymousAuthzContext()
return {
...context,
userId: freshSession.user.id,
actor: { type: 'user', ref: freshSession.user.id },
state: { firstParty: true, role: freshSession.user.role },
}
}
function containsSiteAdmin(declaration: RouteAuthorizationDeclaration): boolean {
if ('public' in declaration) return false
return declaration.siteRole === 'admin'
}
function isSafeMethod(method: string): boolean {
return method === 'GET' || method === 'HEAD' || method === 'OPTIONS'
}
async function recordAgentOAuthGrantUse(
@@ -142,8 +169,8 @@ async function recordAgentOAuthGrantUse(
effectiveOrgId: string | null,
) {
const context = c.get('authzContext')
if (declaration.access !== 'protected' || !declaration.scopes?.length) return
if (context.credential !== 'agent_oauth') return
if (!declaredScopes(declaration).length) return
if (!context.userId || !effectiveOrgId || context.actor?.type !== 'agent_oauth') return
await c.get('deps').agentOAuth.recordGrantUse(c.get('platform').db, {
grantId: context.actor.ref,
@@ -153,6 +180,10 @@ async function recordAgentOAuthGrantUse(
})
}
function allow(effectiveOrgId: string | null): AuthzDecision {
return { allowed: true, effectiveOrgId, reason: 'allowed' }
}
function deny(
context: AuthzContext,
status: 401 | 403,
@@ -168,14 +199,16 @@ function deny(
}
function shouldAudit(declaration: RouteAuthorizationDeclaration): boolean {
return declaration.access === 'protected' && declaration.auditDenied !== false
if ('public' in declaration) return false
return declaration.auditDenied !== false
}
async function recordDenialAudit(
c: Context<Env>,
_declaration: RouteAuthorizationDeclaration,
reason: AuthzDenialReason,
) {
function declaredScopes(declaration: RouteAuthorizationDeclaration): AuthorizationScope[] {
if ('public' in declaration) return []
return [...declaration.scopes]
}
async function recordDenialAudit(c: Context<Env>, reason: AuthzDenialReason) {
const context = c.get('authzContext')
if (!context.actor) return
const orgId = context.fixedOrgId ?? context.orgId ?? c.get('orgId')
@@ -187,7 +220,7 @@ async function recordDenialAudit(
actorRef: context.actor.ref,
action: 'authorization_denied',
targetType: 'route',
targetName: 'protected route',
targetName: 'scoped route',
metadata: {
method: c.req.method.toUpperCase(),
credential: context.credential,
+57 -39
View File
@@ -251,7 +251,7 @@ describe('global OpenAPI document', () => {
expect(events?.description).toContain('download-tasks:read')
expect(events?.description).toContain('resource-change')
expect(events?.['x-zpan-auth']).toMatchObject({
access: 'protected',
public: false,
scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ],
})
})
@@ -276,7 +276,6 @@ describe('global OpenAPI document', () => {
it('emits OpenAPI authorization metadata from one route declaration helper', () => {
const route = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ],
minTeamRole: 'viewer',
},
@@ -294,19 +293,46 @@ describe('global OpenAPI document', () => {
expect(route.security).toEqual([{ bearerAuth: [AuthorizationScope.DOWNLOAD_TASKS_READ] }, { cookieAuth: [] }])
expect(route['x-zpan-auth']).toEqual({
access: 'protected',
public: false,
scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ],
minTeamRole: 'viewer',
allowDownloader: false,
siteRole: null,
auditDenied: true,
})
expect(route.middleware).toHaveLength(1)
})
it('installs runtime authorization for public and scoped policies', () => {
const declarations = [
{ public: true },
{ scopes: [AuthorizationScope.OBJECTS_READ] },
{
scopes: [AuthorizationScope.SITE_ANALYTICS_READ],
siteRole: 'admin',
},
{
scopes: [AuthorizationScope.TEAMS_READ],
minTeamRole: 'viewer',
},
{ scopes: [AuthorizationScope.DOWNLOADERS_UPDATE] },
{ scopes: [AuthorizationScope.DOWNLOADERS_CREATE], siteRole: 'admin' },
] as const
for (const [index, declaration] of declarations.entries()) {
const route = authRoute(declaration, {
operationId: `runtimeAuthProbe${index}`,
method: 'get',
path: `/runtime-auth-probe-${index}`,
responses: { 200: { description: 'OK' } },
}) as { middleware?: unknown[] }
expect(route.middleware).toHaveLength(1)
}
})
it('emits Agent OAuth and API-key security only for Agent-grantable protected scopes', () => {
const route = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.OBJECTS_CREATE],
minTeamRole: 'editor',
},
@@ -325,62 +351,51 @@ describe('global OpenAPI document', () => {
])
})
it('emits CLI metadata for non-agent authorization policies', () => {
const protectedWithoutScopes = authRoute(
{ access: 'protected' },
it('hides non-agent scoped policies from MCP without hiding them from Restish', () => {
const adminRoute = authRoute(
{
operationId: 'genericProtectedProbe',
scopes: [AuthorizationScope.SITE_ANALYTICS_READ],
siteRole: 'admin',
},
{
operationId: 'sessionProbe',
method: 'get',
path: '/probe',
responses: { 200: { description: 'OK' } },
},
) as { security?: unknown; 'x-zpan-auth'?: unknown }
const internalRoute = authRoute(
{ access: 'internal' },
{
operationId: 'internalProbe',
method: 'post',
path: '/internal-probe',
responses: { 204: { description: 'No Content' } },
},
) as { security?: unknown; 'x-zpan-auth'?: unknown; 'x-cli-ignore'?: boolean; 'x-mcp-ignore'?: boolean }
expect(protectedWithoutScopes.security).toEqual([{ bearerAuth: [] }, { cookieAuth: [] }])
expect(protectedWithoutScopes['x-zpan-auth']).toEqual({
access: 'protected',
scopes: [],
expect(adminRoute.security).toEqual([{ bearerAuth: [AuthorizationScope.SITE_ANALYTICS_READ] }, { cookieAuth: [] }])
expect(adminRoute['x-zpan-auth']).toEqual({
public: false,
scopes: [AuthorizationScope.SITE_ANALYTICS_READ],
minTeamRole: null,
allowDownloader: false,
siteRole: 'admin',
auditDenied: true,
})
expect(internalRoute.security).toEqual([])
expect(internalRoute['x-zpan-auth']).toEqual({ access: 'internal' })
expect(internalRoute['x-cli-ignore']).toBe(true)
expect(internalRoute['x-mcp-ignore']).toBe(true)
expect(adminRoute['x-cli-ignore']).toBeUndefined()
expect(adminRoute['x-mcp-ignore']).toBe(true)
})
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' } } },
'/public': { get: { 'x-zpan-auth': { public: true, scopes: [] } } },
'/protected': { post: { 'x-zpan-auth': { public: false, scopes: ['objects:read'] } } },
'/missing': { delete: { responses: { 204: { description: 'Deleted' } } } },
}),
).toEqual(['DELETE /missing'])
})
it('emits explicit authorization metadata for every hand-written OpenAPI operation', async () => {
it('requires every non-public OpenAPI operation to declare at least one scope', 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<string, Record<string, unknown>> }
const handWrittenPaths = Object.fromEntries(
Object.entries(doc.paths).filter(([path]) => !path.startsWith('/api/auth/')),
)
expect(findOperationsMissingAuthContract(handWrittenPaths)).toEqual([])
expect(findOperationsMissingAuthContract(doc.paths)).toEqual([])
})
it('documents downloader registration as admin or one-purpose bootstrap bearer auth', async () => {
it('documents downloader registration with its scope and session admin constraint', 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 {
@@ -388,10 +403,13 @@ describe('global OpenAPI document', () => {
}
const operation = doc.paths['/api/downloads/downloaders']?.post
expect(operation?.security).toEqual([{ cookieAuth: [] }, { bearerAuth: [] }])
expect(operation?.security).toEqual([{ bearerAuth: [AuthorizationScope.DOWNLOADERS_CREATE] }, { cookieAuth: [] }])
expect(operation?.['x-zpan-auth']).toEqual({
access: 'anyOf',
policies: [{ access: 'admin' }, { access: 'downloader-bootstrap' }],
public: false,
scopes: [AuthorizationScope.DOWNLOADERS_CREATE],
minTeamRole: null,
siteRole: 'admin',
auditDenied: true,
})
expect(operation?.['x-mcp-ignore']).toBe(true)
})
@@ -584,7 +602,7 @@ describe('global OpenAPI document', () => {
for (const operation of ownerOperations) {
expect(operation?.['x-zpan-auth']).toMatchObject({
access: 'session',
public: false,
minTeamRole: 'owner',
})
}
+2 -3
View File
@@ -1,7 +1,6 @@
import { describe, expect, it } from 'vitest'
import { WEBDAV_API_KEY_PERMISSIONS } from './api-key-templates'
import { AGENT_GRANTABLE_API_KEY_SCOPES, WEBDAV_API_KEY_PERMISSIONS } from './api-key-templates'
import {
AGENT_GRANTABLE_AUTHORIZATION_SCOPES,
AuthorizationScope,
authorizationScope,
CANONICAL_AUTHORIZATION_SCOPES,
@@ -22,7 +21,7 @@ describe('authorization scope registry', () => {
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(AGENT_GRANTABLE_API_KEY_SCOPES).not.toContain(AuthorizationScope.OBJECTS_PURGE)
expect(scopePermissions([AuthorizationScope.OBJECTS_DELETE])).toEqual({ objects: ['delete'] })
})
+73 -4
View File
@@ -13,16 +13,85 @@ export const AuthorizationScope = {
DOWNLOAD_TASKS_READ: 'download-tasks:read',
DOWNLOAD_TASKS_CREATE: 'download-tasks:create',
DOWNLOAD_TASKS_CANCEL: 'download-tasks:cancel',
SITE_ANALYTICS_READ: 'site-analytics:read',
AGENT_API_KEYS_READ: 'agent-api-keys:read',
AGENT_API_KEYS_CREATE: 'agent-api-keys:create',
AGENT_API_KEYS_UPDATE: 'agent-api-keys:update',
AGENT_API_KEYS_DELETE: 'agent-api-keys:delete',
AGENT_OAUTH_GRANTS_READ: 'agent-oauth-grants:read',
AGENT_OAUTH_GRANTS_CREATE: 'agent-oauth-grants:create',
AGENT_OAUTH_GRANTS_DELETE: 'agent-oauth-grants:delete',
BACKGROUND_JOBS_READ: 'background-jobs:read',
BACKGROUND_JOBS_CREATE: 'background-jobs:create',
BACKGROUND_JOBS_UPDATE: 'background-jobs:update',
DOWNLOADERS_READ: 'downloaders:read',
DOWNLOADERS_CREATE: 'downloaders:create',
DOWNLOADERS_UPDATE: 'downloaders:update',
DOWNLOADERS_DELETE: 'downloaders:delete',
IMAGE_HOSTING_CONFIG_READ: 'image-hosting-config:read',
IMAGE_HOSTING_CONFIG_UPDATE: 'image-hosting-config:update',
IMAGE_HOSTING_CONFIG_DELETE: 'image-hosting-config:delete',
IMAGES_READ: 'images:read',
IMAGES_CREATE: 'images:create',
IMAGES_UPDATE: 'images:update',
IMAGES_DELETE: 'images:delete',
NOTIFICATIONS_READ: 'notifications:read',
NOTIFICATIONS_UPDATE: 'notifications:update',
ANNOUNCEMENTS_READ: 'announcements:read',
ANNOUNCEMENTS_CREATE: 'announcements:create',
ANNOUNCEMENTS_UPDATE: 'announcements:update',
ANNOUNCEMENTS_DELETE: 'announcements:delete',
AUDIT_EVENTS_READ: 'audit-events:read',
AUTH_PROVIDERS_READ: 'auth-providers:read',
AUTH_PROVIDERS_UPDATE: 'auth-providers:update',
AUTH_PROVIDERS_DELETE: 'auth-providers:delete',
BRANDING_UPDATE: 'branding:update',
EMAIL_CONFIG_READ: 'email-config:read',
EMAIL_CONFIG_UPDATE: 'email-config:update',
EMAIL_CONFIG_TEST: 'email-config:test',
IMAGE_DOMAIN_PROVIDER_READ: 'image-domain-provider:read',
IMAGE_DOMAIN_PROVIDER_UPDATE: 'image-domain-provider:update',
IMAGE_DOMAIN_PROVIDER_TEST: 'image-domain-provider:test',
SITE_INVITATIONS_READ: 'site-invitations:read',
SITE_INVITATIONS_CREATE: 'site-invitations:create',
SITE_INVITATIONS_DELETE: 'site-invitations:delete',
INVITE_CODES_READ: 'invite-codes:read',
INVITE_CODES_CREATE: 'invite-codes:create',
INVITE_CODES_DELETE: 'invite-codes:delete',
LICENSING_READ: 'licensing:read',
LICENSING_UPDATE: 'licensing:update',
SITE_SETTINGS_READ: 'site-settings:read',
SITE_SETTINGS_UPDATE: 'site-settings:update',
STORAGES_READ: 'storages:read',
STORAGES_CREATE: 'storages:create',
STORAGES_UPDATE: 'storages:update',
STORAGES_DELETE: 'storages:delete',
SYSTEM_READ: 'system:read',
STORE_READ: 'store:read',
STORE_CREATE: 'store:create',
STORE_UPDATE: 'store:update',
TEAMS_READ: 'teams:read',
TEAMS_CREATE: 'teams:create',
TEAMS_UPDATE: 'teams:update',
TEAM_INVITATIONS_READ: 'team-invitations:read',
TEAM_INVITATIONS_CREATE: 'team-invitations:create',
TEAM_MEMBERS_CREATE: 'team-members:create',
TEAM_ENTITLEMENTS_READ: 'team-entitlements:read',
TEAM_ENTITLEMENTS_CREATE: 'team-entitlements:create',
TEAM_ENTITLEMENTS_UPDATE: 'team-entitlements:update',
TEAM_ENTITLEMENTS_DELETE: 'team-entitlements:delete',
USERS_READ: 'users:read',
USERS_UPDATE: 'users:update',
USER_ENTITLEMENTS_READ: 'user-entitlements:read',
USER_ENTITLEMENTS_CREATE: 'user-entitlements:create',
USER_ENTITLEMENTS_UPDATE: 'user-entitlements:update',
USER_ENTITLEMENTS_DELETE: 'user-entitlements:delete',
} 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<string>(CANONICAL_AUTHORIZATION_SCOPES)
export type ApiKeyPermissions = Record<string, string[]>
+2 -2
View File
@@ -39,8 +39,8 @@ Feature: Image hosting
When it is posted
Then the API responds 400
@image-hosting/presign-session-only @api
Scenario: Presign requires a session, not an API key
@image-hosting/presign-scope-required @api
Scenario: Presign requires images:create for scoped credentials
Given an API-key request to presign
When it is called
Then the API responds 401