Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -88,6 +88,7 @@ app.use("/v1/:workspaceId/*", async (c, next) => {
const apiKeyV1 = new OpenAPIHono<ApiKeyApp>();
apiKeyV1.use("*", ratelimit("apiKey"));
apiKeyV1.use("*", keyAuthorization());
apiKeyV1.use("*", scopeAuthorization());
apiKeyV1.use("*", analytics());

// Mount routes with proper OpenAPIHono to enable spec merging
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const ROUTES = [
"authors",
"cache",
"media",
"fields",
];

export const MAX_UPLOAD_SIZE = 5 * 1024 * 1024;
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/middleware/key-authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
96 changes: 96 additions & 0 deletions apps/api/src/middleware/scope-authorization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
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 getDraftPostReadScope(
method: string,
pathname: string,
status?: string
): ApiScope | null {
if (!READ_METHODS.has(method) || status === undefined) {
return null;
}

const [resource] = getRouteSegments(pathname);
if (resource !== "posts" || (status !== "draft" && status !== "all")) {
return null;
}

return API_KEY_SCOPE_BY_RESOURCE.posts.readDrafts;
}

export const scopeAuthorization =
(): MiddlewareHandler<ApiKeyApp> => async (c, next) => {
const scopes = c.get("apiKeyScopes") ?? [];
const draftPostReadScope = getDraftPostReadScope(
c.req.method,
c.req.path,
c.req.query("status")
);

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);
if (requiredScope && !hasScope(scopes, requiredScope)) {
return c.json(
{
error: "Forbidden",
message: `API key missing required scope: ${requiredScope}`,
},
403
);
}

await next();
};
93 changes: 0 additions & 93 deletions apps/api/src/routes/invalidate.ts

This file was deleted.

3 changes: 3 additions & 0 deletions apps/api/src/types/env.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { ApiScope } from "@marble/utils/api-key-scopes";

export interface Env {
DATABASE_URL: string;
HYPERDRIVE: { connectionString: string };
Expand All @@ -17,6 +19,7 @@ export interface ApiKeyVariables {
workspaceId?: string;
apiKeyId?: string;
apiKeyType?: "public" | "private";
apiKeyScopes?: ApiScope[];
}

// Hono app type for API key authenticated routes
Expand Down
14 changes: 13 additions & 1 deletion apps/cms/src/app/api/keys/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, getPublicKeyForbiddenScopes } from "@/utils/keys";

export async function GET(
_request: Request,
Expand Down Expand Up @@ -89,6 +89,18 @@ export async function PATCH(
updateData.name = body.data.name;
}
if (body.data.scopes !== undefined) {
if (existingKey.type === "public") {
const forbiddenScopes = getPublicKeyForbiddenScopes(body.data.scopes);
if (forbiddenScopes.length > 0) {
return NextResponse.json(
{
error: "Public API keys cannot include private-only scopes",
details: forbiddenScopes,
},
{ status: 400 }
);
}
}
updateData.scopes = body.data.scopes;
}
if (body.data.expiresAt !== undefined) {
Expand Down
19 changes: 18 additions & 1 deletion apps/cms/src/app/api/keys/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
getPublicKeyForbiddenScopes,
} from "@/utils/keys";

export async function GET() {
const accessData = await requireActiveWorkspaceAccess();
Expand Down Expand Up @@ -48,6 +52,19 @@ export async function POST(request: Request) {
? [...DEFAULT_PUBLIC_SCOPES]
: [...DEFAULT_PRIVATE_SCOPES]);

if (body.data.type === "public") {
const forbiddenScopes = getPublicKeyForbiddenScopes(scopesToSet);
if (forbiddenScopes.length > 0) {
return NextResponse.json(
{
error: "Public API keys cannot include private-only scopes",
details: forbiddenScopes,
},
{ status: 400 }
);
}
}

const apiKey = await db.apiKey.create({
data: {
name: body.data.name,
Expand Down
60 changes: 26 additions & 34 deletions apps/cms/src/utils/keys.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { API_KEY_PREFIXES as PREFIXES } from "@marble/utils";
import type { ApiScope } from "@marble/utils";
import {
API_KEY_PRIVATE_ONLY_SCOPES,
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";

Expand All @@ -8,29 +17,15 @@ 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;
export const PRIVATE_ONLY_SCOPES = API_KEY_PRIVATE_ONLY_SCOPES;

/**
* Validates if an API key has a valid prefix
Expand All @@ -50,20 +45,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
Expand Down Expand Up @@ -101,3 +83,13 @@ 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[] {
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));
}
Loading
Loading