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
48 changes: 48 additions & 0 deletions apps/api/src/routes/v2/api-keys.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,54 @@ describe("v2 api_keys over HTTP", () => {
await harness.dispose()
})

// `x-maple-org-id` names an organization explicitly instead of relying on the
// credential's own. This deployment is self-hosted, which has no membership
// directory to check a selection against — so the header is REJECTED rather
// than ignored. Silently ignoring it is the failure that would render one
// organization's data under another's name.
it("rejects an organization selection it cannot verify, in the v2 envelope", async () => {
const harness = makeHarness()
const sessionToken = await harness.bootstrapSession()

const { status, body } = await harness.request("GET", "/v2/api_keys", {
token: sessionToken,
headers: { "x-maple-org-id": "org_other" },
})

// 403, not the 401 a missing organization produces: the credential is fine.
expect(status).toBe(403)
expect((body as { error?: { code?: string } }).error?.code).toBe("organization_access_denied")
await harness.dispose()
})

it("serves the credential's own organization when the header names it", async () => {
const harness = makeHarness()
const sessionToken = await harness.bootstrapSession()

const { status } = await harness.request("GET", "/v2/api_keys", {
token: sessionToken,
// The free no-op, which is what lets a client send the header always.
headers: { "x-maple-org-id": "default" },
})

expect(status).toBe(200)
await harness.dispose()
})

it("rejects an organization selection made with an API key", async () => {
const harness = makeHarness()
const key = await harness.bootstrapKey()

const { status, body } = await harness.request("GET", "/v2/api_keys", {
token: key.secret,
headers: { "x-maple-org-id": "org_other" },
})

expect(status).toBe(403)
expect((body as { error?: { code?: string } }).error?.code).toBe("organization_access_denied")
await harness.dispose()
})

it("allows requests when the limiter fails open", async () => {
const harness = makeHarness(() => Effect.succeed("failed_open"))
const key = await harness.bootstrapKey()
Expand Down
11 changes: 11 additions & 0 deletions apps/api/src/runtime/http-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ import { ApiAuthorizationLayer } from "@/services/auth/ApiAuthorizationLayer"
import { ApiAuthorizationV2Layer } from "@/services/auth/ApiAuthorizationV2Layer"
import { SessionAuthorizationLayer } from "@/services/auth/SessionAuthorizationLayer"
import { ApiV2RateLimiter } from "@/services/auth/ApiV2RateLimiter"
import { EdgeCacheService } from "@maple/cache"
import { CacheBackendLive } from "@/platform/CacheBackendLive"
import { OrgMembershipService } from "@/services/auth/OrgMembershipService"
import { ApiKeysService } from "@/services/org/ApiKeysService"

const HealthRouter = HttpRouter.use((router) => router.add("GET", "/health", HttpServerResponse.text("OK")))
Expand Down Expand Up @@ -166,6 +169,14 @@ export const ApiAuthLive = Layer.mergeAll(
).pipe(
Layer.provideMerge(ApiV2RateLimiter.layer),
Layer.provideMerge(ApiKeysService.layer),
// Membership verification for `x-maple-org-id`. Only the v2 layer asks for
// it; without it that layer cannot build, which is deliberate — the header
// must never end up silently ignored in a runtime that forgot to wire this.
Layer.provideMerge(
OrgMembershipService.layer.pipe(
Layer.provide(EdgeCacheService.layer.pipe(Layer.provide(CacheBackendLive))),
),
),
Layer.provideMerge(Env.layer),
)

Expand Down
40 changes: 38 additions & 2 deletions apps/api/src/services/auth/ApiAuthorizationV2Layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ import {
scopeAllows,
V2InsufficientScope,
V2InvalidCredentials,
V2OrganizationAccessDenied,
V2RateLimited,
} from "@maple/domain/http/v2"
import { Effect, Layer, Option, Schema } from "effect"
import { ApiKeysService } from "@/services/org/ApiKeysService"
import { ORG_SELECTION_HEADER } from "@maple/auth"
import { makeResolveTenant } from "./AuthService"
import { OrgMembershipService } from "@/services/auth/OrgMembershipService"
import { annotateAuthSpan } from "@/services/auth/auth-span"
import { Env } from "@/platform/Env"
import {
Expand All @@ -31,6 +34,9 @@ const getBearerToken = (headers: Record<string, string | undefined>): string | u
return token
}

const getOrgSelectionHeader = (headers: Record<string, string | undefined>): string | undefined =>
headers[ORG_SELECTION_HEADER] ?? headers[ORG_SELECTION_HEADER.toUpperCase()]

const requestPath = (url: string): string => {
const queryStart = url.indexOf("?")
return queryStart === -1 ? url : url.slice(0, queryStart)
Expand All @@ -49,7 +55,20 @@ export const ApiAuthorizationV2Layer = Layer.effect(
const env = yield* Env
const apiKeys = yield* ApiKeysService
const rateLimiter = yield* ApiV2RateLimiter
const resolveTenant = makeResolveTenant(env)
// The one resolver wired for organization selection: `x-maple-org-id` is
// a v2-client affordance (the iOS app publishing a widget snapshot per
// organization), and every other resolver rejects the header instead.
//
// Optional so a route test can build this layer without a membership
// directory. Absent, the header is *rejected* rather than ignored — a
// runtime that forgot to wire it serves 403s, never another org's data.
const membership = yield* Effect.serviceOption(OrgMembershipService)
const resolveTenant = makeResolveTenant(
env,
undefined,
undefined,
Option.match(membership, { onNone: () => undefined, onSome: (service) => service.verify }),
)

return AuthorizationV2.of({
bearer: (httpEffect) =>
Expand Down Expand Up @@ -99,6 +118,19 @@ export const ApiAuthorizationV2Layer = Layer.effect(
)
}

// An API key is already organization-bound, so a selection could
// only ever widen it. This path returns before `resolveTenant`
// runs, so the guard inside the resolver never sees it — the
// check has to be here too.
const requestedOrg = getOrgSelectionHeader(request.headers)
if (requestedOrg !== undefined && requestedOrg !== resolved.orgId) {
return yield* Effect.fail(
V2OrganizationAccessDenied.make(
"An API key cannot select a different organization.",
),
)
}

const tenant = new CurrentTenant.TenantSchema({
orgId: resolved.orgId,
userId: resolved.userId,
Expand All @@ -109,7 +141,11 @@ export const ApiAuthorizationV2Layer = Layer.effect(
return yield* Effect.provideService(httpEffect, CurrentTenant.Context, tenant)
}

const tenant = yield* resolveTenant(request.headers)
const tenant = yield* resolveTenant(request.headers).pipe(
Effect.catchTag("@maple/http/errors/OrganizationAccessDeniedError", (error) =>
Effect.fail(V2OrganizationAccessDenied.make(error.message)),
),
)
yield* annotateAuthSpan("session", { orgId: tenant.orgId, userId: tenant.userId })
return yield* Effect.provideService(
httpEffect,
Expand Down
36 changes: 32 additions & 4 deletions apps/api/src/services/auth/AuthService.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {
AuthorizationUnavailableError,
OrganizationAccessDeniedError,
SelfHostedAuthDisabledError,
SelfHostedInvalidPasswordError,
SelfHostedLoginResponse,
Expand All @@ -13,8 +15,9 @@ import {
makeResolveTenant,
type TenantContext,
} from "@maple/auth"
import { Context, Effect, Layer } from "effect"
import { Context, Effect, Layer, Option } from "effect"
import { Env } from "@/platform/Env"
import { OrgMembershipService } from "@/services/auth/OrgMembershipService"

// The pure tenant-resolution + self-hosted login primitives live in the shared
// `@maple/auth` package (consumed by apps/api AND the standalone
Expand All @@ -27,8 +30,24 @@ export { makeResolveTenant, type TenantContext }
type HeaderRecord = Record<string, string | undefined>

export interface AuthServiceApi {
readonly resolveTenant: (headers: HeaderRecord) => Effect.Effect<TenantContext, UnauthorizedError>
readonly resolveMcpTenant: (headers: HeaderRecord) => Effect.Effect<TenantContext, UnauthorizedError>
readonly resolveTenant: (
headers: HeaderRecord,
) => Effect.Effect<
TenantContext,
UnauthorizedError | OrganizationAccessDeniedError | AuthorizationUnavailableError
>
/**
* Same resolver, wider credentials. It can reject an organization selection
* too: `makeResolveMcpTenant` passes no membership verifier, so
* `x-maple-org-id` naming anything other than the credential's own
* organization is a 403 rather than a silent ignore.
*/
readonly resolveMcpTenant: (
headers: HeaderRecord,
) => Effect.Effect<
TenantContext,
UnauthorizedError | OrganizationAccessDeniedError | AuthorizationUnavailableError
>
readonly loginSelfHosted: (
password: string,
) => Effect.Effect<SelfHostedLoginResponse, SelfHostedAuthDisabledError | SelfHostedInvalidPasswordError>
Expand All @@ -46,7 +65,16 @@ export class AuthService extends Context.Service<AuthService, AuthServiceApi>()(
{
make: Effect.gen(function* () {
const env = yield* Env
const resolveTenant = makeResolveTenant(env)
// Optional on purpose. Where it is absent — tests, and any runtime that
// has not wired it — `makeResolveTenant` rejects `x-maple-org-id`
// outright rather than ignoring it, which is the safe half of the
// no-silent-ignore rule.
const membership = yield* Effect.serviceOption(OrgMembershipService)
const verifyOrgMembership = Option.match(membership, {
onNone: () => undefined,
onSome: (service) => service.verify,
})
const resolveTenant = makeResolveTenant(env, undefined, undefined, verifyOrgMembership)
const resolveMcpTenant = makeResolveMcpTenant(env)
const loginSelfHosted = makeLoginSelfHosted(env)
const refreshSelfHostedSession = makeRefreshSelfHostedSession(env)
Expand Down
72 changes: 72 additions & 0 deletions apps/api/src/services/auth/OrgMembershipService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { assert, describe, it } from "@effect/vitest"
import { AuthorizationUnavailableError } from "@maple/domain/http"
import { Effect } from "effect"
import { type ClerkMembershipRow, collectMemberships } from "./OrgMembershipService"

const row = (id: string, role = "org:member"): ClerkMembershipRow => ({
organization: { id },
role,
})

/** A full page is what tells the pager to ask for another one. */
const fullPage = (prefix: string) => Array.from({ length: 100 }, (_, index) => row(`${prefix}_${index}`))

describe("collectMemberships", () => {
it.effect("stops on the first short page", () =>
Effect.gen(function* () {
const offsets: Array<number> = []
const result = yield* collectMemberships((offset) => {
offsets.push(offset)
return Effect.succeed(offset === 0 ? fullPage("org_a") : [row("org_last")])
})

assert.deepStrictEqual(offsets, [0, 100])
assert.strictEqual(result.memberships.length, 101)
assert.isFalse(result.truncated)
}),
)

/**
* The flag that sends a miss to the precise pair lookup. Without it, a user in
* more organizations than we page through would be told they are not a member
* of one they are in.
*/
it.effect("marks a user with more organizations than we page through as truncated", () =>
Effect.gen(function* () {
let pages = 0
const result = yield* collectMemberships(() => {
pages += 1
return Effect.succeed(fullPage("org"))
})

assert.strictEqual(pages, 5)
assert.strictEqual(result.memberships.length, 500)
assert.isTrue(result.truncated)
}),
)

// `OrgId`/`RoleName` are non-empty, trimmed strings — the ids Clerk cannot
// give us are blank or padded ones.
it.effect("drops memberships it cannot model rather than failing the request", () =>
Effect.gen(function* () {
const result = yield* collectMemberships(() =>
Effect.succeed([row("org_ok"), row("org_padded", " org:member "), row("")]),
)

assert.deepStrictEqual(
result.memberships.map((membership) => membership.orgId),
["org_ok"],
)
}),
)

it.effect("a page failure is a failure, never a shorter answer", () =>
Effect.gen(function* () {
const result = yield* collectMemberships(() =>
Effect.fail(new AuthorizationUnavailableError({ message: "Clerk unreachable" })),
).pipe(Effect.result)

assert.strictEqual(result._tag, "Failure")
}),
)
})
Loading
Loading