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
98 changes: 95 additions & 3 deletions apps/api/src/routes/v2/api-keys.http.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it } from "@effect/vitest"
import { ConfigProvider, Context, Effect, Layer, ManagedRuntime, Schema } from "effect"
import { ConfigProvider, Context, Effect, Layer, ManagedRuntime, Option, Schema } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { OrgId, UserId } from "@maple/domain/http"
Expand Down Expand Up @@ -102,18 +102,42 @@
const ORG = Schema.decodeUnknownSync(OrgId)("org_e2e")
const USER = Schema.decodeUnknownSync(UserId)("user_e2e")

const bootstrapKey = (scopes?: ReadonlyArray<string>, kind: "standard" | "mcp" = "standard") =>
const bootstrapKey = (
scopes?: ReadonlyArray<string>,
kind: "standard" | "mcp" = "standard",
options: { metadataJson?: unknown; userId?: UserId } = {},
) =>
runtime.runPromise(
Effect.gen(function* () {
const service = yield* ApiKeysService
return yield* service.create(ORG, USER, {
return yield* service.create(ORG, options.userId ?? USER, {
name: scopes === undefined ? "root-key" : `scoped:${scopes.join(",")}`,
scopes,
kind,
...(options.metadataJson !== undefined ? { metadataJson: options.metadataJson } : {}),
})
}),
)

/**
* A caller with a real, non-admin role. Keys resolve with `roles: null`
* (= `root`) unless their metadata pins roles, which is how the CLI login
* flow issues member-scoped credentials.
*/
const bootstrapMemberKey = (userId: UserId = USER) =>
bootstrapKey(undefined, "standard", {
userId,
metadataJson: { source: "maple_cli", roles: ["org:member"], deviceName: "laptop" },
})

const resolveKey = (secret: string) =>
runtime.runPromise(
Effect.gen(function* () {
const service = yield* ApiKeysService
return yield* service.resolveByKey(secret)
}),
)

const bootstrapSession = () =>
runtime.runPromise(
Effect.gen(function* () {
Expand All @@ -125,7 +149,11 @@
return {
request,
bootstrapKey,
bootstrapMemberKey,
bootstrapSession,
resolveKey,
ORG,
USER,
closeDatabase: () => testDb.close(),
dispose: async () => {
await disposeHandler()
Expand Down Expand Up @@ -313,6 +341,70 @@
await harness.dispose()
})

it("lets a non-admin member create an MCP key that carries their own roles", async () => {
const harness = makeHarness()
const member = await harness.bootstrapMemberKey()

const created = await harness.request("POST", "/v2/api_keys", {
token: member.secret,
body: { name: "my editor", kind: "mcp" },
})
expect(created.status).toBe(200)
expect(created.body.kind).toBe("mcp")

// The minted key must not resolve as `root` — it inherits the member's roles.
const resolved = await harness.resolveKey(created.body.secret)
expect(Option.isSome(resolved)).toBe(true)
if (Option.isSome(resolved)) {
expect(resolved.value.roles).toEqual(["org:member"])

Check failure on line 359 in apps/api/src/routes/v2/api-keys.http.test.ts

View workflow job for this annotation

GitHub Actions / TypeScript

src/routes/v2/api-keys.http.test.ts > v2 api_keys over HTTP > lets a non-admin member create an MCP key that carries their own roles

AssertionError: expected [ 'root' ] to deeply equal [ 'org:member' ] - Expected + Received [ - "org:member", + "root", ] ❯ src/routes/v2/api-keys.http.test.ts:359:33
}
Comment on lines +356 to +360

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 New permission tests fail because API-key callers are always treated as full admins

The new tests assert that a member's key keeps member-level access (resolveKey expecting ["org:member"] at apps/api/src/routes/v2/api-keys.http.test.ts:356-360), but every request authenticated with an API key is stamped with the top-level admin role (roles: apiKeyDefaultRoles at apps/api/src/services/ApiAuthorizationV2Layer.ts:120), so the member's key is treated as an administrator and these checks do not hold.
Impact: The pull request does not pass its own newly added tests, so the change cannot merge cleanly and the member-vs-admin distinction it claims to test does not actually exist for key-authenticated callers.

Why the assertions can't hold at this revision

The member's credential is a standard key whose stored metadata pins ["org:member"], but ApiAuthorizationV2Layer ignores the resolved key's roles and sets tenant.roles to apiKeyDefaultRoles (["root"]) for all API-key auth. Consequently:

  • api-keys.http.test.ts:344-361 creates an MCP key whose pinned roles come from [...tenant.roles] (apps/api/src/routes/v2/api-keys.http.ts:130), i.e. ["root"], so the resolved roles are ["root"], not ["org:member"].
  • api-keys.http.test.ts:364-379 expects a member to get a 403 creating a standard key, but requireAdmin(["root"]) succeeds, returning 200.
  • api-keys.http.test.ts:381-406 expects a 403 revoking a foreign key, but requireAdmin(["root"]) succeeds, returning 200.

The role-propagation wiring that would make tenant.roles reflect the key's pinned roles lives in the CLI device-auth work that the PR description says is "not included and remains uncommitted locally," so the tests only pass in a working tree that has those uncommitted changes.

Prompt for agents
The new tests in apps/api/src/routes/v2/api-keys.http.test.ts assume that when a request is authenticated with an API key, the resulting tenant's roles reflect the roles pinned in the key's metadata (ResolvedApiKey.roles from ApiKeysService.resolveByKey). However, ApiAuthorizationV2Layer.ts (around line 117-123) hardcodes tenant roles to apiKeyDefaultRoles = ['root'] and never reads resolved.roles. As a result the member key is treated as root: the created MCP key pins ['root'] instead of ['org:member'], the standard-key creation is allowed instead of 403, and the foreign-key revoke is allowed instead of 403. Wire resolved.roles into the tenant construction in ApiAuthorizationV2Layer (use resolved.roles when present, falling back to apiKeyDefaultRoles) so tenant.roles reflects the key's pinned roles. This is the same wiring the PR notes is uncommitted; it must be committed for the feature and its tests to work.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

await harness.dispose()
})

it("still refuses standard API keys for a non-admin member", async () => {
const harness = makeHarness()
const member = await harness.bootstrapMemberKey()

const { status, body } = await harness.request("POST", "/v2/api_keys", {
token: member.secret,
body: { name: "ci" },
})
expect(status).toBe(403)

Check failure on line 372 in apps/api/src/routes/v2/api-keys.http.test.ts

View workflow job for this annotation

GitHub Actions / TypeScript

src/routes/v2/api-keys.http.test.ts > v2 api_keys over HTTP > still refuses standard API keys for a non-admin member

AssertionError: expected 200 to be 403 // Object.is equality - Expected + Received - 403 + 200 ❯ src/routes/v2/api-keys.http.test.ts:372:18
expect(body.error).toEqual({
type: "permission_error",
code: "insufficient_permissions",
message: "Only org admins can create API keys",
})
await harness.dispose()
})

it("lets a member revoke their own MCP key but not anyone else's key", async () => {
const harness = makeHarness()
const member = await harness.bootstrapMemberKey()

const own = await harness.request("POST", "/v2/api_keys", {
token: member.secret,
body: { name: "my editor", kind: "mcp" },
})
expect(own.status).toBe(200)

const revoked = await harness.request("DELETE", `/v2/api_keys/${own.body.id}`, {
token: member.secret,
})
expect(revoked.status).toBe(200)
expect(revoked.body.revoked).toBe(true)

const other = Schema.decodeUnknownSync(UserId)("user_other")
const foreign = await harness.bootstrapKey(undefined, "mcp", { userId: other })
const { encodePublicId } = await import("@maple/domain/http/v2")
const denied = await harness.request("DELETE", `/v2/api_keys/${encodePublicId("key", foreign.id)}`, {
token: member.secret,
})
expect(denied.status).toBe(403)

Check failure on line 403 in apps/api/src/routes/v2/api-keys.http.test.ts

View workflow job for this annotation

GitHub Actions / TypeScript

src/routes/v2/api-keys.http.test.ts > v2 api_keys over HTTP > lets a member revoke their own MCP key but not anyone else's key

AssertionError: expected 200 to be 403 // Object.is equality - Expected + Received - 403 + 200 ❯ src/routes/v2/api-keys.http.test.ts:403:25
expect(denied.body.error.code).toBe("insufficient_permissions")
await harness.dispose()
})

it("returns envelope errors for malformed and unknown IDs", async () => {
const harness = makeHarness()
const root = await harness.bootstrapKey()
Expand Down
23 changes: 21 additions & 2 deletions apps/api/src/routes/v2/api-keys.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,14 @@ export const HttpV2ApiKeysLive = HttpApiBuilder.group(MapleApiV2, "apiKeys", (ha
.handle("create", ({ payload }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
yield* requireAdmin(tenant.roles, adminOnly("create"))
// MCP keys are per-developer credentials any member may mint for
// themselves; the creator's roles are pinned onto the key so it
// carries no more authority than they have (an unpinned key
// resolves as `root`). Standard API keys stay admin-only.
const isMcpKey = payload.kind === "mcp"
if (!isMcpKey) {
yield* requireAdmin(tenant.roles, adminOnly("create"))
}
const createdByEmail = yield* auth.getUserEmail(tenant.userId)
const created = yield* apiKeysService
.create(tenant.orgId, tenant.userId, {
Expand All @@ -119,6 +126,9 @@ export const HttpV2ApiKeysLive = HttpApiBuilder.group(MapleApiV2, "apiKeys", (ha
kind: payload.kind,
scopes: payload.scopes,
createdByEmail,
...(isMcpKey
? { metadataJson: { source: "maple_mcp", roles: [...tenant.roles] } }
: {}),
})
.pipe(Effect.mapError(mapPersistenceError("create")))
return toV2ApiKeyWithSecret(created)
Expand All @@ -138,7 +148,16 @@ export const HttpV2ApiKeysLive = HttpApiBuilder.group(MapleApiV2, "apiKeys", (ha
.handle("revoke", ({ params }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
yield* requireAdmin(tenant.roles, adminOnly("revoke"))
// Whoever can mint a key must be able to kill it: a member may
// revoke an MCP key they created themselves. Everything else
// stays admin-only.
const existing = yield* apiKeysService
.get(tenant.orgId, params.id)
.pipe(mapServiceError("revoke"))
const isOwnMcpKey = existing.kind === "mcp" && existing.createdBy === tenant.userId
if (!isOwnMcpKey) {
yield* requireAdmin(tenant.roles, adminOnly("revoke"))
}
const revoked = yield* apiKeysService
.revoke(tenant.orgId, params.id)
.pipe(mapServiceError("revoke"))
Expand Down
59 changes: 59 additions & 0 deletions apps/api/src/services/ApiKeysService.scopes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,65 @@ describe("ApiKeysService scopes", () => {
}).pipe(Effect.provide(makeLayer())),
)

it.effect("fails closed for malformed Maple CLI role metadata", () =>
Effect.gen(function* () {
const service = yield* ApiKeysService
const created = yield* service.create(ORG, USER, {
name: "broken cli",
metadataJson: { source: "maple_cli", roles: [42], deviceName: "laptop" },
})
expect(Option.isNone(yield* service.resolveByKey(created.secret))).toBe(true)
}).pipe(Effect.provide(makeLayer())),
)

it.effect("resolves the roles pinned by MCP key metadata", () =>
Effect.gen(function* () {
const service = yield* ApiKeysService
const created = yield* service.create(ORG, USER, {
name: "member mcp",
kind: "mcp",
metadataJson: { source: "maple_mcp", roles: ["org:member"] },
})

const resolved = yield* service.resolveByKey(created.secret)
expect(Option.isSome(resolved)).toBe(true)
if (Option.isSome(resolved)) {
expect(resolved.value.roles).toEqual(["org:member"])
expect(resolved.value.cliManaged).toBe(false)
}
}).pipe(Effect.provide(makeLayer())),
)

it.effect("fails closed for malformed MCP role metadata", () =>
Effect.gen(function* () {
const service = yield* ApiKeysService
const created = yield* service.create(ORG, USER, {
name: "broken mcp",
kind: "mcp",
metadataJson: { source: "maple_mcp", roles: "org:member" },
})
expect(Option.isNone(yield* service.resolveByKey(created.secret))).toBe(true)
}).pipe(Effect.provide(makeLayer())),
)

it.effect("roll preserves pinned roles instead of escalating to the root default", () =>
Effect.gen(function* () {
const service = yield* ApiKeysService
const created = yield* service.create(ORG, USER, {
name: "laptop",
metadataJson: { source: "maple_cli", roles: ["org:member"], deviceName: "laptop" },
})

const rolled = yield* service.roll(ORG, USER, created.id, {})
const resolved = yield* service.resolveByKey(rolled.secret)
expect(Option.isSome(resolved)).toBe(true)
if (Option.isSome(resolved)) {
expect(resolved.value.roles).toEqual(["org:member"])
expect(resolved.value.cliManaged).toBe(true)
}
}).pipe(Effect.provide(makeLayer())),
)

it.effect("roll preserves the original key's scopes", () =>
Effect.gen(function* () {
const service = yield* ApiKeysService
Expand Down
62 changes: 62 additions & 0 deletions apps/api/src/services/ApiKeysService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
OrgId,
type PostgresTransactionId,
UserId,
RoleName,
} from "@maple/domain/http"
import { API_KEY_PREFIX, apiKeys, generateApiKey, hashApiKey, parseIngestKeyLookupHmacKey } from "@maple/db"
import { and, desc, eq } from "drizzle-orm"
Expand All @@ -28,6 +29,56 @@ export interface ResolvedApiKey {
readonly metadataJson: string | null
/** v2 scope strings; null = legacy full access. */
readonly scopes: ReadonlyArray<string> | null
readonly roles: ReadonlyArray<RoleName> | null
readonly cliManaged: boolean
}

const CliApiKeyMetadata = Schema.Struct({
source: Schema.Literal("maple_cli"),
roles: Schema.Array(RoleName),
deviceName: Schema.String,
})
const decodeCliApiKeyMetadata = Schema.decodeUnknownOption(CliApiKeyMetadata)

/**
* Metadata written when a member mints an MCP key for themselves: the creator's
* own roles are pinned to the key so it can never carry more authority than the
* user who created it (an absent `roles` resolves to `root` downstream).
*/
const McpApiKeyMetadata = Schema.Struct({
source: Schema.Literal("maple_mcp"),
roles: Schema.Array(RoleName),
})
const decodeMcpApiKeyMetadata = Schema.decodeUnknownOption(McpApiKeyMetadata)

/** Metadata `source` values that pin roles onto a key. */
const ROLE_BEARING_SOURCES = ["maple_cli", "maple_mcp"] as const

interface KeyRoleMetadata {
readonly roles: ReadonlyArray<RoleName> | null
readonly cliManaged: boolean
}

/**
* Read the roles pinned onto a key by its metadata. Fails closed: metadata that
* declares a known role-bearing `source` but doesn't decode must not fall back
* to the null (= `root`) default, so the key is rejected outright.
*/
const readKeyRoleMetadata = (metadata: unknown): Option.Option<KeyRoleMetadata> => {
if (typeof metadata !== "object" || metadata === null || !("source" in metadata)) {
return Option.some({ roles: null, cliManaged: false })
}

const source = (metadata as { source?: unknown }).source
const cli = decodeCliApiKeyMetadata(metadata)
if (Option.isSome(cli)) return Option.some({ roles: cli.value.roles, cliManaged: true })
const mcp = decodeMcpApiKeyMetadata(metadata)
if (Option.isSome(mcp)) return Option.some({ roles: mcp.value.roles, cliManaged: false })

if (typeof source === "string" && (ROLE_BEARING_SOURCES as ReadonlyArray<string>).includes(source)) {
return Option.none()
}
return Option.some({ roles: null, cliManaged: false })
}

const decodeApiKeyIdSync = Schema.decodeUnknownSync(ApiKeyId)
Expand Down Expand Up @@ -118,6 +169,7 @@ export class ApiKeysService extends Context.Service<ApiKeysService>()("@maple/ap
kind?: ApiKeyKind
scopes?: ReadonlyArray<string> | null
createdByEmail?: string | null
metadataJson?: unknown
},
) {
const id = decodeApiKeyIdSync(randomUUID())
Expand Down Expand Up @@ -147,6 +199,7 @@ export class ApiKeysService extends Context.Service<ApiKeysService>()("@maple/ap
createdAt: new Date(now),
createdBy: userId,
createdByEmail,
metadataJson: params.metadataJson,
})
.returning(txidColumn),
)
Expand Down Expand Up @@ -206,6 +259,10 @@ export class ApiKeysService extends Context.Service<ApiKeysService>()("@maple/ap
keyPrefix,
kind: existing.kind,
scopes: existing.scopes ?? null,
// Carry the role metadata across: a rolled key that lost it
// would resolve with the null (= `root`) default, silently
// escalating a CLI/MCP key beyond its creator's roles.
metadataJson: existing.metadataJson,
expiresAt: null,
createdAt: new Date(now),
createdBy: userId,
Expand Down Expand Up @@ -274,13 +331,18 @@ export class ApiKeysService extends Context.Service<ApiKeysService>()("@maple/ap
if (row.value.expiresAt.getTime() < now) return Option.none()
}

const roleMetadata = readKeyRoleMetadata(row.value.metadataJson)
if (Option.isNone(roleMetadata)) return Option.none()

return Option.some({
orgId: decodeOrgIdSync(row.value.orgId),
userId: decodeUserIdSync(row.value.createdBy),
keyId: decodeApiKeyIdSync(row.value.id),
kind: row.value.kind,
metadataJson: row.value.metadataJson == null ? null : JSON.stringify(row.value.metadataJson),
scopes: row.value.scopes ?? null,
roles: roleMetadata.value.roles,
cliManaged: roleMetadata.value.cliManaged,
} satisfies ResolvedApiKey)
})

Expand Down
Loading
Loading