Skip to content
Open
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
93 changes: 93 additions & 0 deletions apps/api/src/lib/api-key-org.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import type { auth as betterAuth } from "@superset/auth/server";
import { db } from "@superset/db/client";
import { members } from "@superset/db/schema";
import { and, eq } from "drizzle-orm";

const API_KEY_HEADER = "x-api-key";
const BEARER_API_KEY_PREFIX = "sk_live_";

function extractApiKey(req: Request): string | undefined {
const headerKey = req.headers.get(API_KEY_HEADER);
if (headerKey) return headerKey;

const authorization = req.headers.get("authorization");
const match = authorization?.match(/^Bearer\s+(.+)$/i);
const bearer = match?.[1];
if (bearer?.startsWith(BEARER_API_KEY_PREFIX)) return bearer;

return undefined;
}

function parseApiKeyMetadata(
metadata: unknown,
): Record<string, unknown> | null {
if (!metadata) return null;

if (typeof metadata === "string") {
try {
const parsed = JSON.parse(metadata);
return parsed && typeof parsed === "object"
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
}

return typeof metadata === "object"
? (metadata as Record<string, unknown>)
: null;
}

export type ApiKeyResolution =
| { kind: "not-api-key" }
| { kind: "no-organization-metadata" }
| { kind: "ok"; organizationId: string }
| { kind: "invalid" };

/**
* Resolve the organization an api-key-authed request should operate
* against. api keys created via `apiKeyRouter.create` store their
* intended org in `metadata.organizationId`. Without this, the
* api-key-synthesized session has no `activeOrganizationId` and
* falls through to the default newest-membership resolver, which
* silently routes requests to the wrong org when a user belongs to
* more than one.
*
* Kinds:
* - `not-api-key` — request is not api-key authed; caller should use session as-is
* - `no-organization-metadata` — legacy key without org metadata; caller should use session as-is
* - `ok` — key's org is valid and user is still a member; caller should override active org
* - `invalid` — key verification failed OR user is no longer a member of the key's org; caller should deny the request
*/
export async function resolveApiKey(
req: Request,
auth: typeof betterAuth,
userId: string,
): Promise<ApiKeyResolution> {
const apiKey = extractApiKey(req);
if (!apiKey) return { kind: "not-api-key" };

try {
const result = await auth.api.verifyApiKey({ body: { key: apiKey } });
if (!result.valid || !result.key) return { kind: "invalid" };

const metadata = parseApiKeyMetadata(result.key.metadata);
const organizationId = metadata?.organizationId;
if (typeof organizationId !== "string") {
return { kind: "no-organization-metadata" };
}

const membership = await db.query.members.findFirst({
where: and(
eq(members.userId, userId),
eq(members.organizationId, organizationId),
),
});
if (!membership) return { kind: "invalid" };

return { kind: "ok", organizationId };
} catch {
return { kind: "invalid" };
}
}
14 changes: 13 additions & 1 deletion apps/api/src/trpc/context.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { auth } from "@superset/auth/server";
import { createTRPCContext } from "@superset/trpc";
import { resolveApiKey } from "@/lib/api-key-org";

export const createContext = async ({
req,
Expand All @@ -10,8 +11,19 @@ export const createContext = async ({
const session = await auth.api.getSession({
headers: req.headers,
});

let effectiveSession = session;
if (session) {
const resolution = await resolveApiKey(req, auth, session.user.id);
if (resolution.kind === "ok") {
session.session.activeOrganizationId = resolution.organizationId;
} else if (resolution.kind === "invalid") {
effectiveSession = null;
}
}

return createTRPCContext({
session,
session: effectiveSession,
auth,
headers: req.headers,
});
Expand Down
77 changes: 76 additions & 1 deletion packages/auth/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { getTrustedVercelPreviewOrigins } from "@superset/shared/vercel-preview-
import { Client } from "@upstash/qstash";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { APIError, createAuthMiddleware } from "better-auth/api";
import { bearer, customSession, organization } from "better-auth/plugins";
import { jwt } from "better-auth/plugins/jwt";
import { and, count, desc, eq, sql } from "drizzle-orm";
Expand Down Expand Up @@ -52,6 +53,26 @@ export const auth = betterAuth({
baseURL: env.NEXT_PUBLIC_API_URL,
secret: env.BETTER_AUTH_SECRET,
disabledPaths: [],
hooks: {
before: createAuthMiddleware(async (ctx) => {
// API key metadata is mint-immutable. `metadata.organizationId` is
// the authority for which org an api key operates on (enforced in
// trpc/context.ts via resolveApiKey). Without this, an attacker
// holding a stolen key could call `POST /api-key/update` on the
// same key and rewrite `metadata.organizationId` to any value
// the owner is a member of, bypassing the strict binding.
// Other update fields (name, enabled, expiresIn) remain mutable.
if (
ctx.path === "/api-key/update" &&
(ctx.body as { metadata?: unknown } | undefined)?.metadata !== undefined
) {
throw new APIError("BAD_REQUEST", {
message:
"API key metadata is immutable. Delete and recreate the key to change its organization.",
});
}
}),
},
database: drizzleAdapter(db, {
provider: "pg",
usePlural: true,
Expand Down Expand Up @@ -182,10 +203,64 @@ export const auth = betterAuth({
expirationTime: "1h",
definePayload: async ({
user,
session,
}: {
user: { id: string; email: string };
session: Record<string, unknown>;
session: { id?: string; token?: string } & Record<string, unknown>;
}) => {
// API-key-derived JWTs must carry only the key's bound org in
// their `organizationIds` claim — otherwise `jwtProcedure`
// routes (e.g. `v2-workspace.create`, `v2-project.create`,
// `device.create`) would accept cross-org input via the
// `ctx.organizationIds.includes(input.organizationId)` check.
// The api-key plugin synthesizes session.token = raw key and
// session.id = apikey.id. For non-api-key callers (browser
// OAuth, desktop auth) session.token is a normal session
// token and we emit the user's full org list as before.
if (session.token?.startsWith("sk_live_") && session.id) {
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The API-key org resolution logic (metadata lookup → membership check → org narrowing) is duplicated nearly verbatim between definePayload and customSession. Extract it into a shared helper like resolveApiKeyOrgBinding(db, sessionId, userId) returning { boundOrganizationId, boundMembership } — the membership query can select both userId and role to satisfy both callers. This avoids drift if the metadata format or membership check changes.

(Based on your team's feedback about preferring shared helpers over repeated multi-step flows.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/auth/src/server.ts, line 230:

<comment>The API-key org resolution logic (metadata lookup → membership check → org narrowing) is duplicated nearly verbatim between `definePayload` and `customSession`. Extract it into a shared helper like `resolveApiKeyOrgBinding(db, sessionId, userId)` returning `{ boundOrganizationId, boundMembership }` — the membership query can select both `userId` and `role` to satisfy both callers. This avoids drift if the metadata format or membership check changes.

(Based on your team's feedback about preferring shared helpers over repeated multi-step flows.) </comment>

<file context>
@@ -215,10 +215,46 @@ export const auth = betterAuth({
+					// `ctx.organizationIds.includes(input.organizationId)` check.
+					// Matches the narrowing `customSession` does for the
+					// protectedProcedure path.
+					if (session.token?.startsWith("sk_live_") && session.id) {
+						const keyRow = await db.query.apikeys.findFirst({
+							where: eq(authSchema.apikeys.id, session.id),
</file context>
Fix with Cubic

const keyRow = await db.query.apikeys.findFirst({
where: eq(authSchema.apikeys.id, session.id),
columns: { metadata: true },
});
const rawMetadata = keyRow?.metadata;
let parsedMetadata: Record<string, unknown> | null = null;
if (rawMetadata) {
if (typeof rawMetadata === "string") {
try {
const parsed = JSON.parse(rawMetadata);
parsedMetadata =
parsed && typeof parsed === "object"
? (parsed as Record<string, unknown>)
: null;
} catch {
parsedMetadata = null;
}
} else if (typeof rawMetadata === "object") {
parsedMetadata = rawMetadata as Record<string, unknown>;
}
}
const boundOrganizationId =
typeof parsedMetadata?.organizationId === "string"
? parsedMetadata.organizationId
: null;

const boundMembership = boundOrganizationId
? await db.query.members.findFirst({
where: and(
eq(members.userId, user.id),
eq(members.organizationId, boundOrganizationId),
),
columns: { userId: true },
})
: undefined;

return {
sub: user.id,
email: user.email,
organizationIds: boundMembership ? [boundOrganizationId] : [],
};
}

const userMemberships = await db.query.members.findMany({
where: eq(members.userId, user.id),
columns: { organizationId: true },
Expand Down
33 changes: 30 additions & 3 deletions packages/cli/scripts/build-dist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,8 +232,10 @@ function copyNativePackages(libDir: string, target: Target): void {
*
* 1. `better-sqlite3`: download the Node-ABI prebuild from GitHub and
* overwrite `build/Release/better_sqlite3.node`.
* 2. `node-pty`: delete `build/Release/` so the `bindings` loader falls
* through to the N-API prebuild in `prebuilds/<target>/pty.node`.
* 2. `node-pty`: on darwin, fall back to the in-package N-API prebuild
* by removing `build/`. On linux-x64 there is no upstream prebuild,
* so rebuild from source via node-gyp; node-pty uses node-addon-api
* so the resulting binding is ABI-stable across Node versions.
*/
async function fixNativeBinariesForNode(
libDir: string,
Expand Down Expand Up @@ -262,7 +264,32 @@ async function fixNativeBinariesForNode(
join(bsqDest, "better_sqlite3.node"),
);

const nodePtyBuild = join(destModules, "node-pty", "build");
const nodePtyDir = join(destModules, "node-pty");
const nodePtyBuild = join(nodePtyDir, "build");

if (target === "linux-x64") {
if (existsSync(nodePtyBuild)) {
rmSync(nodePtyBuild, { recursive: true, force: true });
}
console.log(
"[build-dist] compiling node-pty from source for linux-x64 (requires python3 + gcc)",
);
await exec("npx", ["--yes", "node-gyp", "rebuild"], nodePtyDir);
const builtBinding = join(nodePtyBuild, "Release", "pty.node");
if (!existsSync(builtBinding)) {
throw new Error(`node-pty build did not produce ${builtBinding}`);
}
// Keep only the built binding; strip intermediate node-gyp artifacts
// (obj.target/, Makefile, config.gypi, .deps/, etc.) that would
// otherwise bloat the tarball by ~10MB with no runtime use.
const releaseDir = join(nodePtyBuild, "Release");
const builtBindingBytes = readFileSync(builtBinding);
rmSync(nodePtyBuild, { recursive: true, force: true });
mkdirSync(releaseDir, { recursive: true });
writeFileSync(builtBinding, builtBindingBytes);
return;
}
Comment on lines +278 to +291
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Build artifacts bloat the linux tarball

After node-gyp rebuild succeeds, only build/Release/pty.node is needed at runtime. The entire build/ directory — which includes obj.target/ (compiled .o files), Makefile, config.gypi, and other intermediate artifacts — stays in the staging tree and is packed into the tarball. Depending on the machine, this can easily add 5–20 MB.

The darwin path avoids this cleanly by removing build/ entirely (darwin falls back to the in-package prebuilds). The linux path should do the same with a targeted cleanup after the verification:

const builtBinding = join(nodePtyBuild, "Release", "pty.node");
if (!existsSync(builtBinding)) {
    throw new Error(`node-pty build did not produce ${builtBinding}`);
}
// Strip intermediate artifacts — only the .node file is needed at runtime
const objTarget = join(nodePtyBuild, "Release", "obj.target");
if (existsSync(objTarget)) rmSync(objTarget, { recursive: true, force: true });
// Also remove non-Release subdirectories that node-gyp generates
for (const entry of readdirSync(nodePtyBuild)) {
    if (entry !== "Release") {
        rmSync(join(nodePtyBuild, entry), { recursive: true, force: true });
    }
}
return;
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/cli/scripts/build-dist.ts
Line: 276-281

Comment:
**Build artifacts bloat the linux tarball**

After `node-gyp rebuild` succeeds, only `build/Release/pty.node` is needed at runtime. The entire `build/` directory — which includes `obj.target/` (compiled `.o` files), `Makefile`, `config.gypi`, and other intermediate artifacts — stays in the staging tree and is packed into the tarball. Depending on the machine, this can easily add 5–20 MB.

The darwin path avoids this cleanly by removing `build/` entirely (darwin falls back to the in-package prebuilds). The linux path should do the same with a targeted cleanup after the verification:

```ts
const builtBinding = join(nodePtyBuild, "Release", "pty.node");
if (!existsSync(builtBinding)) {
    throw new Error(`node-pty build did not produce ${builtBinding}`);
}
// Strip intermediate artifacts — only the .node file is needed at runtime
const objTarget = join(nodePtyBuild, "Release", "obj.target");
if (existsSync(objTarget)) rmSync(objTarget, { recursive: true, force: true });
// Also remove non-Release subdirectories that node-gyp generates
for (const entry of readdirSync(nodePtyBuild)) {
    if (entry !== "Release") {
        rmSync(join(nodePtyBuild, entry), { recursive: true, force: true });
    }
}
return;
```

How can I resolve this? If you propose a fix, please make it concise.


if (existsSync(nodePtyBuild)) {
console.log(
"[build-dist] removing node-pty build/ so bindings falls back to prebuilds/",
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ export function createApiClient(
url: `${getApiUrl(config)}/api/trpc`,
transformer: SuperJSON,
headers() {
return { Authorization: `Bearer ${opts.bearer}` };
return opts.bearer.startsWith("sk_live_")
? { "x-api-key": opts.bearer }
: { Authorization: `Bearer ${opts.bearer}` };
},
}),
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ export class JwtApiAuthProvider implements ApiAuthProvider {
}

const response = await fetch(`${this.apiUrl}/api/auth/token`, {
headers: { Authorization: `Bearer ${this.sessionToken}` },
headers: this.sessionToken.startsWith("sk_live_")
? { "x-api-key": this.sessionToken }
: { Authorization: `Bearer ${this.sessionToken}` },
});
if (!response.ok) {
throw new Error(`Failed to mint JWT: ${response.status}`);
Expand Down
Loading