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
22 changes: 22 additions & 0 deletions apps/ai/src/mcp/lib/resolve-tenant.scopes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,28 @@ describe("scoped API keys on the MCP server", () => {
)
})

describe("device credentials on the MCP server", () => {
it.effect("refuses one even when it carries no scopes", () =>
Effect.gen(function* () {
const apiKeys = yield* ApiKeysService
const key = yield* apiKeys.create(orgId, userId, { name: "widgets", kind: "device" })
const result = yield* resolveMcpTenantContext(
new Request("https://api.example.com/mcp", {
method: "POST",
headers: { authorization: `Bearer ${key.secret}` },
}),
).pipe(Effect.result)
expect(result._tag).toBe("Failure")
if (result._tag === "Failure") {
expect(result.failure).toMatchObject({
_tag: "@maple/mcp/errors/McpAuthInvalidError",
reason: "invalid_target",
})
}
}).pipe(Effect.provide(makeLayer())),
)
})

describe("Maple's internal service token on the MCP server", () => {
it.effect("refuses the configured token, whatever org it names", () =>
Effect.gen(function* () {
Expand Down
7 changes: 7 additions & 0 deletions apps/ai/src/mcp/lib/resolve-tenant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,13 @@ export const resolveMcpTenantContext = Effect.fn("McpTenant.resolveMcpTenantCont
reason: "invalid_target",
})
}
// A device credential is the mobile widgets' key; nothing issues one for the MCP server.
if (resolved.kind === "device") {
return yield* new McpAuthInvalidError({
message: "Device credentials cannot be used with the MCP server",
reason: "invalid_target",
})
}
const scopeAccess = isMcpOAuthToken ? "full" : mcpScopeAccess(resolved.scopes)
if (typeof scopeAccess !== "string") {
return yield* new McpAuthInvalidError({
Expand Down
13 changes: 2 additions & 11 deletions apps/api/src/routes/internal/dependency-scan.http.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,8 @@
import { HttpApiBuilder } from "effect/http-api"
import { CurrentTenant, DependencyScanForbiddenError, MapleInternalApi } from "@maple/domain/http"
import { MapleInternalApi } from "@maple/domain/http"
import { Effect } from "effect"
import * as Policy from "@maple/domain/policy"
import { DependencyScanService } from "@maple/backend/services/dependency-scan/DependencyScanService"

/** The caller's tenant, once they hold `security:manage`: the Security Center is for engineers and admins. */
const securityTenant = Effect.tap(CurrentTenant.Context, (tenant) =>
Policy.check(
tenant.roles,
"security:manage",
(denial) => new DependencyScanForbiddenError({ message: denial.message }),
),
)
import { securityTenant } from "./security-tenant"

export const HttpDependencyScanLive = HttpApiBuilder.group(MapleInternalApi, "dependencyScan", (handlers) =>
Effect.gen(function* () {
Expand Down
5 changes: 3 additions & 2 deletions apps/api/src/routes/internal/query-engine.http.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// BOUNDARY: This module owns unparsed external values and narrows them before domain use.
import { HttpApiBuilder } from "effect/http-api"
import { securityTenant } from "./security-tenant"
import { mergeSupabaseSeries } from "./supabase-infra-series"
import { apiTrafficResponse, mergeSupabaseCallers, mergeSupabaseProjects } from "./supabase-insights-merge"
import {
Expand Down Expand Up @@ -2543,7 +2544,7 @@ export const HttpQueryEngineLive = HttpApiBuilder.group(MapleInternalApi, "query
// No raw fallback, like the crawler tab: a cluster without migration 0050 answers 502.
.handle("securityExposure", ({ payload }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
const tenant = yield* securityTenant
yield* warehouse.warmRoute(tenant)
const [signatures, exposed, sources, timeline] = yield* Effect.all(
[
Expand Down Expand Up @@ -2595,7 +2596,7 @@ export const HttpQueryEngineLive = HttpApiBuilder.group(MapleInternalApi, "query
)
.handle("securityRuntimes", ({ payload }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
const tenant = yield* securityTenant
yield* warehouse.warmRoute(tenant)
const rows = yield* runQuery(Queries.runtimeVersions, tenant, payload)
const products = Arr.flatMap(rows, (row): ReadonlyArray<RuntimeProduct> => {
Expand Down
35 changes: 35 additions & 0 deletions apps/api/src/routes/internal/security-tenant.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it } from "@effect/vitest"
import { CurrentTenant, OrgId, RoleName, UserId } from "@maple/domain/http"
import { Effect, Schema } from "effect"
import { securityTenant } from "./security-tenant"

const tenantAs = (role: string) =>
new CurrentTenant.TenantSchema({
orgId: Schema.decodeSync(OrgId)("org_security"),
userId: Schema.decodeSync(UserId)("user_security"),
roles: [Schema.decodeSync(RoleName)(role)],
authMode: "clerk",
})

const resolveAs = (role: string) =>
securityTenant.pipe(Effect.provideService(CurrentTenant.Context, tenantAs(role)), Effect.result)

describe("the Security Center's tenant", () => {
it.effect("admits engineers and admins", () =>
Effect.gen(function* () {
expect((yield* resolveAs("org:engineer"))._tag).toBe("Success")
expect((yield* resolveAs("org:admin"))._tag).toBe("Success")
}),
)

it.effect("refuses members and analysts", () =>
Effect.gen(function* () {
const member = yield* resolveAs("org:member")
const analyst = yield* resolveAs("org:analyst")
expect([member._tag, analyst._tag]).toEqual(["Failure", "Failure"])
if (member._tag === "Failure") {
expect(member.failure._tag).toBe("@maple/http/dependency-scan/DependencyScanForbiddenError")
}
}),
)
})
12 changes: 12 additions & 0 deletions apps/api/src/routes/internal/security-tenant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { CurrentTenant, DependencyScanForbiddenError } from "@maple/domain/http"
import * as Policy from "@maple/domain/policy"
import { Effect } from "effect"

/** The caller's tenant, once they hold `security:manage`: the Security Center is for engineers and admins. */
export const securityTenant = Effect.tap(CurrentTenant.Context, (tenant) =>
Policy.check(
tenant.roles,
"security:manage",
(denial) => new DependencyScanForbiddenError({ message: denial.message }),
),
)
13 changes: 13 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 @@ -713,6 +713,19 @@ describe("v2 api_keys over HTTP", () => {
await harness.dispose()
})

it("refuses to create a device credential by name", async () => {
const harness = makeHarness()
const root = await harness.bootstrapKey()

const { status, body } = await harness.request("POST", "/v2/api_keys", {
token: root.secret,
body: { name: "Home Screen widgets", kind: "device" },
})
expect(status).toBe(400)
expect(body.error.type).toBe("invalid_request_error")
await harness.dispose()
})

it("rejects invalid scope strings on create with a 400 envelope", async () => {
const harness = makeHarness()
const root = await harness.bootstrapKey()
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/routes/v2/integrations-aws.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,12 @@ describe("v2 aws integration over HTTP", () => {
])
expect((await harness.request("GET", `${CONNECTIONS}/${id}/setup`, member.secret)).status).toBe(403)
expect((await harness.request("DELETE", `${CONNECTIONS}/${id}`, member.secret)).status).toBe(403)
// A setup holds the stream's access key, so a key that only reads integrations cannot render one.
const reader = await harness.adminKey(["integrations:read"])
const refused = await harness.request("GET", `${CONNECTIONS}/${id}/setup`, reader.secret)
expect([refused.status, refused.body.error.code]).toEqual([403, "insufficient_scope"])
const writer = await harness.adminKey(["integrations:write"])
expect((await harness.request("GET", `${CONNECTIONS}/${id}/setup`, writer.secret)).status).toBe(200)

const deleted = await harness.request("DELETE", `${CONNECTIONS}/${id}`, admin.secret)
expect(deleted.status).toBe(200)
Expand Down
8 changes: 8 additions & 0 deletions apps/api/src/routes/v2/integrations-aws.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import {
MapleApiV2,
isoTimestamp,
isoTimestampOrNull,
scopeAllows,
V2InsufficientPermissions,
V2InsufficientScope,
} from "@maple/domain/http/v2"
import { Effect, Array as Arr } from "effect"
import { recordHttpAudit } from "@maple/backend/services/audit/AuditLogService"
Expand Down Expand Up @@ -105,6 +107,12 @@ export const HttpV2AwsIntegrationsLive = HttpApiBuilder.group(MapleApiV2, "awsIn
yield* Policy.check(tenant.roles, "integrations:manage", (denial) =>
V2InsufficientPermissions.make(denial.message),
)
// The rendered setup holds the metric stream's access key, which writes to ingest.
if (!scopeAllows(tenant.scopes, { family: "integrations", access: "write" })) {
return yield* V2InsufficientScope.make(
'A connection\'s setup contains its stream access key, so this API key needs "integrations:write".',
)
}
const setup = yield* aws.setup(tenant.orgId, params.id)
return {
object: "aws_connection.setup" as const,
Expand Down
15 changes: 12 additions & 3 deletions apps/api/src/routes/v2/uptime-monitors.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,15 +353,24 @@ describe("v2 uptime monitors", () => {
const created = await harness.request("POST", "/v2/uptime_monitors", full, {
name: "API health",
kind: "http",
config: { type: "http", url: "https://api.example.com/health" },
config: {
type: "http",
url: "https://api.example.com/health",
headers: { Authorization: "Bearer s3cret" },
},
})
expect(created.status).toBe(200)
const path = `/v2/uptime_monitors/${created.body.id}`
const httpConfig = { type: "http", url: "https://api.example.com/health" }

// A key resolves with the default key role, so the read scope is what makes it a reader.
const { secret: reader } = await harness.bootstrapKey(undefined, ["uptime_monitors:read"])
expect((await harness.request("GET", "/v2/uptime_monitors", reader)).status).toBe(200)
expect((await harness.request("GET", path, reader)).status).toBe(200)
const listed = await harness.request("GET", "/v2/uptime_monitors", reader)
expect(listed.status).toBe(200)
expect(listed.body.data[0].config.headers).toEqual({ Authorization: "[redacted]" })
const read = await harness.request("GET", path, reader)
expect(read.status).toBe(200)
expect(read.body.config.headers).toEqual({ Authorization: "[redacted]" })
const denied = [
await harness.request("POST", "/v2/uptime_monitors", reader, {
name: "Denied",
Expand Down
13 changes: 8 additions & 5 deletions apps/api/src/routes/v2/uptime-monitors.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
isoTimestampOrNull,
MapleApiV2,
paginateArray,
scopeAllows,
uptimeConfigFromWire,
uptimeConfigToWire,
V2InsufficientPermissions,
Expand Down Expand Up @@ -62,6 +63,11 @@ const toV2UptimeMonitor = (monitor: UptimeMonitorRecord): V2UptimeMonitor => ({
updated_at: isoTimestamp(monitor.updatedAt),
})

/** Whether the caller may write monitors: the role, and for a scoped API key the write scope too. */
const canWriteMonitors = (tenant: CurrentTenant.TenantContext) =>
Policy.can(tenant.roles, "uptime_monitors:manage") &&
scopeAllows(tenant.scopes, { family: "uptime_monitors", access: "write" })

/** Header values and the heartbeat URL are credentials: callers who cannot write see neither. */
const forReader = (monitor: V2UptimeMonitor, canWrite: boolean): V2UptimeMonitor => {
if (canWrite) return monitor
Expand Down Expand Up @@ -96,7 +102,7 @@ export const HttpV2UptimeMonitorsLive = HttpApiBuilder.group(MapleApiV2, "uptime
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
const monitors = yield* service.list(tenant.orgId)
const canWrite = Policy.can(tenant.roles, "uptime_monitors:manage")
const canWrite = canWriteMonitors(tenant)
const page = yield* paginateArray(
Arr.map(monitors, (monitor) => forReader(toV2UptimeMonitor(monitor), canWrite)),
query,
Expand All @@ -108,10 +114,7 @@ export const HttpV2UptimeMonitorsLive = HttpApiBuilder.group(MapleApiV2, "uptime
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
const monitor = yield* service.get(tenant.orgId, params.id)
return forReader(
toV2UptimeMonitor(monitor),
Policy.can(tenant.roles, "uptime_monitors:manage"),
)
return forReader(toV2UptimeMonitor(monitor), canWriteMonitors(tenant))
}),
)
.handle("create", ({ payload }) =>
Expand Down
12 changes: 6 additions & 6 deletions apps/web/src/page/settings/keys/create-dialog.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ApiKeyKind } from "@maple/domain/http"
import { CreatableApiKeyKind } from "@maple/domain/http"
import { V2ApiKeyCreateParams, V2ApiKeyWithSecret, type V2Scope } from "@maple/domain/http/v2"
import { type ApiKeyCreateOptions, buildApiKeyCreatePayload } from "../kit/api-key-create-payload"
import { Effect, Result, Schema, Array as Arr, Record as Rec, String as Str } from "effect"
Expand Down Expand Up @@ -60,7 +60,7 @@ export type AccessMode = typeof AccessMode.Type

export const Model = Schema.Struct({
dialog: Dialog.Model,
kind: ApiKeyKind,
kind: CreatableApiKeyKind,
name: Field.Model,
description: Field.Model,
expiration: Select.Model,
Expand Down Expand Up @@ -103,7 +103,7 @@ export const Message = defineMessageUnion({
ClickedClearScopeFilter: {},
ClickedCancel: {},
ClickedCreate: {},
CompletedCreate: { kind: ApiKeyKind, result: Schema.Result(V2ApiKeyWithSecret, LoadFailure) },
CompletedCreate: { kind: CreatableApiKeyKind, result: Schema.Result(V2ApiKeyWithSecret, LoadFailure) },
GotSecretCopyMessage: { message: CopyButton.Message },
ToggledSecretVisible: {},
/** `trackProduct("api_key_created")` was handed to the session SDK. */
Expand All @@ -124,7 +124,7 @@ export type OutMessage = typeof OutMessage.Type
type Return = Update.ReturnWithOutMessage<Model, Message, OutMessage, Resources>

export const CreateApiKey = Command.define("CreateApiKey", {
args: { kind: ApiKeyKind, payload: V2ApiKeyCreateParams },
args: { kind: CreatableApiKeyKind, payload: V2ApiKeyCreateParams },
messages: [Message.CompletedCreate],
execute: ({ kind, payload }) =>
Effect.gen(function* () {
Expand All @@ -135,7 +135,7 @@ export const CreateApiKey = Command.define("CreateApiKey", {

/** `api_key_created`: the key's kind and access (an MCP key is always full access). */
export const TrackKeyCreated = Command.define("TrackApiKeyCreated", {
args: { kind: ApiKeyKind, access: Schema.String },
args: { kind: CreatableApiKeyKind, access: Schema.String },
messages: [Message.CompletedTrackKeyCreated],
execute: ({ kind, access }) =>
trackProduct("api_key_created", { kind, access }).pipe(Effect.as(Message.CompletedTrackKeyCreated())),
Expand Down Expand Up @@ -187,7 +187,7 @@ const reset = (model: Model): Model => ({
const close = (model: Model): Return => liftDialog(reset(model), Dialog.close(model.dialog))

/** Opens empty for `kind`; a key created while it was closed is shown. */
export const open = (model: Model, kind: ApiKeyKind): Return => {
export const open = (model: Model, kind: CreatableApiKeyKind): Return => {
const fresh = init(model.dialog.id)
const kept = { isCreating: model.isCreating, created: model.created }
return liftDialog({ ...fresh, ...kept, kind, dialog: model.dialog }, Dialog.open(model.dialog))
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/page/settings/keys/update.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as Policy from "@maple/domain/policy"
import type { ApiKeyKind } from "@maple/domain/http"
import type { CreatableApiKeyKind } from "@maple/domain/http"
import { Array, Option, Result } from "effect"
import { Command } from "foldkit"
import { PageOutMessage, type PageReturn } from "../../contract"
Expand Down Expand Up @@ -70,7 +70,7 @@ const liftRoll = (model: Model, step: ReturnType<typeof RollDialog.update>): Ret
: toast(step.outMessage.type, step.outMessage.title, step.outMessage.description),
})

export const openCreate = (model: Model, kind: ApiKeyKind): Return =>
export const openCreate = (model: Model, kind: CreatableApiKeyKind): Return =>
liftCreate(model, CreateDialog.open(model.create, kind))

/** A row menu's pick: roll or revoke the key it belongs to. */
Expand Down
3 changes: 1 addition & 2 deletions apps/web/src/page/settings/kit/api-key-create-payload.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { ApiKeyKind } from "@maple/domain/http"
import type { V2ApiKeyCreateParams, V2Scope } from "@maple/domain/http/v2"
import { String as Str } from "effect"

Expand All @@ -10,7 +9,7 @@ export interface ApiKeyCreateOptions {
export const buildApiKeyCreatePayload = (
name: string,
description: string,
kind: ApiKeyKind | undefined,
kind: V2ApiKeyCreateParams["kind"],
options: ApiKeyCreateOptions = {},
): V2ApiKeyCreateParams => {
const trimmedDescription = Str.trim(description)
Expand Down
Loading
Loading