From a3eb89e9d561cfef2096585500867e18458b70d2 Mon Sep 17 00:00:00 2001 From: Taqib Date: Wed, 17 Jun 2026 18:37:53 +0100 Subject: [PATCH 1/3] feat: enforce API key scopes --- apps/api/src/app.ts | 4 +- apps/api/src/lib/constants.ts | 1 + apps/api/src/middleware/key-authorization.ts | 1 + .../api/src/middleware/scope-authorization.ts | 71 ++++++++++++++ apps/api/src/routes/invalidate.ts | 93 ------------------- apps/api/src/types/env.ts | 3 + apps/cms/src/app/api/keys/[id]/route.ts | 14 ++- apps/cms/src/app/api/keys/route.ts | 19 +++- apps/cms/src/utils/keys.ts | 54 ++++------- .../migration.sql | 10 ++ packages/db/prisma/schema.prisma | 2 + packages/utils/package.json | 3 +- packages/utils/src/constants/api-key.ts | 65 +++++++++++++ 13 files changed, 208 insertions(+), 132 deletions(-) create mode 100644 apps/api/src/middleware/scope-authorization.ts delete mode 100644 apps/api/src/routes/invalidate.ts create mode 100644 packages/db/prisma/migrations/20260617144443_add_field_api_key_scopes/migration.sql diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 718e90f0..6564232a 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -9,12 +9,12 @@ import { cache } from "./middleware/cache"; import { keyAuthorization } from "./middleware/key-authorization"; import { legacyAnalytics } from "./middleware/legacy-analytics"; import { ratelimit } from "./middleware/ratelimit"; +import { scopeAuthorization } from "./middleware/scope-authorization"; import { systemAuth } from "./middleware/system"; import authorsRoutes from "./routes/authors"; import cacheRoutes from "./routes/cache"; import categoriesRoutes from "./routes/categories"; import eventsRoutes from "./routes/events"; -import invalidateRoutes from "./routes/invalidate"; import mediaRoutes from "./routes/media"; import postsRoutes from "./routes/posts"; import tagsRoutes from "./routes/tags"; @@ -88,6 +88,7 @@ app.use("/v1/:workspaceId/*", async (c, next) => { const apiKeyV1 = new OpenAPIHono(); apiKeyV1.use("*", ratelimit("apiKey")); apiKeyV1.use("*", keyAuthorization()); +apiKeyV1.use("*", scopeAuthorization()); apiKeyV1.use("*", analytics()); // Mount routes with proper OpenAPIHono to enable spec merging @@ -96,7 +97,6 @@ apiKeyV1.route("/categories", categoriesRoutes); apiKeyV1.route("/tags", tagsRoutes); apiKeyV1.route("/authors", authorsRoutes); apiKeyV1.route("/media", mediaRoutes); -apiKeyV1.route("/cache/invalidate", invalidateRoutes); // Mount apiKeyV1 under /v1 to automatically merge OpenAPI specs app.route("/v1", apiKeyV1); diff --git a/apps/api/src/lib/constants.ts b/apps/api/src/lib/constants.ts index dcddd787..2d2bc6bb 100644 --- a/apps/api/src/lib/constants.ts +++ b/apps/api/src/lib/constants.ts @@ -9,6 +9,7 @@ export const ROUTES = [ "authors", "cache", "media", + "fields", ]; export const MAX_UPLOAD_SIZE = 5 * 1024 * 1024; diff --git a/apps/api/src/middleware/key-authorization.ts b/apps/api/src/middleware/key-authorization.ts index 3421ea47..5dc34fe3 100644 --- a/apps/api/src/middleware/key-authorization.ts +++ b/apps/api/src/middleware/key-authorization.ts @@ -99,6 +99,7 @@ export const keyAuthorization = c.set("workspaceId", key.workspaceId); c.set("apiKeyId", key.id); c.set("apiKeyType", key.type); + c.set("apiKeyScopes", key.scopes); if (c.req.method !== "GET" && key.type !== "private") { return c.json( diff --git a/apps/api/src/middleware/scope-authorization.ts b/apps/api/src/middleware/scope-authorization.ts new file mode 100644 index 00000000..f078697f --- /dev/null +++ b/apps/api/src/middleware/scope-authorization.ts @@ -0,0 +1,71 @@ +import { + API_KEY_SCOPE_BY_RESOURCE, + type ApiScope, +} from "@marble/utils/api-key-scopes"; +import type { MiddlewareHandler } from "hono"; +import type { ApiKeyApp } from "@/types/env"; + +const READ_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); + +function getRouteSegments(pathname: string): string[] { + const segments = pathname.split("/").filter(Boolean); + return segments[0] === "v1" ? segments.slice(1) : segments; +} + +function getRequiredScope(method: string, pathname: string): ApiScope | null { + const [resource] = getRouteSegments(pathname); + const resourceScopes = + API_KEY_SCOPE_BY_RESOURCE[ + resource as keyof typeof API_KEY_SCOPE_BY_RESOURCE + ]; + + if (!resourceScopes) { + return null; + } + + return READ_METHODS.has(method) ? resourceScopes.read : resourceScopes.write; +} + +function hasScope(scopes: readonly ApiScope[], scope: ApiScope): boolean { + return scopes.includes(scope); +} + +function isDraftPostRead(method: string, pathname: string, status?: string) { + if (!READ_METHODS.has(method) || status === undefined) { + return false; + } + + const [resource] = getRouteSegments(pathname); + return resource === "posts" && (status === "draft" || status === "all"); +} + +export const scopeAuthorization = + (): MiddlewareHandler => async (c, next) => { + const scopes = c.get("apiKeyScopes") ?? []; + + if ( + isDraftPostRead(c.req.method, c.req.path, c.req.query("status")) && + c.get("apiKeyType") !== "private" + ) { + return c.json( + { + error: "Forbidden", + message: "Reading draft or all posts requires a private API key.", + }, + 403 + ); + } + + const requiredScope = getRequiredScope(c.req.method, c.req.path); + if (requiredScope && !hasScope(scopes, requiredScope)) { + return c.json( + { + error: "Forbidden", + message: `API key missing required scope: ${requiredScope}`, + }, + 403 + ); + } + + await next(); + }; diff --git a/apps/api/src/routes/invalidate.ts b/apps/api/src/routes/invalidate.ts deleted file mode 100644 index 53e3dfe0..00000000 --- a/apps/api/src/routes/invalidate.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { Redis } from "@upstash/redis/cloudflare"; -import { Hono } from "hono"; -import { createCacheClient } from "@/lib/cache"; -import type { ApiKeyApp } from "@/types/env"; -import { CacheInvalidateSchema } from "@/validations/misc"; - -const invalidate = new Hono(); - -/** - * Cache invalidation endpoint - * Allows CMS or admin to invalidate cached data when content changes - * - * POST /v1/cache/invalidate - * - Invalidates all cache for workspace if no resource specified - * - Invalidates specific resource cache if resource is provided - * - * Requires API key authentication (private key recommended) - */ -invalidate.post("/", async (c) => { - const workspaceId = c.get("workspaceId"); - - if (!workspaceId) { - return c.json({ error: "Workspace ID is required" }, 400); - } - - const cache = createCacheClient(c.env.REDIS_URL, c.env.REDIS_TOKEN); - - try { - const rawBody = await c.req.json(); - const validation = CacheInvalidateSchema.safeParse(rawBody); - - if (!validation.success) { - return c.json( - { - error: "Invalid request body", - details: validation.error.issues.map((err) => ({ - field: err.path.join("."), - message: err.message, - })), - }, - 400 - ); - } - - const { resource } = validation.data; - - let invalidatedCount: number; - - if (resource === "usage") { - const redis = new Redis({ - url: c.env.REDIS_URL, - token: c.env.REDIS_TOKEN, - }); - const deleted = await redis.del(`usage:meta:${workspaceId}`); - return c.json({ - success: true, - message: `Invalidated usage cache${deleted ? "" : " (was not cached)"}`, - workspaceId, - resource, - }); - } - - if (resource) { - // Invalidate specific resource - invalidatedCount = await cache.invalidateResource(workspaceId, resource); - return c.json({ - success: true, - message: `Invalidated ${invalidatedCount} cache entries for ${resource}`, - workspaceId, - resource, - }); - } - - // Invalidate all workspace cache - invalidatedCount = await cache.invalidateWorkspace(workspaceId); - return c.json({ - success: true, - message: `Invalidated ${invalidatedCount} cache entries for workspace`, - workspaceId, - }); - } catch (error) { - console.error("[Cache] Invalidation error:", error); - return c.json( - { - error: "Failed to invalidate cache", - message: error instanceof Error ? error.message : "Unknown error", - }, - 500 - ); - } -}); - -export default invalidate; diff --git a/apps/api/src/types/env.ts b/apps/api/src/types/env.ts index 583aad39..0ab70119 100644 --- a/apps/api/src/types/env.ts +++ b/apps/api/src/types/env.ts @@ -1,3 +1,5 @@ +import type { ApiScope } from "@marble/utils/api-key-scopes"; + export interface Env { DATABASE_URL: string; HYPERDRIVE: { connectionString: string }; @@ -17,6 +19,7 @@ export interface ApiKeyVariables { workspaceId?: string; apiKeyId?: string; apiKeyType?: "public" | "private"; + apiKeyScopes?: ApiScope[]; } // Hono app type for API key authenticated routes diff --git a/apps/cms/src/app/api/keys/[id]/route.ts b/apps/cms/src/app/api/keys/[id]/route.ts index a6707b73..a55bccc9 100644 --- a/apps/cms/src/app/api/keys/[id]/route.ts +++ b/apps/cms/src/app/api/keys/[id]/route.ts @@ -2,7 +2,7 @@ import { db } from "@marble/db"; import { NextResponse } from "next/server"; import { requireActiveWorkspaceAccess } from "@/lib/auth/access"; import { updateApiKeySchema } from "@/lib/validations/keys"; -import type { ApiScope } from "@/utils/keys"; +import { type ApiScope, getPublicKeyWriteScopes } from "@/utils/keys"; export async function GET( _request: Request, @@ -89,6 +89,18 @@ export async function PATCH( updateData.name = body.data.name; } if (body.data.scopes !== undefined) { + if (existingKey.type === "public") { + const writeScopes = getPublicKeyWriteScopes(body.data.scopes); + if (writeScopes.length > 0) { + return NextResponse.json( + { + error: "Public API keys cannot include write scopes", + details: writeScopes, + }, + { status: 400 } + ); + } + } updateData.scopes = body.data.scopes; } if (body.data.expiresAt !== undefined) { diff --git a/apps/cms/src/app/api/keys/route.ts b/apps/cms/src/app/api/keys/route.ts index 0bc2da5c..94894962 100644 --- a/apps/cms/src/app/api/keys/route.ts +++ b/apps/cms/src/app/api/keys/route.ts @@ -4,7 +4,11 @@ import { NextResponse } from "next/server"; import { requireActiveWorkspaceAccess } from "@/lib/auth/access"; import { getDashboardApiKeys } from "@/lib/queries/dashboard/settings"; import { createApiKeySchema } from "@/lib/validations/keys"; -import { DEFAULT_PRIVATE_SCOPES, DEFAULT_PUBLIC_SCOPES } from "@/utils/keys"; +import { + DEFAULT_PRIVATE_SCOPES, + DEFAULT_PUBLIC_SCOPES, + getPublicKeyWriteScopes, +} from "@/utils/keys"; export async function GET() { const accessData = await requireActiveWorkspaceAccess(); @@ -48,6 +52,19 @@ export async function POST(request: Request) { ? [...DEFAULT_PUBLIC_SCOPES] : [...DEFAULT_PRIVATE_SCOPES]); + if (body.data.type === "public") { + const writeScopes = getPublicKeyWriteScopes(scopesToSet); + if (writeScopes.length > 0) { + return NextResponse.json( + { + error: "Public API keys cannot include write scopes", + details: writeScopes, + }, + { status: 400 } + ); + } + } + const apiKey = await db.apiKey.create({ data: { name: body.data.name, diff --git a/apps/cms/src/utils/keys.ts b/apps/cms/src/utils/keys.ts index a2296b28..d014bba7 100644 --- a/apps/cms/src/utils/keys.ts +++ b/apps/cms/src/utils/keys.ts @@ -1,5 +1,13 @@ -import { API_KEY_PREFIXES as PREFIXES } from "@marble/utils"; +import type { ApiScope } from "@marble/utils"; +import { + API_KEY_SCOPES, + API_KEY_WRITE_SCOPES, + DEFAULT_PRIVATE_API_KEY_SCOPES, + DEFAULT_PUBLIC_API_KEY_SCOPES, + API_KEY_PREFIXES as PREFIXES, +} from "@marble/utils"; +export type { ApiScope } from "@marble/utils"; // biome-ignore lint/performance/noBarrelFile: <> export { API_KEY_PREFIXES } from "@marble/utils"; @@ -8,29 +16,14 @@ export type ApiKeyPrefix = (typeof PREFIXES)[keyof typeof PREFIXES]; /** * Default scopes for public API keys (read-only access) */ -export const DEFAULT_PUBLIC_SCOPES = [ - "posts_read", - "authors_read", - "categories_read", - "tags_read", - "media_read", -] as const; +export const DEFAULT_PUBLIC_SCOPES = DEFAULT_PUBLIC_API_KEY_SCOPES; /** * Default scopes for private API keys (full access) */ -export const DEFAULT_PRIVATE_SCOPES = [ - "posts_read", - "posts_write", - "authors_read", - "authors_write", - "categories_read", - "categories_write", - "tags_read", - "tags_write", - "media_read", - "media_write", -] as const; +export const DEFAULT_PRIVATE_SCOPES = DEFAULT_PRIVATE_API_KEY_SCOPES; + +export const WRITE_SCOPES = API_KEY_WRITE_SCOPES; /** * Validates if an API key has a valid prefix @@ -50,20 +43,7 @@ export function getApiKeyType(key: string): "public" | "private" | null { /** * Valid scope values matching the ApiScope enum */ -export const VALID_SCOPES = [ - "posts_read", - "posts_write", - "authors_read", - "authors_write", - "categories_read", - "categories_write", - "tags_read", - "tags_write", - "media_read", - "media_write", -] as const; - -export type ApiScope = (typeof VALID_SCOPES)[number]; +export const VALID_SCOPES = API_KEY_SCOPES; /** * Parse permissions string (comma-separated) into scopes array @@ -101,3 +81,9 @@ export function hasScope(scopes: ApiScope[], scope: ApiScope): boolean { export function validateScopes(scopes: string[]): boolean { return scopes.every((scope) => VALID_SCOPES.includes(scope as ApiScope)); } + +export function getPublicKeyWriteScopes(scopes: ApiScope[]): ApiScope[] { + return scopes.filter((scope) => + WRITE_SCOPES.includes(scope as (typeof API_KEY_WRITE_SCOPES)[number]) + ); +} diff --git a/packages/db/prisma/migrations/20260617144443_add_field_api_key_scopes/migration.sql b/packages/db/prisma/migrations/20260617144443_add_field_api_key_scopes/migration.sql new file mode 100644 index 00000000..c41903bd --- /dev/null +++ b/packages/db/prisma/migrations/20260617144443_add_field_api_key_scopes/migration.sql @@ -0,0 +1,10 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "ApiScope" ADD VALUE 'fields_read'; +ALTER TYPE "ApiScope" ADD VALUE 'fields_write'; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index aa7e6895..085c10d9 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -569,6 +569,8 @@ enum ApiScope { tags_write media_read media_write + fields_read + fields_write } enum FieldType { diff --git a/packages/utils/package.json b/packages/utils/package.json index 2a029549..260f68de 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -2,7 +2,8 @@ "name": "@marble/utils", "version": "0.0.0", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./api-key-scopes": "./src/constants/api-key.ts" }, "dependencies": { "nanoid": "^5.0.9", diff --git a/packages/utils/src/constants/api-key.ts b/packages/utils/src/constants/api-key.ts index 98c2b8cf..67e21e82 100644 --- a/packages/utils/src/constants/api-key.ts +++ b/packages/utils/src/constants/api-key.ts @@ -2,3 +2,68 @@ export const API_KEY_PREFIXES = { public: "mpk", private: "msk", } as const; + +export const API_KEY_READ_SCOPES = [ + "posts_read", + "authors_read", + "categories_read", + "tags_read", + "media_read", + "fields_read", +] as const; + +export const API_KEY_WRITE_SCOPES = [ + "posts_write", + "authors_write", + "categories_write", + "tags_write", + "media_write", + "fields_write", +] as const; + +export const API_KEY_SCOPES = [ + "posts_read", + "posts_write", + "authors_read", + "authors_write", + "categories_read", + "categories_write", + "tags_read", + "tags_write", + "media_read", + "media_write", + "fields_read", + "fields_write", +] as const; + +export type ApiScope = (typeof API_KEY_SCOPES)[number]; + +export const DEFAULT_PUBLIC_API_KEY_SCOPES = API_KEY_READ_SCOPES; +export const DEFAULT_PRIVATE_API_KEY_SCOPES = API_KEY_SCOPES; + +export const API_KEY_SCOPE_BY_RESOURCE = { + posts: { + read: "posts_read", + write: "posts_write", + }, + authors: { + read: "authors_read", + write: "authors_write", + }, + categories: { + read: "categories_read", + write: "categories_write", + }, + tags: { + read: "tags_read", + write: "tags_write", + }, + media: { + read: "media_read", + write: "media_write", + }, + fields: { + read: "fields_read", + write: "fields_write", + }, +} as const satisfies Record; From a4dcc4397d6645b724205deb7567dee952739fd3 Mon Sep 17 00:00:00 2001 From: Taqib Date: Wed, 17 Jun 2026 18:37:53 +0100 Subject: [PATCH 2/3] feat: enforce API key scopes --- apps/api/src/app.ts | 4 +- apps/api/src/lib/constants.ts | 1 + apps/api/src/middleware/key-authorization.ts | 1 + .../api/src/middleware/scope-authorization.ts | 71 ++++++++++++++ apps/api/src/routes/invalidate.ts | 93 ------------------- apps/api/src/types/env.ts | 3 + apps/cms/src/app/api/keys/[id]/route.ts | 14 ++- apps/cms/src/app/api/keys/route.ts | 19 +++- apps/cms/src/utils/keys.ts | 54 ++++------- .../migration.sql | 10 ++ packages/db/prisma/schema.prisma | 2 + packages/utils/package.json | 3 +- packages/utils/src/constants/api-key.ts | 55 +++++++++++ 13 files changed, 198 insertions(+), 132 deletions(-) create mode 100644 apps/api/src/middleware/scope-authorization.ts delete mode 100644 apps/api/src/routes/invalidate.ts create mode 100644 packages/db/prisma/migrations/20260617144443_add_field_api_key_scopes/migration.sql diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 718e90f0..6564232a 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -9,12 +9,12 @@ import { cache } from "./middleware/cache"; import { keyAuthorization } from "./middleware/key-authorization"; import { legacyAnalytics } from "./middleware/legacy-analytics"; import { ratelimit } from "./middleware/ratelimit"; +import { scopeAuthorization } from "./middleware/scope-authorization"; import { systemAuth } from "./middleware/system"; import authorsRoutes from "./routes/authors"; import cacheRoutes from "./routes/cache"; import categoriesRoutes from "./routes/categories"; import eventsRoutes from "./routes/events"; -import invalidateRoutes from "./routes/invalidate"; import mediaRoutes from "./routes/media"; import postsRoutes from "./routes/posts"; import tagsRoutes from "./routes/tags"; @@ -88,6 +88,7 @@ app.use("/v1/:workspaceId/*", async (c, next) => { const apiKeyV1 = new OpenAPIHono(); apiKeyV1.use("*", ratelimit("apiKey")); apiKeyV1.use("*", keyAuthorization()); +apiKeyV1.use("*", scopeAuthorization()); apiKeyV1.use("*", analytics()); // Mount routes with proper OpenAPIHono to enable spec merging @@ -96,7 +97,6 @@ apiKeyV1.route("/categories", categoriesRoutes); apiKeyV1.route("/tags", tagsRoutes); apiKeyV1.route("/authors", authorsRoutes); apiKeyV1.route("/media", mediaRoutes); -apiKeyV1.route("/cache/invalidate", invalidateRoutes); // Mount apiKeyV1 under /v1 to automatically merge OpenAPI specs app.route("/v1", apiKeyV1); diff --git a/apps/api/src/lib/constants.ts b/apps/api/src/lib/constants.ts index dcddd787..2d2bc6bb 100644 --- a/apps/api/src/lib/constants.ts +++ b/apps/api/src/lib/constants.ts @@ -9,6 +9,7 @@ export const ROUTES = [ "authors", "cache", "media", + "fields", ]; export const MAX_UPLOAD_SIZE = 5 * 1024 * 1024; diff --git a/apps/api/src/middleware/key-authorization.ts b/apps/api/src/middleware/key-authorization.ts index 3421ea47..5dc34fe3 100644 --- a/apps/api/src/middleware/key-authorization.ts +++ b/apps/api/src/middleware/key-authorization.ts @@ -99,6 +99,7 @@ export const keyAuthorization = c.set("workspaceId", key.workspaceId); c.set("apiKeyId", key.id); c.set("apiKeyType", key.type); + c.set("apiKeyScopes", key.scopes); if (c.req.method !== "GET" && key.type !== "private") { return c.json( diff --git a/apps/api/src/middleware/scope-authorization.ts b/apps/api/src/middleware/scope-authorization.ts new file mode 100644 index 00000000..f078697f --- /dev/null +++ b/apps/api/src/middleware/scope-authorization.ts @@ -0,0 +1,71 @@ +import { + API_KEY_SCOPE_BY_RESOURCE, + type ApiScope, +} from "@marble/utils/api-key-scopes"; +import type { MiddlewareHandler } from "hono"; +import type { ApiKeyApp } from "@/types/env"; + +const READ_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); + +function getRouteSegments(pathname: string): string[] { + const segments = pathname.split("/").filter(Boolean); + return segments[0] === "v1" ? segments.slice(1) : segments; +} + +function getRequiredScope(method: string, pathname: string): ApiScope | null { + const [resource] = getRouteSegments(pathname); + const resourceScopes = + API_KEY_SCOPE_BY_RESOURCE[ + resource as keyof typeof API_KEY_SCOPE_BY_RESOURCE + ]; + + if (!resourceScopes) { + return null; + } + + return READ_METHODS.has(method) ? resourceScopes.read : resourceScopes.write; +} + +function hasScope(scopes: readonly ApiScope[], scope: ApiScope): boolean { + return scopes.includes(scope); +} + +function isDraftPostRead(method: string, pathname: string, status?: string) { + if (!READ_METHODS.has(method) || status === undefined) { + return false; + } + + const [resource] = getRouteSegments(pathname); + return resource === "posts" && (status === "draft" || status === "all"); +} + +export const scopeAuthorization = + (): MiddlewareHandler => async (c, next) => { + const scopes = c.get("apiKeyScopes") ?? []; + + if ( + isDraftPostRead(c.req.method, c.req.path, c.req.query("status")) && + c.get("apiKeyType") !== "private" + ) { + return c.json( + { + error: "Forbidden", + message: "Reading draft or all posts requires a private API key.", + }, + 403 + ); + } + + const requiredScope = getRequiredScope(c.req.method, c.req.path); + if (requiredScope && !hasScope(scopes, requiredScope)) { + return c.json( + { + error: "Forbidden", + message: `API key missing required scope: ${requiredScope}`, + }, + 403 + ); + } + + await next(); + }; diff --git a/apps/api/src/routes/invalidate.ts b/apps/api/src/routes/invalidate.ts deleted file mode 100644 index 53e3dfe0..00000000 --- a/apps/api/src/routes/invalidate.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { Redis } from "@upstash/redis/cloudflare"; -import { Hono } from "hono"; -import { createCacheClient } from "@/lib/cache"; -import type { ApiKeyApp } from "@/types/env"; -import { CacheInvalidateSchema } from "@/validations/misc"; - -const invalidate = new Hono(); - -/** - * Cache invalidation endpoint - * Allows CMS or admin to invalidate cached data when content changes - * - * POST /v1/cache/invalidate - * - Invalidates all cache for workspace if no resource specified - * - Invalidates specific resource cache if resource is provided - * - * Requires API key authentication (private key recommended) - */ -invalidate.post("/", async (c) => { - const workspaceId = c.get("workspaceId"); - - if (!workspaceId) { - return c.json({ error: "Workspace ID is required" }, 400); - } - - const cache = createCacheClient(c.env.REDIS_URL, c.env.REDIS_TOKEN); - - try { - const rawBody = await c.req.json(); - const validation = CacheInvalidateSchema.safeParse(rawBody); - - if (!validation.success) { - return c.json( - { - error: "Invalid request body", - details: validation.error.issues.map((err) => ({ - field: err.path.join("."), - message: err.message, - })), - }, - 400 - ); - } - - const { resource } = validation.data; - - let invalidatedCount: number; - - if (resource === "usage") { - const redis = new Redis({ - url: c.env.REDIS_URL, - token: c.env.REDIS_TOKEN, - }); - const deleted = await redis.del(`usage:meta:${workspaceId}`); - return c.json({ - success: true, - message: `Invalidated usage cache${deleted ? "" : " (was not cached)"}`, - workspaceId, - resource, - }); - } - - if (resource) { - // Invalidate specific resource - invalidatedCount = await cache.invalidateResource(workspaceId, resource); - return c.json({ - success: true, - message: `Invalidated ${invalidatedCount} cache entries for ${resource}`, - workspaceId, - resource, - }); - } - - // Invalidate all workspace cache - invalidatedCount = await cache.invalidateWorkspace(workspaceId); - return c.json({ - success: true, - message: `Invalidated ${invalidatedCount} cache entries for workspace`, - workspaceId, - }); - } catch (error) { - console.error("[Cache] Invalidation error:", error); - return c.json( - { - error: "Failed to invalidate cache", - message: error instanceof Error ? error.message : "Unknown error", - }, - 500 - ); - } -}); - -export default invalidate; diff --git a/apps/api/src/types/env.ts b/apps/api/src/types/env.ts index 583aad39..0ab70119 100644 --- a/apps/api/src/types/env.ts +++ b/apps/api/src/types/env.ts @@ -1,3 +1,5 @@ +import type { ApiScope } from "@marble/utils/api-key-scopes"; + export interface Env { DATABASE_URL: string; HYPERDRIVE: { connectionString: string }; @@ -17,6 +19,7 @@ export interface ApiKeyVariables { workspaceId?: string; apiKeyId?: string; apiKeyType?: "public" | "private"; + apiKeyScopes?: ApiScope[]; } // Hono app type for API key authenticated routes diff --git a/apps/cms/src/app/api/keys/[id]/route.ts b/apps/cms/src/app/api/keys/[id]/route.ts index a6707b73..a55bccc9 100644 --- a/apps/cms/src/app/api/keys/[id]/route.ts +++ b/apps/cms/src/app/api/keys/[id]/route.ts @@ -2,7 +2,7 @@ import { db } from "@marble/db"; import { NextResponse } from "next/server"; import { requireActiveWorkspaceAccess } from "@/lib/auth/access"; import { updateApiKeySchema } from "@/lib/validations/keys"; -import type { ApiScope } from "@/utils/keys"; +import { type ApiScope, getPublicKeyWriteScopes } from "@/utils/keys"; export async function GET( _request: Request, @@ -89,6 +89,18 @@ export async function PATCH( updateData.name = body.data.name; } if (body.data.scopes !== undefined) { + if (existingKey.type === "public") { + const writeScopes = getPublicKeyWriteScopes(body.data.scopes); + if (writeScopes.length > 0) { + return NextResponse.json( + { + error: "Public API keys cannot include write scopes", + details: writeScopes, + }, + { status: 400 } + ); + } + } updateData.scopes = body.data.scopes; } if (body.data.expiresAt !== undefined) { diff --git a/apps/cms/src/app/api/keys/route.ts b/apps/cms/src/app/api/keys/route.ts index 0bc2da5c..94894962 100644 --- a/apps/cms/src/app/api/keys/route.ts +++ b/apps/cms/src/app/api/keys/route.ts @@ -4,7 +4,11 @@ import { NextResponse } from "next/server"; import { requireActiveWorkspaceAccess } from "@/lib/auth/access"; import { getDashboardApiKeys } from "@/lib/queries/dashboard/settings"; import { createApiKeySchema } from "@/lib/validations/keys"; -import { DEFAULT_PRIVATE_SCOPES, DEFAULT_PUBLIC_SCOPES } from "@/utils/keys"; +import { + DEFAULT_PRIVATE_SCOPES, + DEFAULT_PUBLIC_SCOPES, + getPublicKeyWriteScopes, +} from "@/utils/keys"; export async function GET() { const accessData = await requireActiveWorkspaceAccess(); @@ -48,6 +52,19 @@ export async function POST(request: Request) { ? [...DEFAULT_PUBLIC_SCOPES] : [...DEFAULT_PRIVATE_SCOPES]); + if (body.data.type === "public") { + const writeScopes = getPublicKeyWriteScopes(scopesToSet); + if (writeScopes.length > 0) { + return NextResponse.json( + { + error: "Public API keys cannot include write scopes", + details: writeScopes, + }, + { status: 400 } + ); + } + } + const apiKey = await db.apiKey.create({ data: { name: body.data.name, diff --git a/apps/cms/src/utils/keys.ts b/apps/cms/src/utils/keys.ts index a2296b28..d014bba7 100644 --- a/apps/cms/src/utils/keys.ts +++ b/apps/cms/src/utils/keys.ts @@ -1,5 +1,13 @@ -import { API_KEY_PREFIXES as PREFIXES } from "@marble/utils"; +import type { ApiScope } from "@marble/utils"; +import { + API_KEY_SCOPES, + API_KEY_WRITE_SCOPES, + DEFAULT_PRIVATE_API_KEY_SCOPES, + DEFAULT_PUBLIC_API_KEY_SCOPES, + API_KEY_PREFIXES as PREFIXES, +} from "@marble/utils"; +export type { ApiScope } from "@marble/utils"; // biome-ignore lint/performance/noBarrelFile: <> export { API_KEY_PREFIXES } from "@marble/utils"; @@ -8,29 +16,14 @@ export type ApiKeyPrefix = (typeof PREFIXES)[keyof typeof PREFIXES]; /** * Default scopes for public API keys (read-only access) */ -export const DEFAULT_PUBLIC_SCOPES = [ - "posts_read", - "authors_read", - "categories_read", - "tags_read", - "media_read", -] as const; +export const DEFAULT_PUBLIC_SCOPES = DEFAULT_PUBLIC_API_KEY_SCOPES; /** * Default scopes for private API keys (full access) */ -export const DEFAULT_PRIVATE_SCOPES = [ - "posts_read", - "posts_write", - "authors_read", - "authors_write", - "categories_read", - "categories_write", - "tags_read", - "tags_write", - "media_read", - "media_write", -] as const; +export const DEFAULT_PRIVATE_SCOPES = DEFAULT_PRIVATE_API_KEY_SCOPES; + +export const WRITE_SCOPES = API_KEY_WRITE_SCOPES; /** * Validates if an API key has a valid prefix @@ -50,20 +43,7 @@ export function getApiKeyType(key: string): "public" | "private" | null { /** * Valid scope values matching the ApiScope enum */ -export const VALID_SCOPES = [ - "posts_read", - "posts_write", - "authors_read", - "authors_write", - "categories_read", - "categories_write", - "tags_read", - "tags_write", - "media_read", - "media_write", -] as const; - -export type ApiScope = (typeof VALID_SCOPES)[number]; +export const VALID_SCOPES = API_KEY_SCOPES; /** * Parse permissions string (comma-separated) into scopes array @@ -101,3 +81,9 @@ export function hasScope(scopes: ApiScope[], scope: ApiScope): boolean { export function validateScopes(scopes: string[]): boolean { return scopes.every((scope) => VALID_SCOPES.includes(scope as ApiScope)); } + +export function getPublicKeyWriteScopes(scopes: ApiScope[]): ApiScope[] { + return scopes.filter((scope) => + WRITE_SCOPES.includes(scope as (typeof API_KEY_WRITE_SCOPES)[number]) + ); +} diff --git a/packages/db/prisma/migrations/20260617144443_add_field_api_key_scopes/migration.sql b/packages/db/prisma/migrations/20260617144443_add_field_api_key_scopes/migration.sql new file mode 100644 index 00000000..c41903bd --- /dev/null +++ b/packages/db/prisma/migrations/20260617144443_add_field_api_key_scopes/migration.sql @@ -0,0 +1,10 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "ApiScope" ADD VALUE 'fields_read'; +ALTER TYPE "ApiScope" ADD VALUE 'fields_write'; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index aa7e6895..085c10d9 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -569,6 +569,8 @@ enum ApiScope { tags_write media_read media_write + fields_read + fields_write } enum FieldType { diff --git a/packages/utils/package.json b/packages/utils/package.json index 2a029549..260f68de 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -2,7 +2,8 @@ "name": "@marble/utils", "version": "0.0.0", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./api-key-scopes": "./src/constants/api-key.ts" }, "dependencies": { "nanoid": "^5.0.9", diff --git a/packages/utils/src/constants/api-key.ts b/packages/utils/src/constants/api-key.ts index 98c2b8cf..e0f19042 100644 --- a/packages/utils/src/constants/api-key.ts +++ b/packages/utils/src/constants/api-key.ts @@ -2,3 +2,58 @@ export const API_KEY_PREFIXES = { public: "mpk", private: "msk", } as const; + +export const API_KEY_READ_SCOPES = [ + "posts_read", + "authors_read", + "categories_read", + "tags_read", + "media_read", + "fields_read", +] as const; + +export const API_KEY_WRITE_SCOPES = [ + "posts_write", + "authors_write", + "categories_write", + "tags_write", + "media_write", + "fields_write", +] as const; + +export const API_KEY_SCOPES = [ + ...API_KEY_READ_SCOPES, + ...API_KEY_WRITE_SCOPES, +] as const; + +export type ApiScope = (typeof API_KEY_SCOPES)[number]; + +export const DEFAULT_PUBLIC_API_KEY_SCOPES = API_KEY_READ_SCOPES; +export const DEFAULT_PRIVATE_API_KEY_SCOPES = API_KEY_SCOPES; + +export const API_KEY_SCOPE_BY_RESOURCE = { + posts: { + read: "posts_read", + write: "posts_write", + }, + authors: { + read: "authors_read", + write: "authors_write", + }, + categories: { + read: "categories_read", + write: "categories_write", + }, + tags: { + read: "tags_read", + write: "tags_write", + }, + media: { + read: "media_read", + write: "media_write", + }, + fields: { + read: "fields_read", + write: "fields_write", + }, +} as const satisfies Record; From 90f953303e8844efcfdffbbdb0fac394758a435d Mon Sep 17 00:00:00 2001 From: Taqib Date: Wed, 17 Jun 2026 19:27:12 +0100 Subject: [PATCH 3/3] feat: add draft post read scope --- .../api/src/middleware/scope-authorization.ts | 53 ++++++++++++++----- apps/cms/src/app/api/keys/[id]/route.ts | 10 ++-- apps/cms/src/app/api/keys/route.ts | 10 ++-- apps/cms/src/utils/keys.ts | 12 +++-- .../migration.sql | 1 + packages/db/prisma/schema.prisma | 1 + packages/utils/src/constants/api-key.ts | 15 +++++- 7 files changed, 73 insertions(+), 29 deletions(-) diff --git a/apps/api/src/middleware/scope-authorization.ts b/apps/api/src/middleware/scope-authorization.ts index f078697f..adf023ae 100644 --- a/apps/api/src/middleware/scope-authorization.ts +++ b/apps/api/src/middleware/scope-authorization.ts @@ -30,30 +30,55 @@ function hasScope(scopes: readonly ApiScope[], scope: ApiScope): boolean { return scopes.includes(scope); } -function isDraftPostRead(method: string, pathname: string, status?: string) { +function getDraftPostReadScope( + method: string, + pathname: string, + status?: string +): ApiScope | null { if (!READ_METHODS.has(method) || status === undefined) { - return false; + return null; } const [resource] = getRouteSegments(pathname); - return resource === "posts" && (status === "draft" || status === "all"); + if (resource !== "posts" || (status !== "draft" && status !== "all")) { + return null; + } + + return API_KEY_SCOPE_BY_RESOURCE.posts.readDrafts; } export const scopeAuthorization = (): MiddlewareHandler => async (c, next) => { const scopes = c.get("apiKeyScopes") ?? []; + const draftPostReadScope = getDraftPostReadScope( + c.req.method, + c.req.path, + c.req.query("status") + ); - if ( - isDraftPostRead(c.req.method, c.req.path, c.req.query("status")) && - c.get("apiKeyType") !== "private" - ) { - return c.json( - { - error: "Forbidden", - message: "Reading draft or all posts requires a private API key.", - }, - 403 - ); + if (draftPostReadScope) { + if (c.get("apiKeyType") !== "private") { + return c.json( + { + error: "Forbidden", + message: "Reading draft or all posts requires a private API key.", + }, + 403 + ); + } + + if (!hasScope(scopes, draftPostReadScope)) { + return c.json( + { + error: "Forbidden", + message: `API key missing required scope: ${draftPostReadScope}`, + }, + 403 + ); + } + + await next(); + return; } const requiredScope = getRequiredScope(c.req.method, c.req.path); diff --git a/apps/cms/src/app/api/keys/[id]/route.ts b/apps/cms/src/app/api/keys/[id]/route.ts index a55bccc9..3434139c 100644 --- a/apps/cms/src/app/api/keys/[id]/route.ts +++ b/apps/cms/src/app/api/keys/[id]/route.ts @@ -2,7 +2,7 @@ import { db } from "@marble/db"; import { NextResponse } from "next/server"; import { requireActiveWorkspaceAccess } from "@/lib/auth/access"; import { updateApiKeySchema } from "@/lib/validations/keys"; -import { type ApiScope, getPublicKeyWriteScopes } from "@/utils/keys"; +import { type ApiScope, getPublicKeyForbiddenScopes } from "@/utils/keys"; export async function GET( _request: Request, @@ -90,12 +90,12 @@ export async function PATCH( } if (body.data.scopes !== undefined) { if (existingKey.type === "public") { - const writeScopes = getPublicKeyWriteScopes(body.data.scopes); - if (writeScopes.length > 0) { + const forbiddenScopes = getPublicKeyForbiddenScopes(body.data.scopes); + if (forbiddenScopes.length > 0) { return NextResponse.json( { - error: "Public API keys cannot include write scopes", - details: writeScopes, + error: "Public API keys cannot include private-only scopes", + details: forbiddenScopes, }, { status: 400 } ); diff --git a/apps/cms/src/app/api/keys/route.ts b/apps/cms/src/app/api/keys/route.ts index 94894962..52dbfcd6 100644 --- a/apps/cms/src/app/api/keys/route.ts +++ b/apps/cms/src/app/api/keys/route.ts @@ -7,7 +7,7 @@ import { createApiKeySchema } from "@/lib/validations/keys"; import { DEFAULT_PRIVATE_SCOPES, DEFAULT_PUBLIC_SCOPES, - getPublicKeyWriteScopes, + getPublicKeyForbiddenScopes, } from "@/utils/keys"; export async function GET() { @@ -53,12 +53,12 @@ export async function POST(request: Request) { : [...DEFAULT_PRIVATE_SCOPES]); if (body.data.type === "public") { - const writeScopes = getPublicKeyWriteScopes(scopesToSet); - if (writeScopes.length > 0) { + const forbiddenScopes = getPublicKeyForbiddenScopes(scopesToSet); + if (forbiddenScopes.length > 0) { return NextResponse.json( { - error: "Public API keys cannot include write scopes", - details: writeScopes, + error: "Public API keys cannot include private-only scopes", + details: forbiddenScopes, }, { status: 400 } ); diff --git a/apps/cms/src/utils/keys.ts b/apps/cms/src/utils/keys.ts index d014bba7..5e46d278 100644 --- a/apps/cms/src/utils/keys.ts +++ b/apps/cms/src/utils/keys.ts @@ -1,5 +1,6 @@ import type { ApiScope } from "@marble/utils"; import { + API_KEY_PRIVATE_ONLY_SCOPES, API_KEY_SCOPES, API_KEY_WRITE_SCOPES, DEFAULT_PRIVATE_API_KEY_SCOPES, @@ -24,6 +25,7 @@ export const DEFAULT_PUBLIC_SCOPES = DEFAULT_PUBLIC_API_KEY_SCOPES; export const DEFAULT_PRIVATE_SCOPES = DEFAULT_PRIVATE_API_KEY_SCOPES; export const WRITE_SCOPES = API_KEY_WRITE_SCOPES; +export const PRIVATE_ONLY_SCOPES = API_KEY_PRIVATE_ONLY_SCOPES; /** * Validates if an API key has a valid prefix @@ -83,7 +85,11 @@ export function validateScopes(scopes: string[]): boolean { } export function getPublicKeyWriteScopes(scopes: ApiScope[]): ApiScope[] { - return scopes.filter((scope) => - WRITE_SCOPES.includes(scope as (typeof API_KEY_WRITE_SCOPES)[number]) - ); + const writeScopes: readonly ApiScope[] = WRITE_SCOPES; + return scopes.filter((scope) => writeScopes.includes(scope)); +} + +export function getPublicKeyForbiddenScopes(scopes: ApiScope[]): ApiScope[] { + const privateOnlyScopes: readonly ApiScope[] = PRIVATE_ONLY_SCOPES; + return scopes.filter((scope) => privateOnlyScopes.includes(scope)); } diff --git a/packages/db/prisma/migrations/20260617144443_add_field_api_key_scopes/migration.sql b/packages/db/prisma/migrations/20260617144443_add_field_api_key_scopes/migration.sql index c41903bd..2f9b12f8 100644 --- a/packages/db/prisma/migrations/20260617144443_add_field_api_key_scopes/migration.sql +++ b/packages/db/prisma/migrations/20260617144443_add_field_api_key_scopes/migration.sql @@ -6,5 +6,6 @@ -- the enum. +ALTER TYPE "ApiScope" ADD VALUE 'posts_read_drafts'; ALTER TYPE "ApiScope" ADD VALUE 'fields_read'; ALTER TYPE "ApiScope" ADD VALUE 'fields_write'; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 085c10d9..c2ecae59 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -560,6 +560,7 @@ enum ApiKeyType { enum ApiScope { posts_read + posts_read_drafts posts_write authors_read authors_write diff --git a/packages/utils/src/constants/api-key.ts b/packages/utils/src/constants/api-key.ts index e0f19042..c7c27f4a 100644 --- a/packages/utils/src/constants/api-key.ts +++ b/packages/utils/src/constants/api-key.ts @@ -12,6 +12,8 @@ export const API_KEY_READ_SCOPES = [ "fields_read", ] as const; +export const API_KEY_DRAFT_READ_SCOPES = ["posts_read_drafts"] as const; + export const API_KEY_WRITE_SCOPES = [ "posts_write", "authors_write", @@ -21,9 +23,14 @@ export const API_KEY_WRITE_SCOPES = [ "fields_write", ] as const; +export const API_KEY_PRIVATE_ONLY_SCOPES = [ + ...API_KEY_DRAFT_READ_SCOPES, + ...API_KEY_WRITE_SCOPES, +] as const; + export const API_KEY_SCOPES = [ ...API_KEY_READ_SCOPES, - ...API_KEY_WRITE_SCOPES, + ...API_KEY_PRIVATE_ONLY_SCOPES, ] as const; export type ApiScope = (typeof API_KEY_SCOPES)[number]; @@ -34,6 +41,7 @@ export const DEFAULT_PRIVATE_API_KEY_SCOPES = API_KEY_SCOPES; export const API_KEY_SCOPE_BY_RESOURCE = { posts: { read: "posts_read", + readDrafts: "posts_read_drafts", write: "posts_write", }, authors: { @@ -56,4 +64,7 @@ export const API_KEY_SCOPE_BY_RESOURCE = { read: "fields_read", write: "fields_write", }, -} as const satisfies Record; +} as const satisfies Record< + string, + { read: ApiScope; readDrafts?: ApiScope; write: ApiScope } +>;