diff --git a/apps/ai/src/mcp/lib/resolve-tenant.scopes.test.ts b/apps/ai/src/mcp/lib/resolve-tenant.scopes.test.ts index b0b4c9d16f..b449de922f 100644 --- a/apps/ai/src/mcp/lib/resolve-tenant.scopes.test.ts +++ b/apps/ai/src/mcp/lib/resolve-tenant.scopes.test.ts @@ -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* () { diff --git a/apps/ai/src/mcp/lib/resolve-tenant.ts b/apps/ai/src/mcp/lib/resolve-tenant.ts index 35f52cbaf5..e26d630c74 100644 --- a/apps/ai/src/mcp/lib/resolve-tenant.ts +++ b/apps/ai/src/mcp/lib/resolve-tenant.ts @@ -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({ diff --git a/apps/api/src/routes/internal/dependency-scan.http.ts b/apps/api/src/routes/internal/dependency-scan.http.ts index e8469c72e6..8e156917de 100644 --- a/apps/api/src/routes/internal/dependency-scan.http.ts +++ b/apps/api/src/routes/internal/dependency-scan.http.ts @@ -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* () { diff --git a/apps/api/src/routes/internal/query-engine.http.ts b/apps/api/src/routes/internal/query-engine.http.ts index 7ce5ac0c4c..dea63cb441 100644 --- a/apps/api/src/routes/internal/query-engine.http.ts +++ b/apps/api/src/routes/internal/query-engine.http.ts @@ -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 { @@ -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( [ @@ -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 => { diff --git a/apps/api/src/routes/internal/security-tenant.test.ts b/apps/api/src/routes/internal/security-tenant.test.ts new file mode 100644 index 0000000000..385fbcabb5 --- /dev/null +++ b/apps/api/src/routes/internal/security-tenant.test.ts @@ -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") + } + }), + ) +}) diff --git a/apps/api/src/routes/internal/security-tenant.ts b/apps/api/src/routes/internal/security-tenant.ts new file mode 100644 index 0000000000..f4fa2f965c --- /dev/null +++ b/apps/api/src/routes/internal/security-tenant.ts @@ -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 }), + ), +) diff --git a/apps/api/src/routes/v2/api-keys.http.test.ts b/apps/api/src/routes/v2/api-keys.http.test.ts index 10d3894c7c..c35b635bf9 100644 --- a/apps/api/src/routes/v2/api-keys.http.test.ts +++ b/apps/api/src/routes/v2/api-keys.http.test.ts @@ -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() diff --git a/apps/api/src/routes/v2/integrations-aws.http.test.ts b/apps/api/src/routes/v2/integrations-aws.http.test.ts index 3bf94d1830..439e7dbe08 100644 --- a/apps/api/src/routes/v2/integrations-aws.http.test.ts +++ b/apps/api/src/routes/v2/integrations-aws.http.test.ts @@ -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) diff --git a/apps/api/src/routes/v2/integrations-aws.http.ts b/apps/api/src/routes/v2/integrations-aws.http.ts index 4263cce7f4..bc02804a10 100644 --- a/apps/api/src/routes/v2/integrations-aws.http.ts +++ b/apps/api/src/routes/v2/integrations-aws.http.ts @@ -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" @@ -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, diff --git a/apps/api/src/routes/v2/uptime-monitors.http.test.ts b/apps/api/src/routes/v2/uptime-monitors.http.test.ts index 275940948f..2dfefe0e4c 100644 --- a/apps/api/src/routes/v2/uptime-monitors.http.test.ts +++ b/apps/api/src/routes/v2/uptime-monitors.http.test.ts @@ -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", diff --git a/apps/api/src/routes/v2/uptime-monitors.http.ts b/apps/api/src/routes/v2/uptime-monitors.http.ts index c22f493aab..9b18e16cfe 100644 --- a/apps/api/src/routes/v2/uptime-monitors.http.ts +++ b/apps/api/src/routes/v2/uptime-monitors.http.ts @@ -5,6 +5,7 @@ import { isoTimestampOrNull, MapleApiV2, paginateArray, + scopeAllows, uptimeConfigFromWire, uptimeConfigToWire, V2InsufficientPermissions, @@ -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 @@ -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, @@ -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 }) => diff --git a/apps/web/src/page/settings/keys/create-dialog.ts b/apps/web/src/page/settings/keys/create-dialog.ts index a1eb69264b..a314168ca6 100644 --- a/apps/web/src/page/settings/keys/create-dialog.ts +++ b/apps/web/src/page/settings/keys/create-dialog.ts @@ -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" @@ -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, @@ -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. */ @@ -124,7 +124,7 @@ export type OutMessage = typeof OutMessage.Type type Return = Update.ReturnWithOutMessage 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* () { @@ -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())), @@ -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)) diff --git a/apps/web/src/page/settings/keys/update.ts b/apps/web/src/page/settings/keys/update.ts index 771bee3c5b..8407a9c1fd 100644 --- a/apps/web/src/page/settings/keys/update.ts +++ b/apps/web/src/page/settings/keys/update.ts @@ -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" @@ -70,7 +70,7 @@ const liftRoll = (model: Model, step: ReturnType): 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. */ diff --git a/apps/web/src/page/settings/kit/api-key-create-payload.ts b/apps/web/src/page/settings/kit/api-key-create-payload.ts index 20c69bbb2f..6d040804f1 100644 --- a/apps/web/src/page/settings/kit/api-key-create-payload.ts +++ b/apps/web/src/page/settings/kit/api-key-create-payload.ts @@ -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" @@ -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) diff --git a/packages/backend/src/services/auth/MembershipRevocationService.test.ts b/packages/backend/src/services/auth/MembershipRevocationService.test.ts index f4188ef6af..d0edd5ada6 100644 --- a/packages/backend/src/services/auth/MembershipRevocationService.test.ts +++ b/packages/backend/src/services/auth/MembershipRevocationService.test.ts @@ -139,7 +139,8 @@ const seedChatIdentity = (db: TestDb, id: string, org: OrgId, user: UserId) => ), ) -const issueMcpGrant = Effect.fnUntraced(function* ( +/** An MCP authorization the user approved and the client has not exchanged yet. */ +const approveMcpAuthorization = Effect.fnUntraced(function* ( oauth: McpOAuthService, user: UserId, org: OrgId, @@ -168,17 +169,30 @@ const issueMcpGrant = Effect.fnUntraced(function* ( roles, userEmail: null, }) - const tokens = yield* oauth.exchangeAuthorizationCode( + return { clientId: client.client_id, code: new URL(approved.redirectUri).searchParams.get("code")! } +}) + +const exchangeMcpCode = (oauth: McpOAuthService, approval: { clientId: string; code: string }) => + oauth.exchangeAuthorizationCode( { - code: new URL(approved.redirectUri).searchParams.get("code")!, - clientId: client.client_id, + code: approval.code, + clientId: approval.clientId, redirectUri, codeVerifier: verifier, resource, }, "127.0.0.1", ) - return { clientId: client.client_id, tokens } + +const issueMcpGrant = Effect.fnUntraced(function* ( + oauth: McpOAuthService, + user: UserId, + org: OrgId, + roles: ReadonlyArray = [memberRole], +) { + const approval = yield* approveMcpAuthorization(oauth, user, org, roles) + const tokens = yield* exchangeMcpCode(oauth, approval) + return { clientId: approval.clientId, tokens } }) /** A digest subscriber row; `optedOut` stamps the member's own opt-out. */ @@ -483,6 +497,26 @@ describe("MembershipRevocationService", () => { const toMember = yield* revocation.demoteMembership(orgId, stayerId, [memberRole]) expect(toMember.apiKeysRevoked).toBe(1) expect(yield* stillWorks(engineerToken)).toBe(false) + + // An approval made as an engineer and not yet redeemed mints nothing after the demotion. + const pending = yield* cli.start("Laptop", "127.0.0.1") + yield* cli.approve(pending.userCode, { + orgId, + userId: stayerId, + roles: [engineerRole], + userEmail: null, + }) + // One approved at the role the member keeps survives. + const kept = yield* cli.start("Laptop", "127.0.0.1") + yield* cli.approve(kept.userCode, { + orgId, + userId: stayerId, + roles: [memberRole], + userEmail: null, + }) + yield* revocation.demoteMembership(orgId, stayerId, [memberRole]) + expect((yield* cli.poll(pending.deviceCode)).status).toBe("expired") + expect((yield* cli.poll(kept.deviceCode)).status).toBe("complete") }).pipe(Effect.provide(makeLayer(db, cache.layer))) }) @@ -510,6 +544,25 @@ describe("MembershipRevocationService", () => { }).pipe(Effect.provide(makeLayer(db, cache.layer))) }) + it.effect("leaves nothing to exchange from an MCP approval made above the new role", () => { + const db = createTestDb(createdDbs) + const cache = recordingEdgeCache() + return Effect.gen(function* () { + const revocation = yield* MembershipRevocationService + const oauth = yield* McpOAuthService + + const pending = yield* approveMcpAuthorization(oauth, stayerId, orgId, [engineerRole]) + const kept = yield* approveMcpAuthorization(oauth, stayerId, orgId, [memberRole]) + yield* revocation.demoteMembership(orgId, stayerId, [memberRole]) + + const refused = yield* Effect.result(exchangeMcpCode(oauth, pending)) + expect(refused._tag).toBe("Failure") + if (refused._tag === "Failure") expect(refused.failure).toMatchObject({ error: "invalid_grant" }) + // One approved at the role the member keeps is still good. + expect((yield* exchangeMcpCode(oauth, kept)).token_type).toBe("Bearer") + }).pipe(Effect.provide(makeLayer(db, cache.layer))) + }) + it.effect("sweeps every org for a deleted user", () => { const db = createTestDb(createdDbs) const cache = recordingEdgeCache() diff --git a/packages/backend/src/services/auth/MembershipRevocationService.ts b/packages/backend/src/services/auth/MembershipRevocationService.ts index c23b9bf412..c001f5e3e1 100644 --- a/packages/backend/src/services/auth/MembershipRevocationService.ts +++ b/packages/backend/src/services/auth/MembershipRevocationService.ts @@ -389,6 +389,39 @@ const make = Effect.gen(function* () { const summary = yield* dbExecute((db) => db.transaction( Effect.gen(function* () { + // An approval not yet redeemed froze the old role and would mint a credential at it. + const cliApprovals = yield* db.run( + PG.from(CliDeviceAuthorizations) + .select("deviceCodeHash", "approvedRoles") + .where(($) => [$.approvedOrgId.eq(orgId), $.approvedUserId.eq(userId)]), + ) + const [firstCli, ...restCli] = Arr.map( + Arr.filter(cliApprovals, (row) => outlivesDemotion(row.approvedRoles ?? [])), + (row) => row.deviceCodeHash, + ) + if (firstCli !== undefined) { + yield* db.run( + PG.deleteFrom(CliDeviceAuthorizations).where(($) => [ + $.deviceCodeHash.in_(firstCli, ...restCli), + ]), + ) + } + const mcpApprovals = yield* db.run( + PG.from(McpOAuthAuthorizations) + .select("requestIdHash", "approvedRoles") + .where(($) => [$.approvedOrgId.eq(orgId), $.approvedUserId.eq(userId)]), + ) + const [firstMcp, ...restMcp] = Arr.map( + Arr.filter(mcpApprovals, (row) => outlivesDemotion(row.approvedRoles ?? [])), + (row) => row.requestIdHash, + ) + if (firstMcp !== undefined) { + yield* db.run( + PG.deleteFrom(McpOAuthAuthorizations).where(($) => [ + $.requestIdHash.in_(firstMcp, ...restMcp), + ]), + ) + } // Device credentials pin roles in a column rather than key metadata. const liveDevices = yield* db.run( PG.from(DeviceCredentials) diff --git a/packages/domain/src/api-keys.ts b/packages/domain/src/api-keys.ts index 4a9ffef0e9..56b41363a8 100644 --- a/packages/domain/src/api-keys.ts +++ b/packages/domain/src/api-keys.ts @@ -11,3 +11,7 @@ import { Schema } from "effect" */ export const ApiKeyKind = Schema.Literals(["standard", "mcp", "device"]) export type ApiKeyKind = Schema.Schema.Type + +/** The kinds a caller may create by name. A `device` credential is issued to an installation by its own endpoints. */ +export const CreatableApiKeyKind = Schema.Literals(["standard", "mcp"]) +export type CreatableApiKeyKind = Schema.Schema.Type diff --git a/packages/domain/src/http/api-keys.ts b/packages/domain/src/http/api-keys.ts index dddef40db4..80e8283632 100644 --- a/packages/domain/src/http/api-keys.ts +++ b/packages/domain/src/http/api-keys.ts @@ -3,7 +3,7 @@ import { HttpTaggedError } from "./error-policy" import { ApiKeyId, PostgresTransactionId, UserId } from "@maple/primitives" import { ApiKeyKind } from "../api-keys" -export { ApiKeyKind } from "../api-keys" +export { ApiKeyKind, CreatableApiKeyKind } from "../api-keys" export class ApiKeyResponse extends Schema.Class("ApiKeyResponse")({ id: ApiKeyId, diff --git a/packages/domain/src/http/query-engine.ts b/packages/domain/src/http/query-engine.ts index 2a656bb7b0..9d0dce4891 100644 --- a/packages/domain/src/http/query-engine.ts +++ b/packages/domain/src/http/query-engine.ts @@ -33,6 +33,7 @@ import { } from "../query-engine" import { AuditedRead } from "./audit-log" import { SessionAuthorization } from "./current-tenant" +import { DependencyScanForbiddenError } from "./dependency-scan" import { HttpTaggedError } from "./error-policy" import { warehouseHttpErrors } from "./warehouse" import { @@ -4119,6 +4120,9 @@ const queryEngineEndpointErrors = [ ...warehouseHttpErrors, ] as const +// The Security Center's telemetry tabs are for engineers and admins, like its findings. +const securityEndpointErrors = [...queryEngineEndpointErrors, DependencyScanForbiddenError] as const + const costEndpointErrors = [ CloudCostDisabledError, CloudCostPersistenceError, @@ -4969,14 +4973,14 @@ export class QueryEngineApiGroup extends HttpApiGroup.make("queryEngine") HttpApiEndpoint.post("securityExposure", "/security-exposure", { payload: SecurityExposureRequest, success: SecurityExposureResponse, - error: queryEngineEndpointErrors, + error: securityEndpointErrors, }), ) .add( HttpApiEndpoint.post("securityRuntimes", "/security-runtimes", { payload: SecurityRuntimesRequest, success: SecurityRuntimesResponse, - error: queryEngineEndpointErrors, + error: securityEndpointErrors, }), ) .add( diff --git a/packages/domain/src/http/v2/api-keys.ts b/packages/domain/src/http/v2/api-keys.ts index f2adf60d9f..1a7b23e27e 100644 --- a/packages/domain/src/http/v2/api-keys.ts +++ b/packages/domain/src/http/v2/api-keys.ts @@ -1,7 +1,7 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api" import { Schema } from "effect" import { ApiKeyId, PostgresTransactionId, UserId } from "@maple/primitives" -import { ApiKeyKind, ApiKeyNotFoundError, ApiKeyPersistenceError } from "../api-keys" +import { ApiKeyKind, ApiKeyNotFoundError, ApiKeyPersistenceError, CreatableApiKeyKind } from "../api-keys" import { AuthorizationV2, V2Scope } from "./auth" import { wireExample, ListOf, ListQuery, Timestamp } from "./envelopes" import { V2InsufficientPermissions, V2ParameterInvalid } from "./errors" @@ -161,7 +161,7 @@ export const V2ApiKeyCreateParams = Schema.Struct({ }), ), kind: Schema.optionalKey( - ApiKeyKind.annotate({ + CreatableApiKeyKind.annotate({ description: "Key type. Defaults to `standard`.", examples: ["standard"], }), diff --git a/packages/domain/src/http/v2/integrations-aws.ts b/packages/domain/src/http/v2/integrations-aws.ts index fa52b7730f..2a32eb3d56 100644 --- a/packages/domain/src/http/v2/integrations-aws.ts +++ b/packages/domain/src/http/v2/integrations-aws.ts @@ -379,7 +379,7 @@ export class V2AwsIntegrationsApiGroup extends HttpApiGroup.make("awsIntegration identifier: "getAwsConnectionSetup", summary: "Render a connection's setup", description: - "Returns the CloudShell script, the CloudFormation template and the Terraform file that set up one connection. Requires an org-admin role and the `integrations:read` scope.", + "Returns the CloudShell script, the CloudFormation template and the Terraform file that set up one connection. They contain the metric stream's access key, so this requires an org-admin role and, for a scoped API key, the `integrations:write` scope.", }), ), )