From 7ea2550b6c6d403cf550cf93190df1af835cf9c5 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 00:43:58 +0100 Subject: [PATCH 1/4] feat(ai): keep MCP tools and chat turns read-only for observers An observer is refused every MCP tool that changes data and is not shown one in tools/list, the same as a read-only API key. The check reads the role, so it holds on every way a tenant reaches the registry: an API key, an MCP sign-in, a session and a chat turn. A chat turn run for an observer is offered no approval-gated tool, since an observer could not approve the proposal. They can still ask questions: the read tools are unchanged. --- apps/ai/src/chat/agents.test.ts | 21 +++++++++++- apps/ai/src/chat/profiles.ts | 14 +++++++- apps/ai/src/chat/run.ts | 4 +-- apps/ai/src/mcp/server.ts | 6 ++-- .../mcp/tools/__tests__/read-only-key.test.ts | 32 ++++++++++++++++--- apps/ai/src/mcp/tools/registry.ts | 8 +++-- apps/ai/src/mcp/tools/types.ts | 7 ++++ 7 files changed, 79 insertions(+), 13 deletions(-) diff --git a/apps/ai/src/chat/agents.test.ts b/apps/ai/src/chat/agents.test.ts index 8fe2c0e099..6f206ceef8 100644 --- a/apps/ai/src/chat/agents.test.ts +++ b/apps/ai/src/chat/agents.test.ts @@ -20,7 +20,7 @@ import { mapleToolCatalog } from "../mcp/tools/registry" import { MUTATING_TOOL_NAMES } from "./permissions" import type { TenantContext } from "@maple/domain/http" import { AGENTS, agentForSession, isAutonomousTurn, sessionOf } from "./agents" -import { profileForTurn } from "./profiles" +import { profileForRoles, profileForTurn } from "./profiles" import { CONNECTOR_SYSTEM_PROMPT } from "./prompts" import { buildChatToolkit } from "./maple-toolkit" @@ -137,6 +137,25 @@ describe("profileForTurn", () => { } }) + it("offers an observer's turn the read tools and none that change data", () => { + const profile = profileForRoles(profileForTurn(agentForSession(appSession), { kind: "app" }), [ + "org:observer", + ]) + const { tools } = buildChatToolkit(executor, tenant, profile.ruleset, profile.surface).toolkit + assert.property(tools, "search_traces") + for (const name of MUTATING_TOOL_NAMES) { + assert.notProperty(tools, name, `${name} was offered to an observer`) + } + // A member's turn on the same session still proposes them. + const member = profileForRoles(profileForTurn(agentForSession(appSession), { kind: "app" }), [ + "org:member", + ]) + assert.isNotEmpty([...MUTATING_TOOL_NAMES]) + for (const name of MUTATING_TOOL_NAMES) { + assert.equal(evaluatePermission(member.ruleset, name), "ask", name) + } + }) + it("offers a connector the repository sandbox but no internal-audience tool", () => { // The reviewer's pull request tools act on the org's behalf; a channel never reaches them. const { tools } = toolsFor(connectorSession, CONNECTOR_ORIGIN) diff --git a/apps/ai/src/chat/profiles.ts b/apps/ai/src/chat/profiles.ts index de8ab9450c..bcdf976a03 100644 --- a/apps/ai/src/chat/profiles.ts +++ b/apps/ai/src/chat/profiles.ts @@ -6,7 +6,9 @@ * surface and be billed, traced or prompted as another. */ import type { ChatTurnOrigin } from "@maple/domain/chat-session" -import type { PermissionRuleset } from "@maple/domain/permission" +import { PermissionRule, type PermissionRuleset } from "@maple/domain/permission" +import * as Policy from "@maple/domain/policy" +import { Array as Arr } from "effect" import type { AgentDefinition } from "./agents" import { READ_ONLY_RULESET } from "./permissions" import { CONNECTOR_SYSTEM_PROMPT } from "./prompts" @@ -21,6 +23,16 @@ export interface TurnProfile { readonly prompt: string } +/** The ruleset with every approval-gated tool withheld: an observer could not approve one. */ +const withoutProposals = (ruleset: PermissionRuleset): PermissionRuleset => + Arr.map(ruleset, (rule) => + rule.action === "ask" ? new PermissionRule({ tool: rule.tool, action: "deny" }) : rule, + ) + +/** The profile for whoever the turn's tools run as: an observer is offered nothing that changes data. */ +export const profileForRoles = (profile: TurnProfile, roles: ReadonlyArray): TurnProfile => + Policy.isReadOnly(roles) ? { ...profile, ruleset: withoutProposals(profile.ruleset) } : profile + export const profileForTurn = (agent: AgentDefinition, origin: ChatTurnOrigin): TurnProfile => { switch (origin.kind) { case "app": diff --git a/apps/ai/src/chat/run.ts b/apps/ai/src/chat/run.ts index fb51d25155..5325a13548 100644 --- a/apps/ai/src/chat/run.ts +++ b/apps/ai/src/chat/run.ts @@ -34,7 +34,7 @@ import { agentSessionSpanAttributes, type ResolvedModel } from "../platform/Llm" import { withRunSessionAttributes } from "../platform/genai-spans" import { summarizeCause } from "@maple/backend/platform/describe-cause" import { type AgentDefinition, agentForSession, chatAgent } from "./agents" -import { profileForTurn } from "./profiles" +import { profileForRoles, profileForTurn } from "./profiles" import { MUTATING_TOOL_NAMES } from "./permissions" import { makeTextSanitizer, @@ -165,7 +165,7 @@ export const runChatTurn = (input: ChatRunInput) => const runChatTurnFrom = (input: ChatRunInput, startedAt: number) => { const agent = input.agent ?? agentForSession(input.sessionId) - const profile = profileForTurn(agent, input.origin) + const profile = profileForRoles(profileForTurn(agent, input.origin), input.tenant.roles) const sessionAttributes = agentSessionSpanAttributes(input.model.tags) const completion = input.completion?.({ turnId: input.messageId, diff --git a/apps/ai/src/mcp/server.ts b/apps/ai/src/mcp/server.ts index fadaaec288..c00bc1e4bd 100644 --- a/apps/ai/src/mcp/server.ts +++ b/apps/ai/src/mcp/server.ts @@ -7,7 +7,7 @@ import { McpToolsUnavailableError, } from "./executor-loader" import { CurrentMcpRequestTenant } from "./tenant" -import { refusesReadOnlyCredential, type McpToolResult } from "./tools/types" +import { isReadOnlyTenant, refusesReadOnlyCredential, type McpToolResult } from "./tools/types" import type { McpToolDescriptor } from "@maple/domain/mcp-tool-contract" /** @@ -69,13 +69,13 @@ const annotateToolFailure = (result: McpToolResult) => : Effect.void /** - * Hides a tool from a read-only credential's `tools/list`, and answers a call to it as unknown: + * Hides a tool from a read-only credential's or an observer's `tools/list`, and answers a call to it as unknown: * the registry would refuse every such call anyway, and a listed tool invites it. */ const HiddenFromReadOnlyCredentials = EffectMcpServer.visibility(() => Effect.gen(function* () { const tenant = yield* CurrentMcpRequestTenant - return tenant?.readOnly !== true + return tenant === undefined || !isReadOnlyTenant(tenant) }), ) diff --git a/apps/ai/src/mcp/tools/__tests__/read-only-key.test.ts b/apps/ai/src/mcp/tools/__tests__/read-only-key.test.ts index aa4a19780a..090ad00061 100644 --- a/apps/ai/src/mcp/tools/__tests__/read-only-key.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/read-only-key.test.ts @@ -11,21 +11,21 @@ import { executeRegisteredMcpToolUnscoped } from "../registry" const createdDbs: TestDb[] = [] afterEach(() => cleanupTestDbs(createdDbs)) -const tenant = (readOnly: boolean, scopes?: ReadonlyArray): TenantContext => ({ +const tenant = (readOnly: boolean, scopes?: ReadonlyArray, role = "root"): TenantContext => ({ orgId: Schema.decodeSync(OrgId)("org_read_only_key"), userId: Schema.decodeSync(UserId)("user_read_only_key"), - roles: [Schema.decodeSync(RoleName)("root")], + roles: [Schema.decodeSync(RoleName)(role)], authMode: "self_hosted", ...(readOnly ? { readOnly: true as const } : undefined), ...(scopes === undefined ? undefined : { scopes }), }) -const makeLayer = (readOnly: boolean, scopes?: ReadonlyArray) => { +const makeLayer = (readOnly: boolean, scopes?: ReadonlyArray, role?: string) => { const database = createTestDb(createdDbs).layer return Layer.mergeAll( ErrorPolicyService.layer.pipe(Layer.provide(database)), database, - Layer.succeed(CurrentMcpTenant, tenant(readOnly, scopes)), + Layer.succeed(CurrentMcpTenant, tenant(readOnly, scopes, role)), Layer.succeed(AlertDestinationsService, { listDestinations: () => Effect.succeed({ destinations: [] }), } as never), @@ -71,6 +71,30 @@ describe("read-only API key over MCP", () => { ) }) +describe("an observer over MCP", () => { + // A layer per test: each owns a database that `afterEach` removes. + const observer = () => makeLayer(false, undefined, "org:observer") + + it.effect("runs read tools", () => + Effect.gen(function* () { + const result = yield* call("get_error_notification_policy", {}) + expect(Result.isSuccess(result)).toBe(true) + }).pipe(Effect.provide(observer())), + ) + + it.effect("refuses a tool that changes data and says which role can", () => + Effect.gen(function* () { + const result = yield* call("update_error_notification_policy", updatePolicy) + expect(Result.isFailure(result)).toBe(true) + if (Result.isFailure(result)) { + expect(result.failure._tag).toBe("@maple/mcp/errors/McpAuthInvalidError") + expect(result.failure.message).toContain("update_error_notification_policy") + expect(result.failure.message).toContain("Ask an org admin for the Member role") + } + }).pipe(Effect.provide(observer())), + ) +}) + describe("a scoped key without users:read over MCP", () => { const scopes = Arr.map(INVESTIGATION_READ_FAMILIES, (family) => `${family}:write`) diff --git a/apps/ai/src/mcp/tools/registry.ts b/apps/ai/src/mcp/tools/registry.ts index e1ca7df54f..e6bfa1b90f 100644 --- a/apps/ai/src/mcp/tools/registry.ts +++ b/apps/ai/src/mcp/tools/registry.ts @@ -1,4 +1,5 @@ // BOUNDARY-FILE: This module owns unparsed external values and narrows them before domain use. +import * as Policy from "@maple/domain/policy" import { McpToolNotFoundError } from "@maple/domain/mcp-tool-contract" import { scopeAllows } from "@maple/domain/http/v2" import type { McpToolSurface } from "@maple/domain/mcp-manifest" @@ -133,6 +134,7 @@ import { type McpToolPhrases, type McpToolRegistrar, type McpToolResult, + isReadOnlyTenant, refusesReadOnlyCredential, } from "./types" import { unknownToolMessage } from "../lib/tool-suggestions" @@ -750,9 +752,11 @@ export const executeRegisteredMcpToolUnscoped = Effect.fn("McpToolRegistry.execu if (refusesReadOnlyCredential(definition.name, definition.hints.readOnly)) { const tenant = yield* CurrentMcpTenant - if (tenant.readOnly === true) { + if (isReadOnlyTenant(tenant)) { return yield* new McpAuthInvalidError({ - message: `\`${definition.name}\` changes data, and this API key is read-only. Use a key with write access, or connect with OAuth.`, + message: Policy.isReadOnly(tenant.roles) + ? `\`${definition.name}\` changes data. ${Policy.readOnlyMessage}` + : `\`${definition.name}\` changes data, and this API key is read-only. Use a key with write access, or connect with OAuth.`, reason: "insufficient_scope", }) } diff --git a/apps/ai/src/mcp/tools/types.ts b/apps/ai/src/mcp/tools/types.ts index be77cec142..384e0240f6 100644 --- a/apps/ai/src/mcp/tools/types.ts +++ b/apps/ai/src/mcp/tools/types.ts @@ -4,6 +4,7 @@ import type { McpToolSurface } from "@maple/domain/mcp-manifest" import type { McpToolName, McpToolOutputs } from "@maple/domain/mcp-outputs" import type { McpToolRequirements } from "./runtime-services" import type { ToolDoc } from "../lib/tool-doc" +import * as Policy from "@maple/domain/policy" export class McpAuthMissingError extends Schema.TaggedError()( "@maple/mcp/errors/McpAuthMissingError", @@ -123,6 +124,12 @@ export const FEEDBACK_TOOL = "send_maple_feedback" export const refusesReadOnlyCredential = (name: string, readOnly: boolean): boolean => !readOnly && name !== FEEDBACK_TOOL +/** Whether the caller may only read: a read-only API key, or anyone whose role is observer. */ +export const isReadOnlyTenant = (tenant: { + readonly readOnly?: true + readonly roles: ReadonlyArray +}): boolean => tenant.readOnly === true || Policy.isReadOnly(tenant.roles) + /** * Who a tool is for. * From 887db0340ca07003c6b1fd0c491690be7e46fd37 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 12:12:07 +0100 Subject: [PATCH 2/4] refactor: rename the observer role to analyst --- CLAUDE.md | 4 +-- apps/ai/src/chat/agents.test.ts | 6 ++-- apps/ai/src/chat/profiles.ts | 4 +-- apps/ai/src/mcp/server.ts | 2 +- .../mcp/tools/__tests__/read-only-key.test.ts | 8 ++--- apps/ai/src/mcp/tools/types.ts | 2 +- .../src/routes/v2/mobile-devices.http.test.ts | 4 +-- .../routes/v2/phase1-resources.http.test.ts | 6 ++-- apps/api/src/routes/v2/telemetry.http.test.ts | 4 +-- .../src/services/auth/ApiAuthorizationLive.ts | 6 ++-- .../services/auth/ApiAuthorizationV2Live.ts | 8 ++--- .../services/auth/SessionAuthorizationLive.ts | 6 ++-- ...rver-gate.test.ts => analyst-gate.test.ts} | 32 +++++++++---------- .../{observer-gate.ts => analyst-gate.ts} | 16 +++++----- packages/domain/src/http/ai-models.ts | 6 ++-- packages/domain/src/http/ai-sessions.ts | 6 ++-- ...-access.test.ts => analyst-access.test.ts} | 16 +++++----- .../{observer-access.ts => analyst-access.ts} | 16 +++++----- packages/domain/src/http/auth.ts | 14 ++++---- packages/domain/src/http/billing.ts | 4 +-- packages/domain/src/http/current-tenant.ts | 2 +- packages/domain/src/http/digest.ts | 10 +++--- packages/domain/src/http/errors.ts | 4 +-- packages/domain/src/http/index.ts | 2 +- packages/domain/src/http/integrations.ts | 6 ++-- packages/domain/src/http/mcp.ts | 6 ++-- packages/domain/src/http/query-engine.ts | 6 ++-- packages/domain/src/http/session-replay.ts | 6 ++-- packages/domain/src/http/users.ts | 6 ++-- packages/domain/src/http/v2/agent-feedback.ts | 4 +-- .../domain/src/http/v2/device-credentials.ts | 8 ++--- .../domain/src/http/v2/integrations-chat.ts | 4 +-- .../domain/src/http/v2/mcp-connections.ts | 4 +-- packages/domain/src/http/v2/mobile-devices.ts | 10 +++--- .../domain/src/http/v2/widget-credentials.ts | 6 ++-- packages/domain/src/policy/index.ts | 8 ++--- packages/domain/src/policy/policy.test.ts | 28 ++++++++-------- packages/domain/src/policy/roles.ts | 20 ++++++------ 38 files changed, 155 insertions(+), 155 deletions(-) rename packages/backend/src/services/auth/{observer-gate.test.ts => analyst-gate.test.ts} (52%) rename packages/backend/src/services/auth/{observer-gate.ts => analyst-gate.ts} (71%) rename packages/domain/src/http/{observer-access.test.ts => analyst-access.test.ts} (75%) rename packages/domain/src/http/{observer-access.ts => analyst-access.ts} (57%) diff --git a/CLAUDE.md b/CLAUDE.md index e91207cae8..42a0c57ba0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,8 +42,8 @@ with `packageManager`. - LLM code (Effect AI + `@yielded/agent`) is only in `apps/ai`; Maple-specific wiring lives in `apps/ai/src/platform/Llm.ts`. - Shared services live in `packages/backend`, imported via `@maple/backend/*` subpaths. -- Observers (`org:observer`) only read. The auth middlewares refuse them every non-GET endpoint unless - it is marked `ObserverAccess` (`packages/domain/src/http/observer-access.ts`): `read` for a POST that +- Analysts (`org:analyst`) only read. The auth middlewares refuse them every non-GET endpoint unless + it is marked `AnalystAccess` (`packages/domain/src/http/analyst-access.ts`): `read` for a POST that only queries, `personal` for the caller's own state. An unmarked read-only POST is a 403 for them. ### Service conventions diff --git a/apps/ai/src/chat/agents.test.ts b/apps/ai/src/chat/agents.test.ts index 6f206ceef8..0412a4587e 100644 --- a/apps/ai/src/chat/agents.test.ts +++ b/apps/ai/src/chat/agents.test.ts @@ -137,14 +137,14 @@ describe("profileForTurn", () => { } }) - it("offers an observer's turn the read tools and none that change data", () => { + it("offers an analyst's turn the read tools and none that change data", () => { const profile = profileForRoles(profileForTurn(agentForSession(appSession), { kind: "app" }), [ - "org:observer", + "org:analyst", ]) const { tools } = buildChatToolkit(executor, tenant, profile.ruleset, profile.surface).toolkit assert.property(tools, "search_traces") for (const name of MUTATING_TOOL_NAMES) { - assert.notProperty(tools, name, `${name} was offered to an observer`) + assert.notProperty(tools, name, `${name} was offered to an analyst`) } // A member's turn on the same session still proposes them. const member = profileForRoles(profileForTurn(agentForSession(appSession), { kind: "app" }), [ diff --git a/apps/ai/src/chat/profiles.ts b/apps/ai/src/chat/profiles.ts index bcdf976a03..f80614f1cd 100644 --- a/apps/ai/src/chat/profiles.ts +++ b/apps/ai/src/chat/profiles.ts @@ -23,13 +23,13 @@ export interface TurnProfile { readonly prompt: string } -/** The ruleset with every approval-gated tool withheld: an observer could not approve one. */ +/** The ruleset with every approval-gated tool withheld: an analyst could not approve one. */ const withoutProposals = (ruleset: PermissionRuleset): PermissionRuleset => Arr.map(ruleset, (rule) => rule.action === "ask" ? new PermissionRule({ tool: rule.tool, action: "deny" }) : rule, ) -/** The profile for whoever the turn's tools run as: an observer is offered nothing that changes data. */ +/** The profile for whoever the turn's tools run as: an analyst is offered nothing that changes data. */ export const profileForRoles = (profile: TurnProfile, roles: ReadonlyArray): TurnProfile => Policy.isReadOnly(roles) ? { ...profile, ruleset: withoutProposals(profile.ruleset) } : profile diff --git a/apps/ai/src/mcp/server.ts b/apps/ai/src/mcp/server.ts index c00bc1e4bd..264fcf9293 100644 --- a/apps/ai/src/mcp/server.ts +++ b/apps/ai/src/mcp/server.ts @@ -69,7 +69,7 @@ const annotateToolFailure = (result: McpToolResult) => : Effect.void /** - * Hides a tool from a read-only credential's or an observer's `tools/list`, and answers a call to it as unknown: + * Hides a tool from a read-only credential's or an analyst's `tools/list`, and answers a call to it as unknown: * the registry would refuse every such call anyway, and a listed tool invites it. */ const HiddenFromReadOnlyCredentials = EffectMcpServer.visibility(() => diff --git a/apps/ai/src/mcp/tools/__tests__/read-only-key.test.ts b/apps/ai/src/mcp/tools/__tests__/read-only-key.test.ts index 090ad00061..52c6d0e614 100644 --- a/apps/ai/src/mcp/tools/__tests__/read-only-key.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/read-only-key.test.ts @@ -71,15 +71,15 @@ describe("read-only API key over MCP", () => { ) }) -describe("an observer over MCP", () => { +describe("an analyst over MCP", () => { // A layer per test: each owns a database that `afterEach` removes. - const observer = () => makeLayer(false, undefined, "org:observer") + const analyst = () => makeLayer(false, undefined, "org:analyst") it.effect("runs read tools", () => Effect.gen(function* () { const result = yield* call("get_error_notification_policy", {}) expect(Result.isSuccess(result)).toBe(true) - }).pipe(Effect.provide(observer())), + }).pipe(Effect.provide(analyst())), ) it.effect("refuses a tool that changes data and says which role can", () => @@ -91,7 +91,7 @@ describe("an observer over MCP", () => { expect(result.failure.message).toContain("update_error_notification_policy") expect(result.failure.message).toContain("Ask an org admin for the Member role") } - }).pipe(Effect.provide(observer())), + }).pipe(Effect.provide(analyst())), ) }) diff --git a/apps/ai/src/mcp/tools/types.ts b/apps/ai/src/mcp/tools/types.ts index 384e0240f6..3b949cf34e 100644 --- a/apps/ai/src/mcp/tools/types.ts +++ b/apps/ai/src/mcp/tools/types.ts @@ -124,7 +124,7 @@ export const FEEDBACK_TOOL = "send_maple_feedback" export const refusesReadOnlyCredential = (name: string, readOnly: boolean): boolean => !readOnly && name !== FEEDBACK_TOOL -/** Whether the caller may only read: a read-only API key, or anyone whose role is observer. */ +/** Whether the caller may only read: a read-only API key, or anyone whose role is analyst. */ export const isReadOnlyTenant = (tenant: { readonly readOnly?: true readonly roles: ReadonlyArray diff --git a/apps/api/src/routes/v2/mobile-devices.http.test.ts b/apps/api/src/routes/v2/mobile-devices.http.test.ts index 61c527929c..08fc58a2b4 100644 --- a/apps/api/src/routes/v2/mobile-devices.http.test.ts +++ b/apps/api/src/routes/v2/mobile-devices.http.test.ts @@ -190,10 +190,10 @@ describe("v2 mobile devices", () => { } }) - it("lets an observer register and remove their own device", async () => { + it("lets an analyst register and remove their own device", async () => { const harness = makeHarness() try { - const key = await harness.bootstrapKey(undefined, ["org:observer"]) + const key = await harness.bootstrapKey(undefined, ["org:analyst"]) const created = await harness.request("PUT", `/v2/mobile_devices/${TOKEN}`, key.secret, { platform: "ios", environment: "sandbox", diff --git a/apps/api/src/routes/v2/phase1-resources.http.test.ts b/apps/api/src/routes/v2/phase1-resources.http.test.ts index b37296d26b..f863cf37f8 100644 --- a/apps/api/src/routes/v2/phase1-resources.http.test.ts +++ b/apps/api/src/routes/v2/phase1-resources.http.test.ts @@ -902,9 +902,9 @@ describe("v2 error_issues over HTTP", () => { await harness.dispose() }) - it("lets an observer read an issue and refuses to let them triage it", async () => { + it("lets an analyst read an issue and refuses to let them triage it", async () => { const harness = makeHarness() - const key = await harness.bootstrapKey(undefined, ["org:observer"]) + const key = await harness.bootstrapKey(undefined, ["org:analyst"]) const read = await harness.request("GET", `/v2/error_issues/${ISS_ID}`, { token: key.secret }) expect(read.status).toBe(200) @@ -915,7 +915,7 @@ describe("v2 error_issues over HTTP", () => { }) expect(refused.status).toBe(403) expect(refused.body.error.code).toBe("insufficient_permissions") - expect(refused.body.error.message).toContain("Observers can read Maple") + expect(refused.body.error.message).toContain("Analysts can read Maple") expect(harness.issueActions()).toEqual([]) await harness.dispose() }) diff --git a/apps/api/src/routes/v2/telemetry.http.test.ts b/apps/api/src/routes/v2/telemetry.http.test.ts index 205b616bda..92fdacb02b 100644 --- a/apps/api/src/routes/v2/telemetry.http.test.ts +++ b/apps/api/src/routes/v2/telemetry.http.test.ts @@ -571,9 +571,9 @@ describe("v2 telemetry reads over HTTP", () => { await harness.dispose() }) - it("lets an observer search traces, a POST that only reads", async () => { + it("lets an analyst search traces, a POST that only reads", async () => { const harness = makeHarness() - const key = await harness.bootstrapKey(undefined, ["org:observer"]) + const key = await harness.bootstrapKey(undefined, ["org:analyst"]) const searched = await harness.request("POST", "/v2/traces/search", key.secret, { start_time: START, end_time: END, diff --git a/packages/backend/src/services/auth/ApiAuthorizationLive.ts b/packages/backend/src/services/auth/ApiAuthorizationLive.ts index 95ba60f791..11c7b604a7 100644 --- a/packages/backend/src/services/auth/ApiAuthorizationLive.ts +++ b/packages/backend/src/services/auth/ApiAuthorizationLive.ts @@ -11,7 +11,7 @@ import { withAuditedRead } from "@maple/backend/services/audit/audit-access" import { Env } from "@maple/backend/platform/Env" import { OrganizationRegionService } from "@maple/backend/services/org/OrganizationRegionService" import { API_KEY_DEFAULT_ROLES, getBearerToken } from "./bearer-token" -import { refuseObserverWrites } from "./observer-gate" +import { refuseAnalystWrites } from "./analyst-gate" export const ApiAuthorizationLive = Layer.effect( CurrentTenant.Authorization, @@ -75,7 +75,7 @@ export const ApiAuthorizationLive = Layer.effect( roles: resolved.roles ?? API_KEY_DEFAULT_ROLES, authMode: "self_hosted", }) - yield* refuseObserverWrites(tenant.roles, options).pipe( + yield* refuseAnalystWrites(tenant.roles, options).pipe( Effect.tapError((error) => recordDenied(error.message)), ) return yield* httpEffect.pipe( @@ -103,7 +103,7 @@ export const ApiAuthorizationLive = Layer.effect( yield* annotateAuthSpan("session", { orgId: tenant.orgId, userId: tenant.userId }) // API keys are minted per instance; only a session can name an org from elsewhere. yield* regions.ensureServedHere(tenant.orgId) - yield* refuseObserverWrites(tenant.roles, options) + yield* refuseAnalystWrites(tenant.roles, options) return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, new CurrentTenant.TenantSchema(tenant)), Effect.provideService(CurrentAuditActor, { type: "user", source: "dashboard" }), diff --git a/packages/backend/src/services/auth/ApiAuthorizationV2Live.ts b/packages/backend/src/services/auth/ApiAuthorizationV2Live.ts index 6bd974d9d3..aa2c1e8b79 100644 --- a/packages/backend/src/services/auth/ApiAuthorizationV2Live.ts +++ b/packages/backend/src/services/auth/ApiAuthorizationV2Live.ts @@ -33,7 +33,7 @@ import { deviceRateLimitKey, } from "./ApiV2RateLimiter" import { API_KEY_DEFAULT_ROLES, getBearerToken } from "./bearer-token" -import { refuseObserverWritesV2 } from "./observer-gate" +import { refuseAnalystWritesV2 } from "./analyst-gate" /** The access token of an RFC 9449 `Authorization: DPoP ` request. */ const getDpopToken = (headers: Record): string | undefined => { @@ -181,7 +181,7 @@ export const ApiAuthorizationV2Live = Layer.effect( authMode: "self_hosted", scopes: resolved.scopes, }) - yield* refuseObserverWritesV2(tenant.roles, options, route.path) + yield* refuseAnalystWritesV2(tenant.roles, options, route.path) const label = "Device credential" return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, tenant), @@ -302,7 +302,7 @@ export const ApiAuthorizationV2Live = Layer.effect( authMode: "self_hosted", ...(resolved.scopes !== null ? { scopes: resolved.scopes } : undefined), }) - yield* refuseObserverWritesV2(tenant.roles, options, route.path).pipe( + yield* refuseAnalystWritesV2(tenant.roles, options, route.path).pipe( Effect.tapError((error) => recordDenied(error.message)), ) return yield* httpEffect.pipe( @@ -347,7 +347,7 @@ export const ApiAuthorizationV2Live = Layer.effect( .pipe(Effect.mapError((error) => V2OrganizationAccessDenied.make(error.message))) } const { route } = yield* HttpRouter.RouteContext - yield* refuseObserverWritesV2(tenant.roles, options, route.path) + yield* refuseAnalystWritesV2(tenant.roles, options, route.path) return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, new CurrentTenant.TenantSchema(tenant)), Effect.provideService(CurrentAuditActor, { type: "user", source: "dashboard" }), diff --git a/packages/backend/src/services/auth/SessionAuthorizationLive.ts b/packages/backend/src/services/auth/SessionAuthorizationLive.ts index 7760b8ab5a..3aa7548927 100644 --- a/packages/backend/src/services/auth/SessionAuthorizationLive.ts +++ b/packages/backend/src/services/auth/SessionAuthorizationLive.ts @@ -10,7 +10,7 @@ import { withAuditedRead } from "@maple/backend/services/audit/audit-access" import { Env } from "@maple/backend/platform/Env" import { OrganizationRegionService } from "@maple/backend/services/org/OrganizationRegionService" import { getBearerToken } from "./bearer-token" -import { refuseObserverWrites } from "./observer-gate" +import { refuseAnalystWrites } from "./analyst-gate" /** * Authorization for the internal API: Clerk sessions only. @@ -48,7 +48,7 @@ export const SessionAuthorizationLive = Layer.effect( const tenant = yield* resolveTenant(request.headers) yield* annotateAuthSpan("session", { orgId: tenant.orgId, userId: tenant.userId }) yield* regions.ensureServedHere(tenant.orgId) - yield* refuseObserverWrites(tenant.roles, options) + yield* refuseAnalystWrites(tenant.roles, options) const actor = { type: "user", source: "dashboard" } as const return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, new CurrentTenant.TenantSchema(tenant)), @@ -87,7 +87,7 @@ export const RegionlessSessionAuthorizationLive = Layer.effect( } const tenant = yield* resolveTenant(request.headers) yield* annotateAuthSpan("session", { orgId: tenant.orgId, userId: tenant.userId }) - yield* refuseObserverWrites(tenant.roles, options) + yield* refuseAnalystWrites(tenant.roles, options) const actor = { type: "user", source: "dashboard" } as const return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, new CurrentTenant.TenantSchema(tenant)), diff --git a/packages/backend/src/services/auth/observer-gate.test.ts b/packages/backend/src/services/auth/analyst-gate.test.ts similarity index 52% rename from packages/backend/src/services/auth/observer-gate.test.ts rename to packages/backend/src/services/auth/analyst-gate.test.ts index b59b4a992c..abc15270e9 100644 --- a/packages/backend/src/services/auth/observer-gate.test.ts +++ b/packages/backend/src/services/auth/analyst-gate.test.ts @@ -2,9 +2,9 @@ import { assert, describe, expect, it } from "@effect/vitest" import { CurrentTenant, MapleInternalApi } from "@maple/domain/http" import { MapleApiV2 } from "@maple/domain/http/v2" import { Effect, Exit } from "effect" -import { refuseObserverWrites, refuseObserverWritesV2 } from "./observer-gate" +import { refuseAnalystWrites, refuseAnalystWritesV2 } from "./analyst-gate" -const OBSERVER = ["org:observer"] +const ANALYST = ["org:analyst"] const MEMBER = ["org:member"] const errors = MapleInternalApi.groups.errors @@ -16,10 +16,10 @@ const dashboards = MapleApiV2.groups.dashboards /** Whether the gate let the call through. */ const allowed = (gate: Effect.Effect) => Effect.map(Effect.exit(gate), Exit.isSuccess) -describe("refuseObserverWrites", () => { - it.effect("refuses an observer an issue transition and says how to get it", () => +describe("refuseAnalystWrites", () => { + it.effect("refuses an analyst an issue transition and says how to get it", () => Effect.gen(function* () { - const error = yield* Effect.flip(refuseObserverWrites(OBSERVER, transition)) + const error = yield* Effect.flip(refuseAnalystWrites(ANALYST, transition)) assert.instanceOf(error, CurrentTenant.ReadOnlyRoleError) expect(error.message).toContain("Ask an org admin for the Member role") }), @@ -27,34 +27,34 @@ describe("refuseObserverWrites", () => { it.effect("lets a member, and Maple's own agents with no roles, make the same change", () => Effect.gen(function* () { - expect(yield* allowed(refuseObserverWrites(MEMBER, transition))).toBe(true) - expect(yield* allowed(refuseObserverWrites([], transition))).toBe(true) + expect(yield* allowed(refuseAnalystWrites(MEMBER, transition))).toBe(true) + expect(yield* allowed(refuseAnalystWrites([], transition))).toBe(true) }), ) - it.effect("lets an observer run a dashboard query", () => + it.effect("lets an analyst run a dashboard query", () => Effect.gen(function* () { const query = { group: queries, endpoint: queries.endpoints.executeBatch } - expect(yield* allowed(refuseObserverWrites(OBSERVER, query))).toBe(true) + expect(yield* allowed(refuseAnalystWrites(ANALYST, query))).toBe(true) }), ) }) -describe("refuseObserverWritesV2", () => { +describe("refuseAnalystWritesV2", () => { const create = { group: dashboards, endpoint: dashboards.endpoints.create } - it.effect("lets an observer search traces, which the scope registry names as a read", () => + it.effect("lets an analyst search traces, which the scope registry names as a read", () => Effect.gen(function* () { const search = { group: traces, endpoint: traces.endpoints.search } - expect(yield* allowed(refuseObserverWritesV2(OBSERVER, search, "/v2/traces/search"))).toBe(true) + expect(yield* allowed(refuseAnalystWritesV2(ANALYST, search, "/v2/traces/search"))).toBe(true) }), ) - it.effect("refuses an observer a new dashboard and lets a member create one", () => + it.effect("refuses an analyst a new dashboard and lets a member create one", () => Effect.gen(function* () { - const error = yield* Effect.flip(refuseObserverWritesV2(OBSERVER, create, "/v2/dashboards")) - expect(error.message).toContain("Observers can read Maple") - expect(yield* allowed(refuseObserverWritesV2(MEMBER, create, "/v2/dashboards"))).toBe(true) + const error = yield* Effect.flip(refuseAnalystWritesV2(ANALYST, create, "/v2/dashboards")) + expect(error.message).toContain("Analysts can read Maple") + expect(yield* allowed(refuseAnalystWritesV2(MEMBER, create, "/v2/dashboards"))).toBe(true) }), ) }) diff --git a/packages/backend/src/services/auth/observer-gate.ts b/packages/backend/src/services/auth/analyst-gate.ts similarity index 71% rename from packages/backend/src/services/auth/observer-gate.ts rename to packages/backend/src/services/auth/analyst-gate.ts index d3bc7b0025..a3afc86ef6 100644 --- a/packages/backend/src/services/auth/observer-gate.ts +++ b/packages/backend/src/services/auth/analyst-gate.ts @@ -1,34 +1,34 @@ -import { CurrentTenant, isOpenToObservers } from "@maple/domain/http" +import { CurrentTenant, isOpenToAnalysts } from "@maple/domain/http" import { requiredScopeForRoute, V2InsufficientPermissions } from "@maple/domain/http/v2" import * as Policy from "@maple/domain/policy" import { Effect } from "effect" -type Called = Parameters[0] +type Called = Parameters[0] /** * Refuses a read-only role every endpoint that changes the organization. It runs in the auth * layers, so an endpoint added later is refused until it, or its whole group, is marked - * `ObserverAccess`. + * `AnalystAccess`. */ -export const refuseObserverWrites = ( +export const refuseAnalystWrites = ( roles: ReadonlyArray, called: Called, ): Effect.Effect => - Policy.isReadOnly(roles) && !isOpenToObservers(called) + Policy.isReadOnly(roles) && !isOpenToAnalysts(called) ? Effect.fail(new CurrentTenant.ReadOnlyRoleError({ message: Policy.readOnlyMessage })) : Effect.void /** - * {@link refuseObserverWrites} for `/v2`, where the scope registry already names the POSTs that + * {@link refuseAnalystWrites} for `/v2`, where the scope registry already names the POSTs that * only read. `routePath` is the matched route template, as that registry expects. */ -export const refuseObserverWritesV2 = ( +export const refuseAnalystWritesV2 = ( roles: ReadonlyArray, called: Called, routePath: string, ): Effect.Effect> => Policy.isReadOnly(roles) && - !isOpenToObservers(called) && + !isOpenToAnalysts(called) && requiredScopeForRoute(called.endpoint.method, routePath)?.access !== "read" ? Effect.fail(V2InsufficientPermissions.make(Policy.readOnlyMessage)) : Effect.void diff --git a/packages/domain/src/http/ai-models.ts b/packages/domain/src/http/ai-models.ts index d15ad4d15e..555f03a866 100644 --- a/packages/domain/src/http/ai-models.ts +++ b/packages/domain/src/http/ai-models.ts @@ -1,7 +1,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/http-api" import { Schema } from "effect" import { SessionAuthorization } from "./current-tenant" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" // AI model detection: a model string as an instrumentation reported it // (`gen_ai.request.model`, an OpenRouter id, a Bedrock id) → the model's @@ -75,5 +75,5 @@ export class AiModelsInternalApiGroup extends HttpApiGroup.make("aiModelsInterna ) .prefix("/internal/ai-models") .middleware(SessionAuthorization) - // Every endpoint here only reads, so an observer may call it. - .annotate(ObserverAccess, "read") {} + // Every endpoint here only reads, so an analyst may call it. + .annotate(AnalystAccess, "read") {} diff --git a/packages/domain/src/http/ai-sessions.ts b/packages/domain/src/http/ai-sessions.ts index c20c2ed8da..92357cff53 100644 --- a/packages/domain/src/http/ai-sessions.ts +++ b/packages/domain/src/http/ai-sessions.ts @@ -6,7 +6,7 @@ import { BucketSeconds } from "./query-engine" import { SessionAuthorization } from "./current-tenant" import { HttpTaggedError } from "./error-policy" import { warehouseReadHttpErrors } from "./warehouse" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" // Backed by the `maple_ai.*` span attributes the ingest gateway stamps at // decode time; a session is resolved at trace granularity by @@ -1101,5 +1101,5 @@ export class AiSessionsInternalApiGroup extends HttpApiGroup.make("aiSessionsInt ) .prefix("/internal/ai-sessions") .middleware(SessionAuthorization) - // Every endpoint here only reads, so an observer may call it. - .annotate(ObserverAccess, "read") {} + // Every endpoint here only reads, so an analyst may call it. + .annotate(AnalystAccess, "read") {} diff --git a/packages/domain/src/http/observer-access.test.ts b/packages/domain/src/http/analyst-access.test.ts similarity index 75% rename from packages/domain/src/http/observer-access.test.ts rename to packages/domain/src/http/analyst-access.test.ts index fc4a9907c2..cb31658008 100644 --- a/packages/domain/src/http/observer-access.test.ts +++ b/packages/domain/src/http/analyst-access.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it } from "@effect/vitest" import { Array as Arr } from "effect" import { MapleApi } from "./api" import { MapleInternalApi } from "./internal-api" -import { isOpenToObservers } from "./observer-access" +import { isOpenToAnalysts } from "./analyst-access" import { MapleApiV2 } from "./v2/api" -type Called = Parameters[0] +type Called = Parameters[0] type Group = Called["group"] & { readonly endpoints: { readonly [name: string]: Called["endpoint"] & { readonly identifier: string } } } @@ -16,18 +16,18 @@ const endpointsOf = (group: Group) => Object.values(group.endpoints) const open = (group: Group, name: string): boolean => Arr.some( endpointsOf(group), - (endpoint) => endpoint.identifier === name && isOpenToObservers({ group, endpoint }), + (endpoint) => endpoint.identifier === name && isOpenToAnalysts({ group, endpoint }), ) const internal = MapleInternalApi.groups const v2 = MapleApiV2.groups -describe("isOpenToObservers", () => { - it("lets an observer run every dashboard query, which are all POSTs", () => { +describe("isOpenToAnalysts", () => { + it("lets an analyst run every dashboard query, which are all POSTs", () => { const queries = endpointsOf(internal.queryEngine) expect(queries.length).toBeGreaterThan(0) expect( - Arr.every(queries, (endpoint) => isOpenToObservers({ group: internal.queryEngine, endpoint })), + Arr.every(queries, (endpoint) => isOpenToAnalysts({ group: internal.queryEngine, endpoint })), ).toBe(true) }) @@ -41,7 +41,7 @@ describe("isOpenToObservers", () => { it("keeps every GET of a group that also changes things", () => { const reads = Arr.filter(endpointsOf(internal.errors), (endpoint) => endpoint.method === "GET") expect(reads.length).toBeGreaterThan(0) - expect(Arr.every(reads, (endpoint) => isOpenToObservers({ group: internal.errors, endpoint }))).toBe( + expect(Arr.every(reads, (endpoint) => isOpenToAnalysts({ group: internal.errors, endpoint }))).toBe( true, ) }) @@ -51,7 +51,7 @@ describe("isOpenToObservers", () => { expect(open(internal.digest, "preview")).toBe(true) }) - it("lets an observer manage their own digest, devices and connections", () => { + it("lets an analyst manage their own digest, devices and connections", () => { expect(open(internal.digest, "upsertSubscription")).toBe(true) expect(open(internal.digest, "deleteSubscription")).toBe(true) expect(open(v2.mobileDevices, "register")).toBe(true) diff --git a/packages/domain/src/http/observer-access.ts b/packages/domain/src/http/analyst-access.ts similarity index 57% rename from packages/domain/src/http/observer-access.ts rename to packages/domain/src/http/analyst-access.ts index ebcf0bde89..8b7a4159bd 100644 --- a/packages/domain/src/http/observer-access.ts +++ b/packages/domain/src/http/analyst-access.ts @@ -1,12 +1,12 @@ import { Context } from "effect" /** - * Why an observer, who only reads, may still call an endpoint that is not a GET: `read` only looks + * Why an analyst, who only reads, may still call an endpoint that is not a GET: `read` only looks * (a query with a body), `personal` changes the caller's own state. Unset means the endpoint changes - * the organization, and the auth middlewares refuse it to an observer. + * the organization, and the auth middlewares refuse it to an analyst. */ -export class ObserverAccess extends Context.Reference<"read" | "personal" | undefined>( - "@maple/http/ObserverAccess", +export class AnalystAccess extends Context.Reference<"read" | "personal" | undefined>( + "@maple/http/AnalystAccess", { defaultValue: () => undefined }, ) {} @@ -14,8 +14,8 @@ interface Annotated { readonly annotations: Context.Context } -/** Whether a read-only role may call the endpoint: a GET, or one marked {@link ObserverAccess}. */ -export const isOpenToObservers = (options: { +/** Whether a read-only role may call the endpoint: a GET, or one marked {@link AnalystAccess}. */ +export const isOpenToAnalysts = (options: { readonly endpoint: Annotated & { readonly method: string } readonly group: Annotated }): boolean => { @@ -23,7 +23,7 @@ export const isOpenToObservers = (options: { if (method === "GET" || method === "HEAD") return true // A group-level `.annotate` lands on the group only, so both are read; the endpoint wins. const access = - Context.get(options.endpoint.annotations, ObserverAccess) ?? - Context.get(options.group.annotations, ObserverAccess) + Context.get(options.endpoint.annotations, AnalystAccess) ?? + Context.get(options.group.annotations, AnalystAccess) return access !== undefined } diff --git a/packages/domain/src/http/auth.ts b/packages/domain/src/http/auth.ts index 72e2981dee..f2c40962ce 100644 --- a/packages/domain/src/http/auth.ts +++ b/packages/domain/src/http/auth.ts @@ -2,7 +2,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/http-api" import { Schema } from "effect" import { OrgId, UserId } from "@maple/primitives" import { Authorization, SessionAuthorization, TenantSchema, UnauthorizedError } from "./current-tenant" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" export class CliDeviceStartRequest extends Schema.Class("CliDeviceStartRequest")({ deviceName: Schema.String, @@ -265,13 +265,13 @@ export class AuthApiGroup extends HttpApiGroup.make("auth") HttpApiEndpoint.post("sessionRefresh", "/session/refresh", { success: SelfHostedLoginResponse, error: [SelfHostedAuthDisabledError, UnauthorizedError], - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .add( HttpApiEndpoint.delete("cliSessionRevoke", "/cli/session", { success: CliDeviceActionResponse, error: [CliDeviceConflictError, CliDevicePersistenceError], - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .prefix("/api/auth") .middleware(Authorization) {} @@ -298,7 +298,7 @@ export class AuthSessionApiGroup extends HttpApiGroup.make("authSession") CliDeviceConflictError, CliDevicePersistenceError, ], - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .add( HttpApiEndpoint.post("cliDeviceDeny", "/cli/device/:userCode/deny", { @@ -310,7 +310,7 @@ export class AuthSessionApiGroup extends HttpApiGroup.make("authSession") CliDeviceConflictError, CliDevicePersistenceError, ], - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .add( HttpApiEndpoint.get("mcpOAuthAuthorizationInspect", "/mcp/oauth/authorization/:requestId", { @@ -333,7 +333,7 @@ export class AuthSessionApiGroup extends HttpApiGroup.make("authSession") McpOAuthAuthorizationConflictError, McpOAuthPersistenceError, ], - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .add( HttpApiEndpoint.post("mcpOAuthAuthorizationDeny", "/mcp/oauth/authorization/:requestId/deny", { @@ -345,7 +345,7 @@ export class AuthSessionApiGroup extends HttpApiGroup.make("authSession") McpOAuthAuthorizationConflictError, McpOAuthPersistenceError, ], - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .prefix("/api/auth") .middleware(SessionAuthorization) {} diff --git a/packages/domain/src/http/billing.ts b/packages/domain/src/http/billing.ts index 23b430de22..ae110490ae 100644 --- a/packages/domain/src/http/billing.ts +++ b/packages/domain/src/http/billing.ts @@ -15,7 +15,7 @@ import { LinkBillingOrgRequest, } from "./billing-account" import { WarehouseQueryError } from "./warehouse-errors" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" // Contract for raw Autumn proxy responses. Schemas model only consumed fields // and tolerate additive upstream fields. @@ -726,7 +726,7 @@ export class BillingApiGroup extends HttpApiGroup.make("billing") payload: PreviewAttachRequest, success: PreviewAttachResult, error: [BillingManagedByBillingOrgError, ...billingRequestErrors], - }).annotate(ObserverAccess, "read"), + }).annotate(AnalystAccess, "read"), ) .add( HttpApiEndpoint.post("openCustomerPortal", "/portal", { diff --git a/packages/domain/src/http/current-tenant.ts b/packages/domain/src/http/current-tenant.ts index 1c4706cb48..c76c0bdfb3 100644 --- a/packages/domain/src/http/current-tenant.ts +++ b/packages/domain/src/http/current-tenant.ts @@ -91,7 +91,7 @@ export class OrganizationWrongRegionError extends HttpTaggedError()( "@maple/http/errors/ReadOnlyRoleError", { message: Schema.String }, diff --git a/packages/domain/src/http/digest.ts b/packages/domain/src/http/digest.ts index ac8ec68c6e..c0e38e6bc2 100644 --- a/packages/domain/src/http/digest.ts +++ b/packages/domain/src/http/digest.ts @@ -1,7 +1,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/http-api" import { DateTime, Option, Schema } from "effect" import { SessionAuthorization } from "./current-tenant" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" export const DigestSubscriptionId = Schema.String.check(Schema.isUUID()).pipe( Schema.brand("@maple/DigestSubscriptionId"), @@ -164,25 +164,25 @@ export class DigestApiGroup extends HttpApiGroup.make("digest") payload: UpsertDigestSubscriptionRequest, success: DigestSubscriptionResponse, error: [DigestPersistenceError, DigestNotFoundError, DigestEmailUnavailableError], - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .add( HttpApiEndpoint.delete("deleteSubscription", "/", { success: Schema.Void, error: DigestPersistenceError, - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .add( HttpApiEndpoint.post("preview", "/preview", { success: DigestPreviewResponse, error: [DigestPersistenceError, DigestNotConfiguredError, DigestRenderError], - }).annotate(ObserverAccess, "read"), + }).annotate(AnalystAccess, "read"), ) .add( HttpApiEndpoint.post("previewWebAnalytics", "/web-analytics/preview", { success: DigestPreviewResponse, error: [DigestPersistenceError, DigestNotConfiguredError, DigestRenderError], - }).annotate(ObserverAccess, "read"), + }).annotate(AnalystAccess, "read"), ) .prefix("/internal/digest") .middleware(SessionAuthorization) {} diff --git a/packages/domain/src/http/errors.ts b/packages/domain/src/http/errors.ts index 89750ffcba..b46fae0a47 100644 --- a/packages/domain/src/http/errors.ts +++ b/packages/domain/src/http/errors.ts @@ -43,7 +43,7 @@ import { IssueSeveritySource, WorkflowState, } from "../error-issues" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" export { ActorType, ErrorIncidentReason, @@ -920,7 +920,7 @@ export class ErrorsApiGroup extends HttpApiGroup.make("errors") payload: EscalationPolicyEvaluationRequest, success: EscalationPolicyEvaluationDocument, error: ErrorPersistenceError, - }).annotate(ObserverAccess, "read"), + }).annotate(AnalystAccess, "read"), ) .add( HttpApiEndpoint.get("listIssueEscalations", "/issues/:issueId/escalations", { diff --git a/packages/domain/src/http/index.ts b/packages/domain/src/http/index.ts index e079d9a9d5..491c80a7a0 100644 --- a/packages/domain/src/http/index.ts +++ b/packages/domain/src/http/index.ts @@ -21,7 +21,7 @@ export * from "./anomalies" export * from "./api-keys" export * from "./device-credentials" export * from "./audit-log" -export * from "./observer-access" +export * from "./analyst-access" export * from "./alerts" export * from "./escalation-chains" export * from "./oncall-schedules" diff --git a/packages/domain/src/http/integrations.ts b/packages/domain/src/http/integrations.ts index 9902e298a2..a0f0d24bd9 100644 --- a/packages/domain/src/http/integrations.ts +++ b/packages/domain/src/http/integrations.ts @@ -35,7 +35,7 @@ import { GcpCloudSqlSettings } from "../gcp" export { GcpCloudSqlSettings } from "../gcp" import { SupabaseAdvisorLint, SupabaseLogDrainSummary, SupabaseTopQuery } from "../supabase" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" export { SupabaseAdvisorKind, SupabaseAdvisorLevel, @@ -1736,7 +1736,7 @@ export class IntegrationsApiGroup extends HttpApiGroup.make("integrations") HttpApiEndpoint.post("infraSurfaces", "/infra-surfaces", { payload: InfraPresenceRequest, success: InfraSurfacesResponse, - }).annotate(ObserverAccess, "read"), + }).annotate(AnalystAccess, "read"), ) .add( HttpApiEndpoint.get("cloudflareStatus", "/cloudflare/status", { @@ -1761,7 +1761,7 @@ export class IntegrationsApiGroup extends HttpApiGroup.make("integrations") IntegrationsUpstreamError, IntegrationsPersistenceError, ], - }).annotate(ObserverAccess, "read"), + }).annotate(AnalystAccess, "read"), ) .add( HttpApiEndpoint.post("cloudflareStart", "/cloudflare/start", { diff --git a/packages/domain/src/http/mcp.ts b/packages/domain/src/http/mcp.ts index 3975ecb914..a278f9c6ef 100644 --- a/packages/domain/src/http/mcp.ts +++ b/packages/domain/src/http/mcp.ts @@ -5,7 +5,7 @@ import { TinybirdDateTime } from "../query-engine" import { BucketSeconds } from "./query-engine" import { SessionAuthorization } from "./current-tenant" import { warehouseReadHttpErrors } from "./warehouse" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" // MCP hub endpoint schemas (`/mcp-servers`, a server's overview, its tool page and its sessions). // @@ -341,5 +341,5 @@ export class McpInternalApiGroup extends HttpApiGroup.make("mcpInternal") ) .prefix("/internal/mcp-servers") .middleware(SessionAuthorization) - // Every endpoint here only reads, so an observer may call it. - .annotate(ObserverAccess, "read") {} + // Every endpoint here only reads, so an analyst may call it. + .annotate(AnalystAccess, "read") {} diff --git a/packages/domain/src/http/query-engine.ts b/packages/domain/src/http/query-engine.ts index d60d43dc93..afbd27dc42 100644 --- a/packages/domain/src/http/query-engine.ts +++ b/packages/domain/src/http/query-engine.ts @@ -44,7 +44,7 @@ import { PathsInclude, } from "@maple/query-model" import { WireRow } from "../wire" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" /** * A timeseries bucket width. @@ -5069,7 +5069,7 @@ export class QueryEngineApiGroup extends HttpApiGroup.make("queryEngine") ) .prefix("/internal/query-engine") .middleware(SessionAuthorization) - // Every endpoint here only reads, so an observer may call it. - .annotate(ObserverAccess, "read") + // Every endpoint here only reads, so an analyst may call it. + .annotate(AnalystAccess, "read") // Every endpoint here reads telemetry for the dashboard. .annotate(AuditedRead, "telemetry.read") {} diff --git a/packages/domain/src/http/session-replay.ts b/packages/domain/src/http/session-replay.ts index bebe700627..623f91f91a 100644 --- a/packages/domain/src/http/session-replay.ts +++ b/packages/domain/src/http/session-replay.ts @@ -6,7 +6,7 @@ import { AuditedRead } from "./audit-log" import { SessionAuthorization } from "./current-tenant" import { QueryEngineExecutionError, QueryEngineTimeoutError } from "./query-engine" import { warehouseHttpErrors } from "./warehouse" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" // Session replay schemas shared by the v2 contract and the internal replay group. @@ -170,6 +170,6 @@ export class SessionReplaysInternalApiGroup extends HttpApiGroup.make("sessionRe ) .prefix("/internal/session-replays") .middleware(SessionAuthorization) - // Every endpoint here only reads, so an observer may call it. - .annotate(ObserverAccess, "read") + // Every endpoint here only reads, so an analyst may call it. + .annotate(AnalystAccess, "read") .annotate(AuditedRead, "session_replay.read") {} diff --git a/packages/domain/src/http/users.ts b/packages/domain/src/http/users.ts index 1deb76bd37..05b76487b8 100644 --- a/packages/domain/src/http/users.ts +++ b/packages/domain/src/http/users.ts @@ -7,7 +7,7 @@ import { TinybirdDateTime } from "../query-engine" import { AuditedRead } from "./audit-log" import { SessionAuthorization } from "./current-tenant" import { warehouseReadHttpErrors } from "./warehouse" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" // End users endpoint schemas (`/users`): the people Maple's customers identified with `identify()` // or a `user.id` span attribute, read by `ch/queries/users.ts` and `user-activity.ts`. Times are @@ -459,6 +459,6 @@ export class UsersInternalApiGroup extends HttpApiGroup.make("usersInternal") ) .prefix("/internal/users") .middleware(SessionAuthorization) - // Every endpoint here only reads, so an observer may call it. - .annotate(ObserverAccess, "read") + // Every endpoint here only reads, so an analyst may call it. + .annotate(AnalystAccess, "read") .annotate(AuditedRead, "telemetry.read") {} diff --git a/packages/domain/src/http/v2/agent-feedback.ts b/packages/domain/src/http/v2/agent-feedback.ts index 78ce306610..a894ae206c 100644 --- a/packages/domain/src/http/v2/agent-feedback.ts +++ b/packages/domain/src/http/v2/agent-feedback.ts @@ -12,7 +12,7 @@ import { ListOf, ListQuery, Timestamp, wireExample } from "./envelopes" import { V2ParameterInvalid } from "./errors" import { publicError } from "./public-error" import { AgentFeedbackPublicId } from "./resource-ids" -import { ObserverAccess } from "../observer-access" +import { AnalystAccess } from "../analyst-access" const isNotBlank = Schema.makeFilter((value: string) => Str.trim(value).length > 0, { description: "Must contain a non-whitespace character", @@ -136,7 +136,7 @@ export class V2AgentFeedbackApiGroup extends HttpApiGroup.make("agentFeedback") success: V2AgentFeedback, error: [persistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "createAgentFeedback", diff --git a/packages/domain/src/http/v2/device-credentials.ts b/packages/domain/src/http/v2/device-credentials.ts index 9d50979fc4..a9000a67ed 100644 --- a/packages/domain/src/http/v2/device-credentials.ts +++ b/packages/domain/src/http/v2/device-credentials.ts @@ -18,7 +18,7 @@ import { V2RateLimited } from "./errors" import { publicError } from "./public-error" import { DeviceCredentialPublicId } from "./resource-ids" import { InstallationId } from "./widget-credentials" -import { ObserverAccess } from "../observer-access" +import { AnalystAccess } from "../analyst-access" /** * Device-bound credentials (DPoP, RFC 9449) for the mobile Home Screen widgets. @@ -190,7 +190,7 @@ export class V2DeviceCredentialsApiGroup extends HttpApiGroup.make("deviceCreden persistence, ], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "registerDeviceCredential", @@ -206,7 +206,7 @@ export class V2DeviceCredentialsApiGroup extends HttpApiGroup.make("deviceCreden success: V2DeviceCredentialDeleteResponse, error: [sessionRequired, persistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "revokeDeviceCredential", @@ -253,7 +253,7 @@ export class V2DeviceTokensApiGroup extends HttpApiGroup.make("deviceTokens") publicError(DeviceCredentialPersistenceError), ], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "issueDeviceToken", diff --git a/packages/domain/src/http/v2/integrations-chat.ts b/packages/domain/src/http/v2/integrations-chat.ts index 3307d82d2c..704970eef5 100644 --- a/packages/domain/src/http/v2/integrations-chat.ts +++ b/packages/domain/src/http/v2/integrations-chat.ts @@ -14,7 +14,7 @@ import { wireExample, Timestamp } from "./envelopes" import { V2CallbackHostUnavailable, V2InsufficientPermissions } from "./errors" import { publicErrors } from "./public-error" import { PublicId, PublicIdPrefixes } from "./public-id" -import { ObserverAccess } from "../observer-access" +import { AnalystAccess } from "../analyst-access" // The chat-platform surface: one contract for every chat platform Maple can // connect to. The platform is a value (`connector`), never a path segment or a @@ -391,7 +391,7 @@ export class V2ChatIntegrationsApiGroup extends HttpApiGroup.make("chatIntegrati success: V2ChatIdentityDeleteResponse, error: [V2InsufficientPermissions.schema, chatNotFound, chatPersistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "deleteChatIdentity", diff --git a/packages/domain/src/http/v2/mcp-connections.ts b/packages/domain/src/http/v2/mcp-connections.ts index 6da5af4814..2b2a67af52 100644 --- a/packages/domain/src/http/v2/mcp-connections.ts +++ b/packages/domain/src/http/v2/mcp-connections.ts @@ -7,7 +7,7 @@ import { wireExample, ListOf, ListQuery, Timestamp } from "./envelopes" import { V2ParameterInvalid } from "./errors" import { publicError } from "./public-error" import { McpConnectionPublicId } from "./resource-ids" -import { ObserverAccess } from "../observer-access" +import { AnalystAccess } from "../analyst-access" const mcpConnectionExample = { id: "mcpc_p12S8T8agEHUkYc825nMz6", @@ -82,7 +82,7 @@ export class V2McpConnectionsApiGroup extends HttpApiGroup.make("mcpConnections" success: V2McpConnection, error: [notFound, persistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "revokeMcpConnection", diff --git a/packages/domain/src/http/v2/mobile-devices.ts b/packages/domain/src/http/v2/mobile-devices.ts index 46fa78a8f7..ba55eb443f 100644 --- a/packages/domain/src/http/v2/mobile-devices.ts +++ b/packages/domain/src/http/v2/mobile-devices.ts @@ -11,7 +11,7 @@ import { AuthorizationV2 } from "./auth" import { wireExample, ListOf, Timestamp } from "./envelopes" import { publicError } from "./public-error" import { AlertIncidentPublicId, MobileDevicePublicId } from "./resource-ids" -import { ObserverAccess } from "../observer-access" +import { AnalystAccess } from "../analyst-access" // Code points, not UTF-16 units: identical for these ASCII values, and the only length the // JSON Schema `minLength` can state exactly (Effect halves a UTF-16 bound when it exports one). @@ -196,7 +196,7 @@ export class V2MobileDevicesApiGroup extends HttpApiGroup.make("mobileDevices") success: V2MobileDevice, error: [persistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "registerMobileDevice", @@ -212,7 +212,7 @@ export class V2MobileDevicesApiGroup extends HttpApiGroup.make("mobileDevices") success: V2MobileDeviceDeleteResponse, error: [notFound, persistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "unregisterMobileDevice", @@ -229,7 +229,7 @@ export class V2MobileDevicesApiGroup extends HttpApiGroup.make("mobileDevices") success: V2LiveActivity, error: [notFound, persistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "registerLiveActivity", @@ -245,7 +245,7 @@ export class V2MobileDevicesApiGroup extends HttpApiGroup.make("mobileDevices") success: V2LiveActivityDeleteResponse, error: [notFound, persistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "endLiveActivity", diff --git a/packages/domain/src/http/v2/widget-credentials.ts b/packages/domain/src/http/v2/widget-credentials.ts index dd1a91d729..cd48e0a652 100644 --- a/packages/domain/src/http/v2/widget-credentials.ts +++ b/packages/domain/src/http/v2/widget-credentials.ts @@ -5,7 +5,7 @@ import { OrgId } from "@maple/primitives" import { AuthorizationV2 } from "./auth" import { wireExample, Timestamp } from "./envelopes" import { publicError } from "./public-error" -import { ObserverAccess } from "../observer-access" +import { AnalystAccess } from "../analyst-access" /** * The credential a device's Home Screen widgets fetch with. @@ -100,7 +100,7 @@ export class V2WidgetCredentialsApiGroup extends HttpApiGroup.make("widgetCreden success: V2WidgetCredential, error: [publicError(ApiKeyPersistenceError)], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "mintWidgetCredential", @@ -116,7 +116,7 @@ export class V2WidgetCredentialsApiGroup extends HttpApiGroup.make("widgetCreden success: V2WidgetCredentialDeleteResponse, error: [publicError(ApiKeyPersistenceError)], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "revokeWidgetCredential", diff --git a/packages/domain/src/policy/index.ts b/packages/domain/src/policy/index.ts index d31d176803..d80afc066f 100644 --- a/packages/domain/src/policy/index.ts +++ b/packages/domain/src/policy/index.ts @@ -51,12 +51,12 @@ export const check = ( ): Effect.Effect => can(roles, permission) ? Effect.void : Effect.fail(onDenied(denialFor(permission))) -/** Whether the roles may only read: an observer, with no higher role beside it. */ -export const isReadOnly = (roles: ReadonlyArray): boolean => highestRole(roles) === "observer" +/** Whether the roles may only read: an analyst, with no higher role beside it. */ +export const isReadOnly = (roles: ReadonlyArray): boolean => highestRole(roles) === "analyst" -/** What an observer is told when a change is refused, on every surface. */ +/** What an analyst is told when a change is refused, on every surface. */ export const readOnlyMessage = - "Observers can read Maple but not change it. Ask an org admin for the Member role to make changes." + "Analysts can read Maple but not change it. Ask an org admin for the Member role to make changes." /** Every permission the roles hold, for a page model to keep instead of role flags. */ export const grantsOf = (roles: ReadonlyArray): ReadonlyArray => diff --git a/packages/domain/src/policy/policy.test.ts b/packages/domain/src/policy/policy.test.ts index ae4febfa64..ca5294c9f7 100644 --- a/packages/domain/src/policy/policy.test.ts +++ b/packages/domain/src/policy/policy.test.ts @@ -32,8 +32,8 @@ describe("Policy.can", () => { } }) - it("holds nothing a member has for an observer or a Clerk role it does not know", () => { - for (const roles of [["org:observer"], ["org:billing"]]) { + it("holds nothing a member has for an analyst or a Clerk role it does not know", () => { + for (const roles of [["org:analyst"], ["org:billing"]]) { expect(Policy.can(roles, "ingest_keys:view_public")).toBe(false) expect(Policy.grantsOf(roles)).toEqual([]) } @@ -67,11 +67,11 @@ describe("Policy.check", () => { }), ) - it.effect("tells an observer which roles hold a member permission", () => + it.effect("tells an analyst which roles hold a member permission", () => Effect.gen(function* () { const error = yield* Effect.flip( Policy.check( - ["org:observer"], + ["org:analyst"], "ingest_keys:view_public", (denial) => new Forbidden({ message: denial.message }), ), @@ -119,26 +119,26 @@ describe("roleOf", () => { ]) }) - it("reads the observer key as observer, below every assignable role", () => { - expect(Policy.roleOf("org:observer")).toBe("observer") + it("reads the analyst key as analyst, below every assignable role", () => { + expect(Policy.roleOf("org:analyst")).toBe("analyst") for (const entry of Policy.assignableRoles) { - expect(Policy.outranks(entry.role, "observer")).toBe(true) + expect(Policy.outranks(entry.role, "analyst")).toBe(true) } }) }) describe("highestRole", () => { - it("keeps an observer an observer instead of lifting them to member", () => { - expect(Policy.highestRole(["org:observer"])).toBe("observer") - expect(Policy.highestRole(["org:observer", "org:billing"])).toBe("observer") + it("keeps an analyst an analyst instead of lifting them to member", () => { + expect(Policy.highestRole(["org:analyst"])).toBe("analyst") + expect(Policy.highestRole(["org:analyst", "org:billing"])).toBe("analyst") }) - it("takes the highest when an observer role sits beside a higher one", () => { - expect(Policy.highestRole(["org:observer", "org:engineer"])).toBe("engineer") + it("takes the highest when an analyst role sits beside a higher one", () => { + expect(Policy.highestRole(["org:analyst", "org:engineer"])).toBe("engineer") }) - it("retires a member-pinned credential when its owner becomes an observer", () => { + it("retires a member-pinned credential when its owner becomes an analyst", () => { const pinned = Policy.highestRole(["org:member"]) - expect(Policy.outranks(pinned, Policy.highestRole(["org:observer"]))).toBe(true) + expect(Policy.outranks(pinned, Policy.highestRole(["org:analyst"]))).toBe(true) }) }) diff --git a/packages/domain/src/policy/roles.ts b/packages/domain/src/policy/roles.ts index db865fd59a..8774d56f9a 100644 --- a/packages/domain/src/policy/roles.ts +++ b/packages/domain/src/policy/roles.ts @@ -5,11 +5,11 @@ import { Array as Arr, Option, Order, Schema } from "effect" * permission names only its lowest role. `root` is Maple's own operator access and self-hosted * mode; it is never assigned in Clerk. */ -export const Role = Schema.Literals(["observer", "member", "engineer", "admin", "root"]) +export const Role = Schema.Literals(["analyst", "member", "engineer", "admin", "root"]) export type Role = typeof Role.Type const RANK = { - observer: 0, + analyst: 0, member: 1, engineer: 2, admin: 3, @@ -44,18 +44,18 @@ export type AssignableRole = (typeof assignableRoles)[number] export type ClerkRoleKey = AssignableRole["clerkKey"] /** Read-only, for people who follow the product without operating it. Not offered on the Members page yet. */ -const observerRole = { - role: "observer", - clerkKey: "org:observer", - label: "Observer", +const analystRole = { + role: "analyst", + clerkKey: "org:analyst", + label: "Analyst", summary: "Follows incidents, reliability, product analytics and cost. Changes nothing.", } as const satisfies { role: Role; clerkKey: string; label: string; summary: string } -const clerkRoles = [observerRole, ...assignableRoles] as const +const clerkRoles = [analystRole, ...assignableRoles] as const /** * A Clerk or session role string as a {@link Role}. A role this code does not know reads as - * observer, the least it can hold. + * analyst, the least it can hold. */ export const roleOf = (name: string): Role => { if (name === "root") return "root" @@ -64,7 +64,7 @@ export const roleOf = (name: string): Role => { Arr.findFirst(clerkRoles, (entry) => entry.clerkKey === name), (entry): Role => entry.role, ), - (): Role => "observer", + (): Role => "analyst", ) } @@ -86,7 +86,7 @@ export const outranks = (role: Role, other: Role): boolean => RANK[role] > RANK[ /** "org admins and engineers": who holds a permission whose lowest role is `min`, for messages. */ export const holdersPhrase = (min: Role): string => { switch (min) { - case "observer": + case "analyst": return "people in the organization" case "member": return "org admins, engineers and members" From 9eba2bf0d51efd1f51fab3054d4c018a2b7d473b Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 12:17:25 +0100 Subject: [PATCH 3/4] feat(ai): let analysts build dashboards over MCP and in chat The seven dashboard tools stay open to an analyst; every other tool that changes data is refused and hidden, and a chat turn proposes only dashboard changes to them. --- apps/ai/src/chat/agents.test.ts | 13 ++++--- apps/ai/src/chat/profiles.ts | 13 ++++--- apps/ai/src/mcp/server.ts | 35 ++++++++++++------- .../mcp/tools/__tests__/read-only-key.test.ts | 25 ++++++++++++- apps/ai/src/mcp/tools/registry.ts | 12 ++++--- apps/ai/src/mcp/tools/types.ts | 30 +++++++++++++--- 6 files changed, 95 insertions(+), 33 deletions(-) diff --git a/apps/ai/src/chat/agents.test.ts b/apps/ai/src/chat/agents.test.ts index 0412a4587e..6b34e60ab2 100644 --- a/apps/ai/src/chat/agents.test.ts +++ b/apps/ai/src/chat/agents.test.ts @@ -21,6 +21,7 @@ import { MUTATING_TOOL_NAMES } from "./permissions" import type { TenantContext } from "@maple/domain/http" import { AGENTS, agentForSession, isAutonomousTurn, sessionOf } from "./agents" import { profileForRoles, profileForTurn } from "./profiles" +import { DASHBOARD_AUTHORING_TOOLS } from "../mcp/tools/types" import { CONNECTOR_SYSTEM_PROMPT } from "./prompts" import { buildChatToolkit } from "./maple-toolkit" @@ -137,20 +138,24 @@ describe("profileForTurn", () => { } }) - it("offers an analyst's turn the read tools and none that change data", () => { + it("proposes dashboard changes to an analyst and offers no other tool that changes data", () => { const profile = profileForRoles(profileForTurn(agentForSession(appSession), { kind: "app" }), [ "org:analyst", ]) const { tools } = buildChatToolkit(executor, tenant, profile.ruleset, profile.surface).toolkit assert.property(tools, "search_traces") for (const name of MUTATING_TOOL_NAMES) { - assert.notProperty(tools, name, `${name} was offered to an analyst`) + if (DASHBOARD_AUTHORING_TOOLS.has(name)) { + assert.equal(evaluatePermission(profile.ruleset, name), "ask", name) + } else { + assert.notProperty(tools, name, `${name} was offered to an analyst`) + } } - // A member's turn on the same session still proposes them. + assert.property(tools, "create_dashboard") + // A member's turn on the same session still proposes every one of them. const member = profileForRoles(profileForTurn(agentForSession(appSession), { kind: "app" }), [ "org:member", ]) - assert.isNotEmpty([...MUTATING_TOOL_NAMES]) for (const name of MUTATING_TOOL_NAMES) { assert.equal(evaluatePermission(member.ruleset, name), "ask", name) } diff --git a/apps/ai/src/chat/profiles.ts b/apps/ai/src/chat/profiles.ts index f80614f1cd..43d30a2446 100644 --- a/apps/ai/src/chat/profiles.ts +++ b/apps/ai/src/chat/profiles.ts @@ -9,6 +9,7 @@ import type { ChatTurnOrigin } from "@maple/domain/chat-session" import { PermissionRule, type PermissionRuleset } from "@maple/domain/permission" import * as Policy from "@maple/domain/policy" import { Array as Arr } from "effect" +import { DASHBOARD_AUTHORING_TOOLS } from "../mcp/tools/types" import type { AgentDefinition } from "./agents" import { READ_ONLY_RULESET } from "./permissions" import { CONNECTOR_SYSTEM_PROMPT } from "./prompts" @@ -23,15 +24,17 @@ export interface TurnProfile { readonly prompt: string } -/** The ruleset with every approval-gated tool withheld: an analyst could not approve one. */ -const withoutProposals = (ruleset: PermissionRuleset): PermissionRuleset => +/** The ruleset an analyst's turn runs under: only dashboard changes are still proposed to them. */ +const forAnalyst = (ruleset: PermissionRuleset): PermissionRuleset => Arr.map(ruleset, (rule) => - rule.action === "ask" ? new PermissionRule({ tool: rule.tool, action: "deny" }) : rule, + rule.action === "ask" && !DASHBOARD_AUTHORING_TOOLS.has(rule.tool) + ? new PermissionRule({ tool: rule.tool, action: "deny" }) + : rule, ) -/** The profile for whoever the turn's tools run as: an analyst is offered nothing that changes data. */ +/** The profile for whoever the turn's tools run as: an analyst is offered no change outside dashboards. */ export const profileForRoles = (profile: TurnProfile, roles: ReadonlyArray): TurnProfile => - Policy.isReadOnly(roles) ? { ...profile, ruleset: withoutProposals(profile.ruleset) } : profile + Policy.isAnalyst(roles) ? { ...profile, ruleset: forAnalyst(profile.ruleset) } : profile export const profileForTurn = (agent: AgentDefinition, origin: ChatTurnOrigin): TurnProfile => { switch (origin.kind) { diff --git a/apps/ai/src/mcp/server.ts b/apps/ai/src/mcp/server.ts index 264fcf9293..3742a6b265 100644 --- a/apps/ai/src/mcp/server.ts +++ b/apps/ai/src/mcp/server.ts @@ -7,8 +7,9 @@ import { McpToolsUnavailableError, } from "./executor-loader" import { CurrentMcpRequestTenant } from "./tenant" -import { isReadOnlyTenant, refusesReadOnlyCredential, type McpToolResult } from "./tools/types" +import { refusesAnalyst, refusesReadOnlyCredential, type McpToolResult } from "./tools/types" import type { McpToolDescriptor } from "@maple/domain/mcp-tool-contract" +import * as Policy from "@maple/domain/policy" /** * The text blocks are what a model reads; `structuredContent` is the typed output the tool's @@ -69,15 +70,19 @@ const annotateToolFailure = (result: McpToolResult) => : Effect.void /** - * Hides a tool from a read-only credential's or an analyst's `tools/list`, and answers a call to it as unknown: - * the registry would refuse every such call anyway, and a listed tool invites it. + * Hides a tool from the `tools/list` of a caller the registry would refuse, and answers a call to + * it as unknown: a listed tool invites the call. */ -const HiddenFromReadOnlyCredentials = EffectMcpServer.visibility(() => - Effect.gen(function* () { - const tenant = yield* CurrentMcpRequestTenant - return tenant === undefined || !isReadOnlyTenant(tenant) - }), -) +const hiddenFrom = (isRefused: (tenant: { readOnly?: true; roles: ReadonlyArray }) => boolean) => + EffectMcpServer.visibility(() => + Effect.gen(function* () { + const tenant = yield* CurrentMcpRequestTenant + return tenant === undefined || !isRefused(tenant) + }), + ) + +const HiddenFromReadOnlyKeys = hiddenFrom((tenant) => tenant.readOnly === true) +const HiddenFromAnalysts = hiddenFrom((tenant) => tenant.readOnly === true || Policy.isAnalyst(tenant.roles)) const toBoundaryErrorResult = (error: { readonly _tag: string; readonly message: string }) => toCallToolResult({ @@ -104,12 +109,16 @@ export class McpToolRegistration extends Context.Service< make: Effect.gen(function* () { const server = yield* EffectMcpServer.McpServer const loader = yield* McpToolExecutorLoader - const readOnlyGate = yield* Layer.build(HiddenFromReadOnlyCredentials.layer) + const keyGate = yield* Layer.build(HiddenFromReadOnlyKeys.layer) + const analystGate = yield* Layer.build(HiddenFromAnalysts.layer) + // A dashboard tool is hidden from read-only keys alone; every other change from analysts too. + const gateFor = (name: string, readOnly: boolean) => { + if (refusesAnalyst(name, readOnly)) return { services: analystGate } + return refusesReadOnlyCredential(name, readOnly) ? { services: keyGate } : undefined + } const addTool = (descriptor: McpToolDescriptor) => server.addTool({ - ...(refusesReadOnlyCredential(descriptor.name, descriptor.annotations?.readOnlyHint === true) - ? { services: readOnlyGate } - : undefined), + ...gateFor(descriptor.name, descriptor.annotations?.readOnlyHint === true), tool: new McpSchema.Tool({ name: descriptor.name, title: descriptor.title, diff --git a/apps/ai/src/mcp/tools/__tests__/read-only-key.test.ts b/apps/ai/src/mcp/tools/__tests__/read-only-key.test.ts index 52c6d0e614..27847f8e37 100644 --- a/apps/ai/src/mcp/tools/__tests__/read-only-key.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/read-only-key.test.ts @@ -6,7 +6,8 @@ import { cleanupTestDbs, createTestDb, type TestDb } from "@maple/backend/testin import { AlertDestinationsService } from "@maple/backend/services/alerts/AlertDestinationsService" import { ErrorPolicyService } from "@maple/backend/services/errors/ErrorPolicyService" import { CurrentMcpTenant } from "../../tenant" -import { executeRegisteredMcpToolUnscoped } from "../registry" +import { executeRegisteredMcpToolUnscoped, mapleToolCatalog } from "../registry" +import { DASHBOARD_AUTHORING_TOOLS, FEEDBACK_TOOL, refusalFor } from "../types" const createdDbs: TestDb[] = [] afterEach(() => cleanupTestDbs(createdDbs)) @@ -95,6 +96,28 @@ describe("an analyst over MCP", () => { ) }) +describe("what an analyst may change over MCP", () => { + const changing = Arr.filter(mapleToolCatalog, (definition) => !definition.hints.readOnly) + const openTo = (caller: Parameters[0]) => + Arr.filter( + Arr.map(changing, (definition) => definition.name), + (name) => refusalFor(caller, name, false) === undefined, + ).sort() + + it("is the dashboard tools and Maple's feedback channel, out of every tool that changes data", () => { + expect(openTo({ roles: ["org:analyst"] })).toEqual( + [...DASHBOARD_AUTHORING_TOOLS, FEEDBACK_TOOL].sort(), + ) + }) + + it("is only the feedback channel when their key is read-only", () => { + expect(openTo({ roles: ["org:analyst"], readOnly: true })).toEqual([FEEDBACK_TOOL]) + expect(refusalFor({ roles: ["org:analyst"], readOnly: true }, "create_dashboard", false)).toBe( + "read_only_key", + ) + }) +}) + describe("a scoped key without users:read over MCP", () => { const scopes = Arr.map(INVESTIGATION_READ_FAMILIES, (family) => `${family}:write`) diff --git a/apps/ai/src/mcp/tools/registry.ts b/apps/ai/src/mcp/tools/registry.ts index e6bfa1b90f..0bd7b926f4 100644 --- a/apps/ai/src/mcp/tools/registry.ts +++ b/apps/ai/src/mcp/tools/registry.ts @@ -134,7 +134,7 @@ import { type McpToolPhrases, type McpToolRegistrar, type McpToolResult, - isReadOnlyTenant, + refusalFor, refusesReadOnlyCredential, } from "./types" import { unknownToolMessage } from "../lib/tool-suggestions" @@ -752,11 +752,13 @@ export const executeRegisteredMcpToolUnscoped = Effect.fn("McpToolRegistry.execu if (refusesReadOnlyCredential(definition.name, definition.hints.readOnly)) { const tenant = yield* CurrentMcpTenant - if (isReadOnlyTenant(tenant)) { + const refusal = refusalFor(tenant, definition.name, definition.hints.readOnly) + if (refusal !== undefined) { return yield* new McpAuthInvalidError({ - message: Policy.isReadOnly(tenant.roles) - ? `\`${definition.name}\` changes data. ${Policy.readOnlyMessage}` - : `\`${definition.name}\` changes data, and this API key is read-only. Use a key with write access, or connect with OAuth.`, + message: + refusal === "analyst" + ? `\`${definition.name}\` changes data. ${Policy.analystMessage}` + : `\`${definition.name}\` changes data, and this API key is read-only. Use a key with write access, or connect with OAuth.`, reason: "insufficient_scope", }) } diff --git a/apps/ai/src/mcp/tools/types.ts b/apps/ai/src/mcp/tools/types.ts index 3b949cf34e..a466a3a9d9 100644 --- a/apps/ai/src/mcp/tools/types.ts +++ b/apps/ai/src/mcp/tools/types.ts @@ -124,11 +124,31 @@ export const FEEDBACK_TOOL = "send_maple_feedback" export const refusesReadOnlyCredential = (name: string, readOnly: boolean): boolean => !readOnly && name !== FEEDBACK_TOOL -/** Whether the caller may only read: a read-only API key, or anyone whose role is analyst. */ -export const isReadOnlyTenant = (tenant: { - readonly readOnly?: true - readonly roles: ReadonlyArray -}): boolean => tenant.readOnly === true || Policy.isReadOnly(tenant.roles) +/** The tools that build dashboards: the only changes an analyst's role reaches. */ +export const DASHBOARD_AUTHORING_TOOLS: ReadonlySet = new Set([ + "create_dashboard", + "update_dashboard", + "add_dashboard_widget", + "update_dashboard_widget", + "remove_dashboard_widget", + "reorder_dashboard_widgets", + "replace_dashboard_widgets", +]) + +/** Whether an analyst is refused this tool: one that changes data and does not build a dashboard. */ +export const refusesAnalyst = (name: string, readOnly: boolean): boolean => + refusesReadOnlyCredential(name, readOnly) && !DASHBOARD_AUTHORING_TOOLS.has(name) + +/** Why this caller is refused the tool, and so never shown it: their key, their role, or nothing. */ +export const refusalFor = ( + tenant: { readonly readOnly?: true; readonly roles: ReadonlyArray }, + name: string, + readOnly: boolean, +): "analyst" | "read_only_key" | undefined => { + if (Policy.isAnalyst(tenant.roles) && refusesAnalyst(name, readOnly)) return "analyst" + if (tenant.readOnly === true && refusesReadOnlyCredential(name, readOnly)) return "read_only_key" + return undefined +} /** * Who a tool is for. From ee977efe5522fc0dcdbbee640c2b9309ead4770a Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 12:32:56 +0100 Subject: [PATCH 4/4] fix(ai): keep analysts from driving or stopping an investigation's chat The chat routes are a raw router, so the analyst gate never ran for them: an analyst could send a follow-up, which may file a new diagnosis, and stop a running pass. Both are refused now; reading the thread stays open. Also covers an analyst's tools/list over the real MCP transport. --- apps/ai/src/mcp/app.test.ts | 20 ++++++++- apps/ai/src/routes/chat-sessions.http.test.ts | 44 ++++++++++++++++++- apps/ai/src/routes/chat-sessions.http.ts | 7 +++ 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/apps/ai/src/mcp/app.test.ts b/apps/ai/src/mcp/app.test.ts index f40e9ec61f..dc2a479f6c 100644 --- a/apps/ai/src/mcp/app.test.ts +++ b/apps/ai/src/mcp/app.test.ts @@ -736,7 +736,7 @@ describe("MCP HTTP authorization", () => { }) describe("MCP tools/list per credential", () => { - it("hides write tools from a read-only key, in the same isolate that shows them to a full key", async () => { + it("hides write tools from a read-only key and all but the dashboard tools from an analyst, in the isolate that shows them to a full key", async () => { const db = createTestDb(createdDbs) const base = Layer.mergeAll(db.layer, Env.layer.pipe(Layer.provide(testConfig()))) const services = Layer.mergeAll( @@ -748,7 +748,7 @@ describe("MCP tools/list per credential", () => { ).pipe(Layer.provideMerge(base)) const orgId = Schema.decodeSync(OrgId)("org_test") const userId = Schema.decodeSync(UserId)("user_test") - const [full, readOnly] = await Effect.runPromise( + const [full, readOnly, analyst] = await Effect.runPromise( Effect.gen(function* () { const apiKeys = yield* ApiKeysService return [ @@ -757,6 +757,11 @@ describe("MCP tools/list per credential", () => { name: "Read-only key", scopes: Arr.map(INVESTIGATION_READ_FAMILIES, (family) => `${family}:read`), }), + // A key minted for a person acts with that person's org roles. + yield* apiKeys.create(orgId, userId, { + name: "Analyst's key", + metadataJson: { source: "maple_cli", roles: ["org:analyst"], deviceName: "laptop" }, + }), ] as const }).pipe(Effect.provide(services)), ) @@ -783,6 +788,7 @@ describe("MCP tools/list per credential", () => { return { status: response.status, write: names.includes("create_alert_rule"), + dashboard: names.includes("create_dashboard"), read: names.includes("inspect_trace"), feedback: names.includes("send_maple_feedback"), } @@ -791,12 +797,22 @@ describe("MCP tools/list per credential", () => { expect(await listFor(full.secret)).toEqual({ status: 200, write: true, + dashboard: true, read: true, feedback: true, }) expect(await listFor(readOnly.secret)).toEqual({ status: 200, write: false, + dashboard: false, + read: true, + feedback: true, + }) + // An analyst builds dashboards and operates nothing else. + expect(await listFor(analyst.secret)).toEqual({ + status: 200, + write: false, + dashboard: true, read: true, feedback: true, }) diff --git a/apps/ai/src/routes/chat-sessions.http.test.ts b/apps/ai/src/routes/chat-sessions.http.test.ts index dc498c99fc..ed9cd662fc 100644 --- a/apps/ai/src/routes/chat-sessions.http.test.ts +++ b/apps/ai/src/routes/chat-sessions.http.test.ts @@ -1,5 +1,5 @@ import { afterEach, assert, describe, it } from "@effect/vitest" -import { AuthorizationUnavailableError, OrgId, UserId } from "@maple/domain/http" +import { AuthorizationUnavailableError, OrgId, RoleName, UserId } from "@maple/domain/http" import { fakeChatSessionsLayer } from "@maple/backend/testing/chat-sessions-fake" import { envPorts } from "@maple/backend/platform/env-ports" import { ConfigProvider, Effect, Layer, Schema } from "effect" @@ -314,6 +314,48 @@ describe("ChatSessionsRouter", () => { ) }) + it.effect("lets an analyst read an investigation's thread but not drive or stop it", () => { + const testDb = createTestDb(trackedDbs) + const turns: Array = [] + const asAnalyst = Layer.effect( + AuthService, + Effect.gen(function* () { + const auth = yield* AuthService + return { + ...auth, + resolveTenant: () => + Effect.succeed({ + orgId: ORG, + userId: USER, + roles: [Schema.decodeSync(RoleName)("org:analyst")], + authMode: "clerk" as const, + }), + } + }), + ).pipe(Layer.provide(AuthService.layer)) + const call = (handler: (request: Request) => Promise, method: string, path: string) => + Effect.promise(() => + handler( + new Request(`http://api.localhost${SESSION_PATH}${path}`, { + method, + headers: { authorization: "Bearer aaa.bbb.ccc" }, + }), + ), + ) + return withHandler( + testDb, + turns, + Effect.fnUntraced(function* (handler) { + const sent = yield* send(handler, { authorization: "Bearer aaa.bbb.ccc" }) + assert.strictEqual(sent.status, 403) + assert.strictEqual(turns.length, 0) + assert.strictEqual((yield* call(handler, "POST", "/abort")).status, 403) + assert.strictEqual((yield* call(handler, "GET", "/history")).status, 200) + }), + asAnalyst, + ) + }) + it.effect("hides another org's session as 404", () => { const testDb = createTestDb(trackedDbs) const turns: Array = [] diff --git a/apps/ai/src/routes/chat-sessions.http.ts b/apps/ai/src/routes/chat-sessions.http.ts index 222383c2ac..c0a9bd122a 100644 --- a/apps/ai/src/routes/chat-sessions.http.ts +++ b/apps/ai/src/routes/chat-sessions.http.ts @@ -40,6 +40,7 @@ import { AuthService } from "@maple/backend/services/auth/AuthService" import { Env } from "@maple/backend/platform/Env" import { resolveChatSessionTenantContext } from "../mcp/lib/resolve-tenant" import type { TenantContext } from "@maple/domain/http" +import * as Policy from "@maple/domain/policy" const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)) @@ -147,6 +148,12 @@ const resolveSession = Effect.fn("ChatSessionsRouter.resolveSession")(function* return { ok: false, failure: problem("Chat session not found", 404) } as const } + // Driving an investigation is a member's job (`investigations:run`): a follow-up can file a new + // diagnosis, and a stop ends a running pass. An analyst reads the thread. + if (request.method !== "GET" && Policy.isAnalyst(tenant.roles)) { + return { ok: false, failure: problem(Policy.analystMessage, 403) } as const + } + const stub = (yield* ChatSessions).session(sessionId) const session: ResolvedSession = { sessionId, tenant, stub, url }