From 11938f41dcbf79277c9f0ae6976dd98e2103332f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 00:39:45 +0100 Subject: [PATCH 1/3] feat(auth): refuse observers every endpoint that changes the organization The auth middlewares now refuse a session or credential whose highest role is observer any request that is not a read or a change to the caller's own state. It is refused by default: an endpoint passes only as a GET, when it is marked ObserverAccess, or on /v2 when the scope registry already names it a read. Marked read: the dashboard query groups and the previews that sit beside writes. Marked personal: the caller's digest, devices, MCP connection and CLI and MCP sign-in approvals, which pin the caller's role. Internal routes answer with ReadOnlyRoleError, /v2 with insufficient_permissions, and API key denials are audited. --- CLAUDE.md | 3 + .../src/routes/v2/mobile-devices.http.test.ts | 30 ++++++- .../routes/v2/phase1-resources.http.test.ts | 23 ++++- apps/api/src/routes/v2/telemetry.http.test.ts | 21 ++++- .../MapleAPI/Sources/MapleAPI/openapi.json | 84 +++++++++---------- .../src/services/auth/ApiAuthorizationLive.ts | 5 ++ .../services/auth/ApiAuthorizationV2Live.ts | 7 ++ .../services/auth/SessionAuthorizationLive.ts | 3 + .../src/services/auth/observer-gate.test.ts | 60 +++++++++++++ .../src/services/auth/observer-gate.ts | 34 ++++++++ .../anticipated-error-identifiers.ts | 1 + packages/domain/src/http/ai-models.ts | 5 +- packages/domain/src/http/ai-sessions.ts | 5 +- packages/domain/src/http/auth.ts | 13 +-- packages/domain/src/http/billing.ts | 3 +- packages/domain/src/http/current-tenant.ts | 19 +++++ packages/domain/src/http/digest.ts | 9 +- packages/domain/src/http/errors.ts | 3 +- packages/domain/src/http/index.ts | 8 +- packages/domain/src/http/integrations.ts | 5 +- packages/domain/src/http/mcp.ts | 5 +- .../domain/src/http/observer-access.test.ts | 61 ++++++++++++++ packages/domain/src/http/observer-access.ts | 29 +++++++ packages/domain/src/http/query-engine.ts | 3 + packages/domain/src/http/session-replay.ts | 3 + packages/domain/src/http/users.ts | 3 + packages/domain/src/http/v2/agent-feedback.ts | 19 +++-- packages/domain/src/http/v2/auth.ts | 2 + .../domain/src/http/v2/device-credentials.ts | 55 ++++++------ .../domain/src/http/v2/integrations-chat.ts | 19 +++-- .../domain/src/http/v2/mcp-connections.ts | 19 +++-- packages/domain/src/http/v2/mobile-devices.ts | 73 +++++++++------- .../domain/src/http/v2/widget-credentials.ts | 37 ++++---- packages/domain/src/policy/index.ts | 7 ++ 34 files changed, 513 insertions(+), 163 deletions(-) create mode 100644 packages/backend/src/services/auth/observer-gate.test.ts create mode 100644 packages/backend/src/services/auth/observer-gate.ts create mode 100644 packages/domain/src/http/observer-access.test.ts create mode 100644 packages/domain/src/http/observer-access.ts diff --git a/CLAUDE.md b/CLAUDE.md index 189c1c0d4b..e91207cae8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,9 @@ 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 + 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/api/src/routes/v2/mobile-devices.http.test.ts b/apps/api/src/routes/v2/mobile-devices.http.test.ts index e7728dd97a..61c527929c 100644 --- a/apps/api/src/routes/v2/mobile-devices.http.test.ts +++ b/apps/api/src/routes/v2/mobile-devices.http.test.ts @@ -90,11 +90,17 @@ const makeHarness = () => { return { status: response.status, body: text.length === 0 ? null : JSON.parse(text) } } - const bootstrapKey = (scopes?: ReadonlyArray) => + const bootstrapKey = (scopes?: ReadonlyArray, roles?: ReadonlyArray) => runtime.runPromise( Effect.gen(function* () { const service = yield* ApiKeysService - return yield* service.create(ORG, USER, { name: "mobile-test", scopes }) + return yield* service.create(ORG, USER, { + name: "mobile-test", + scopes, + ...(roles === undefined + ? undefined + : { metadataJson: { source: "maple_cli", roles, deviceName: "laptop" } }), + }) }), ) @@ -184,6 +190,26 @@ describe("v2 mobile devices", () => { } }) + it("lets an observer register and remove their own device", async () => { + const harness = makeHarness() + try { + const key = await harness.bootstrapKey(undefined, ["org:observer"]) + const created = await harness.request("PUT", `/v2/mobile_devices/${TOKEN}`, key.secret, { + platform: "ios", + environment: "sandbox", + bundle_id: "com.maple.mobile", + }) + expect(created.status).toBe(200) + expect(created.body.token).toBe(TOKEN) + + const removed = await harness.request("DELETE", `/v2/mobile_devices/${TOKEN}`, key.secret) + expect(removed.status).toBe(200) + expect(removed.body.deleted).toBe(true) + } finally { + await harness.dispose() + } + }) + it("records and forgets a Live Activity's update token", async () => { const harness = makeHarness() try { 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 9eedd4b3b2..b37296d26b 100644 --- a/apps/api/src/routes/v2/phase1-resources.http.test.ts +++ b/apps/api/src/routes/v2/phase1-resources.http.test.ts @@ -726,13 +726,16 @@ const makeHarness = ( } } - const bootstrapKey = (scopes?: ReadonlyArray) => + const bootstrapKey = (scopes?: ReadonlyArray, roles?: ReadonlyArray) => runtime.runPromise( Effect.gen(function* () { const service = yield* ApiKeysService return yield* service.create(ORG, USER, { name: "phase1-test", scopes, + ...(roles === undefined + ? undefined + : { metadataJson: { source: "maple_cli", roles, deviceName: "laptop" } }), }) }), ) @@ -899,6 +902,24 @@ describe("v2 error_issues over HTTP", () => { await harness.dispose() }) + it("lets an observer read an issue and refuses to let them triage it", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey(undefined, ["org:observer"]) + + const read = await harness.request("GET", `/v2/error_issues/${ISS_ID}`, { token: key.secret }) + expect(read.status).toBe(200) + + const refused = await harness.request("POST", `/v2/error_issues/${ISS_ID}/transitions`, { + token: key.secret, + body: { to_state: "todo", expected_state: "triage" }, + }) + expect(refused.status).toBe(403) + expect(refused.body.error.code).toBe("insufficient_permissions") + expect(refused.body.error.message).toContain("Observers can read Maple") + expect(harness.issueActions()).toEqual([]) + await harness.dispose() + }) + it("maps refused transitions to 409, missing issues to 404, and needs write scope", async () => { const harness = makeHarness() const reader = await harness.bootstrapKey(["error_issues:read"]) diff --git a/apps/api/src/routes/v2/telemetry.http.test.ts b/apps/api/src/routes/v2/telemetry.http.test.ts index f44f2d58b1..205b616bda 100644 --- a/apps/api/src/routes/v2/telemetry.http.test.ts +++ b/apps/api/src/routes/v2/telemetry.http.test.ts @@ -378,11 +378,17 @@ const makeHarness = ( const runtime = ManagedRuntime.make(servicesLive) const org = Schema.decodeSync(OrgId)("org_telemetry_e2e") const user = Schema.decodeSync(UserId)("user_telemetry_e2e") - const bootstrapKey = (scopes?: ReadonlyArray) => + const bootstrapKey = (scopes?: ReadonlyArray, roles?: ReadonlyArray) => runtime.runPromise( Effect.gen(function* () { const service = yield* ApiKeysService - return yield* service.create(org, user, { name: "telemetry-test", scopes }) + return yield* service.create(org, user, { + name: "telemetry-test", + scopes, + ...(roles === undefined + ? undefined + : { metadataJson: { source: "maple_cli", roles, deviceName: "laptop" } }), + }) }), ) const request = async (method: string, path: string, token: string, body?: unknown) => { @@ -565,6 +571,17 @@ describe("v2 telemetry reads over HTTP", () => { await harness.dispose() }) + it("lets an observer search traces, a POST that only reads", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey(undefined, ["org:observer"]) + const searched = await harness.request("POST", "/v2/traces/search", key.secret, { + start_time: START, + end_time: END, + }) + expect(searched.status).toBe(200) + expect(searched.body.data[0]).toMatchObject({ object: "trace", id: TRACE_ID }) + }) + it("requires windows, validates log IDs, and treats telemetry POSTs as scoped reads", async () => { const harness = makeHarness() const tracesKey = await harness.bootstrapKey(["traces:read"]) diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json index 179457999a..cdb95c5afb 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json @@ -7638,7 +7638,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -7807,7 +7807,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -7931,7 +7931,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -8078,7 +8078,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -8203,7 +8203,7 @@ } } }, - "description": "The @maple/http/errors/AlertForbiddenError failure. HTTP 403. | The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/errors/AlertForbiddenError failure. HTTP 403. | The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -8337,7 +8337,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -8528,7 +8528,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -8743,7 +8743,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -8867,7 +8867,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -9017,7 +9017,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -9174,7 +9174,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -9298,7 +9298,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -9442,7 +9442,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -9584,7 +9584,7 @@ } } }, - "description": "The @maple/http/errors/DeviceCredentialSessionRequiredError failure. HTTP 403. | The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/errors/DeviceCredentialSessionRequiredError failure. HTTP 403. | The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -9733,7 +9733,7 @@ } } }, - "description": "The @maple/http/errors/DeviceCredentialSessionRequiredError failure. HTTP 403. | The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/errors/DeviceCredentialSessionRequiredError failure. HTTP 403. | The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "409": { "content": { @@ -9875,7 +9875,7 @@ } } }, - "description": "The @maple/http/errors/DeviceCredentialSessionRequiredError failure. HTTP 403. | The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/errors/DeviceCredentialSessionRequiredError failure. HTTP 403. | The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -10118,7 +10118,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -10399,7 +10399,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -10524,7 +10524,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -10680,7 +10680,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -10834,7 +10834,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -10988,7 +10988,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -11142,7 +11142,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -11286,7 +11286,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -11431,7 +11431,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -11566,7 +11566,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -11691,7 +11691,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -11822,7 +11822,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -11971,7 +11971,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -12110,7 +12110,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -12267,7 +12267,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -12446,7 +12446,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -12581,7 +12581,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -12731,7 +12731,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -12908,7 +12908,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -13053,7 +13053,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -13188,7 +13188,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -13323,7 +13323,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -13457,7 +13457,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -13609,7 +13609,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -13739,7 +13739,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -13863,7 +13863,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { diff --git a/packages/backend/src/services/auth/ApiAuthorizationLive.ts b/packages/backend/src/services/auth/ApiAuthorizationLive.ts index 2a82f2200b..95ba60f791 100644 --- a/packages/backend/src/services/auth/ApiAuthorizationLive.ts +++ b/packages/backend/src/services/auth/ApiAuthorizationLive.ts @@ -11,6 +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" export const ApiAuthorizationLive = Layer.effect( CurrentTenant.Authorization, @@ -74,6 +75,9 @@ export const ApiAuthorizationLive = Layer.effect( roles: resolved.roles ?? API_KEY_DEFAULT_ROLES, authMode: "self_hosted", }) + yield* refuseObserverWrites(tenant.roles, options).pipe( + Effect.tapError((error) => recordDenied(error.message)), + ) return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, tenant), Effect.provideService(CurrentAuditActor, { @@ -99,6 +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) 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 ed35cc5578..6bd974d9d3 100644 --- a/packages/backend/src/services/auth/ApiAuthorizationV2Live.ts +++ b/packages/backend/src/services/auth/ApiAuthorizationV2Live.ts @@ -33,6 +33,7 @@ import { deviceRateLimitKey, } from "./ApiV2RateLimiter" import { API_KEY_DEFAULT_ROLES, getBearerToken } from "./bearer-token" +import { refuseObserverWritesV2 } from "./observer-gate" /** The access token of an RFC 9449 `Authorization: DPoP ` request. */ const getDpopToken = (headers: Record): string | undefined => { @@ -180,6 +181,7 @@ export const ApiAuthorizationV2Live = Layer.effect( authMode: "self_hosted", scopes: resolved.scopes, }) + yield* refuseObserverWritesV2(tenant.roles, options, route.path) const label = "Device credential" return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, tenant), @@ -300,6 +302,9 @@ export const ApiAuthorizationV2Live = Layer.effect( authMode: "self_hosted", ...(resolved.scopes !== null ? { scopes: resolved.scopes } : undefined), }) + yield* refuseObserverWritesV2(tenant.roles, options, route.path).pipe( + Effect.tapError((error) => recordDenied(error.message)), + ) return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, tenant), Effect.provideService(CurrentAuditActor, { @@ -341,6 +346,8 @@ export const ApiAuthorizationV2Live = Layer.effect( .ensureServedHere(tenant.orgId) .pipe(Effect.mapError((error) => V2OrganizationAccessDenied.make(error.message))) } + const { route } = yield* HttpRouter.RouteContext + yield* refuseObserverWritesV2(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 db5a65b60c..7760b8ab5a 100644 --- a/packages/backend/src/services/auth/SessionAuthorizationLive.ts +++ b/packages/backend/src/services/auth/SessionAuthorizationLive.ts @@ -10,6 +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" /** * Authorization for the internal API: Clerk sessions only. @@ -47,6 +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) const actor = { type: "user", source: "dashboard" } as const return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, new CurrentTenant.TenantSchema(tenant)), @@ -85,6 +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) 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/observer-gate.test.ts new file mode 100644 index 0000000000..b59b4a992c --- /dev/null +++ b/packages/backend/src/services/auth/observer-gate.test.ts @@ -0,0 +1,60 @@ +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" + +const OBSERVER = ["org:observer"] +const MEMBER = ["org:member"] + +const errors = MapleInternalApi.groups.errors +const transition = { group: errors, endpoint: errors.endpoints.transitionIssue } +const queries = MapleInternalApi.groups.queryEngine +const traces = MapleApiV2.groups.traces +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", () => + Effect.gen(function* () { + const error = yield* Effect.flip(refuseObserverWrites(OBSERVER, transition)) + assert.instanceOf(error, CurrentTenant.ReadOnlyRoleError) + expect(error.message).toContain("Ask an org admin for the Member role") + }), + ) + + 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) + }), + ) + + it.effect("lets an observer run a dashboard query", () => + Effect.gen(function* () { + const query = { group: queries, endpoint: queries.endpoints.executeBatch } + expect(yield* allowed(refuseObserverWrites(OBSERVER, query))).toBe(true) + }), + ) +}) + +describe("refuseObserverWritesV2", () => { + const create = { group: dashboards, endpoint: dashboards.endpoints.create } + + it.effect("lets an observer 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) + }), + ) + + it.effect("refuses an observer 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) + }), + ) +}) diff --git a/packages/backend/src/services/auth/observer-gate.ts b/packages/backend/src/services/auth/observer-gate.ts new file mode 100644 index 0000000000..d3bc7b0025 --- /dev/null +++ b/packages/backend/src/services/auth/observer-gate.ts @@ -0,0 +1,34 @@ +import { CurrentTenant, isOpenToObservers } 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] + +/** + * 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`. + */ +export const refuseObserverWrites = ( + roles: ReadonlyArray, + called: Called, +): Effect.Effect => + Policy.isReadOnly(roles) && !isOpenToObservers(called) + ? Effect.fail(new CurrentTenant.ReadOnlyRoleError({ message: Policy.readOnlyMessage })) + : Effect.void + +/** + * {@link refuseObserverWrites} 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 = ( + roles: ReadonlyArray, + called: Called, + routePath: string, +): Effect.Effect> => + Policy.isReadOnly(roles) && + !isOpenToObservers(called) && + requiredScopeForRoute(called.endpoint.method, routePath)?.access !== "read" + ? Effect.fail(V2InsufficientPermissions.make(Policy.readOnlyMessage)) + : Effect.void diff --git a/packages/domain/src/generated/anticipated-error-identifiers.ts b/packages/domain/src/generated/anticipated-error-identifiers.ts index 9099172dd0..a1ef0e1e27 100644 --- a/packages/domain/src/generated/anticipated-error-identifiers.ts +++ b/packages/domain/src/generated/anticipated-error-identifiers.ts @@ -111,6 +111,7 @@ export const ANTICIPATED_ERROR_IDENTIFIER_LIST: ReadonlyArray = [ "@maple/http/errors/PreviewSignInInvalidKeyError", "@maple/http/errors/QueryEngineValidationError", "@maple/http/errors/RawSqlValidationError", + "@maple/http/errors/ReadOnlyRoleError", "@maple/http/errors/RecommendationIssueNotFoundError", "@maple/http/errors/ScrapeTargetLimitReachedError", "@maple/http/errors/ScrapeTargetNotFoundError", diff --git a/packages/domain/src/http/ai-models.ts b/packages/domain/src/http/ai-models.ts index 7b6acc8097..d15ad4d15e 100644 --- a/packages/domain/src/http/ai-models.ts +++ b/packages/domain/src/http/ai-models.ts @@ -1,6 +1,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/http-api" import { Schema } from "effect" import { SessionAuthorization } from "./current-tenant" +import { ObserverAccess } from "./observer-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 @@ -73,4 +74,6 @@ export class AiModelsInternalApiGroup extends HttpApiGroup.make("aiModelsInterna }), ) .prefix("/internal/ai-models") - .middleware(SessionAuthorization) {} + .middleware(SessionAuthorization) + // Every endpoint here only reads, so an observer may call it. + .annotate(ObserverAccess, "read") {} diff --git a/packages/domain/src/http/ai-sessions.ts b/packages/domain/src/http/ai-sessions.ts index 8b3ef7dd3b..c20c2ed8da 100644 --- a/packages/domain/src/http/ai-sessions.ts +++ b/packages/domain/src/http/ai-sessions.ts @@ -6,6 +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" // Backed by the `maple_ai.*` span attributes the ingest gateway stamps at // decode time; a session is resolved at trace granularity by @@ -1099,4 +1100,6 @@ export class AiSessionsInternalApiGroup extends HttpApiGroup.make("aiSessionsInt }), ) .prefix("/internal/ai-sessions") - .middleware(SessionAuthorization) {} + .middleware(SessionAuthorization) + // Every endpoint here only reads, so an observer may call it. + .annotate(ObserverAccess, "read") {} diff --git a/packages/domain/src/http/auth.ts b/packages/domain/src/http/auth.ts index 45709c81e2..72e2981dee 100644 --- a/packages/domain/src/http/auth.ts +++ b/packages/domain/src/http/auth.ts @@ -2,6 +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" export class CliDeviceStartRequest extends Schema.Class("CliDeviceStartRequest")({ deviceName: Schema.String, @@ -264,13 +265,13 @@ export class AuthApiGroup extends HttpApiGroup.make("auth") HttpApiEndpoint.post("sessionRefresh", "/session/refresh", { success: SelfHostedLoginResponse, error: [SelfHostedAuthDisabledError, UnauthorizedError], - }), + }).annotate(ObserverAccess, "personal"), ) .add( HttpApiEndpoint.delete("cliSessionRevoke", "/cli/session", { success: CliDeviceActionResponse, error: [CliDeviceConflictError, CliDevicePersistenceError], - }), + }).annotate(ObserverAccess, "personal"), ) .prefix("/api/auth") .middleware(Authorization) {} @@ -297,7 +298,7 @@ export class AuthSessionApiGroup extends HttpApiGroup.make("authSession") CliDeviceConflictError, CliDevicePersistenceError, ], - }), + }).annotate(ObserverAccess, "personal"), ) .add( HttpApiEndpoint.post("cliDeviceDeny", "/cli/device/:userCode/deny", { @@ -309,7 +310,7 @@ export class AuthSessionApiGroup extends HttpApiGroup.make("authSession") CliDeviceConflictError, CliDevicePersistenceError, ], - }), + }).annotate(ObserverAccess, "personal"), ) .add( HttpApiEndpoint.get("mcpOAuthAuthorizationInspect", "/mcp/oauth/authorization/:requestId", { @@ -332,7 +333,7 @@ export class AuthSessionApiGroup extends HttpApiGroup.make("authSession") McpOAuthAuthorizationConflictError, McpOAuthPersistenceError, ], - }), + }).annotate(ObserverAccess, "personal"), ) .add( HttpApiEndpoint.post("mcpOAuthAuthorizationDeny", "/mcp/oauth/authorization/:requestId/deny", { @@ -344,7 +345,7 @@ export class AuthSessionApiGroup extends HttpApiGroup.make("authSession") McpOAuthAuthorizationConflictError, McpOAuthPersistenceError, ], - }), + }).annotate(ObserverAccess, "personal"), ) .prefix("/api/auth") .middleware(SessionAuthorization) {} diff --git a/packages/domain/src/http/billing.ts b/packages/domain/src/http/billing.ts index 7eb1fb1f1f..23b430de22 100644 --- a/packages/domain/src/http/billing.ts +++ b/packages/domain/src/http/billing.ts @@ -15,6 +15,7 @@ import { LinkBillingOrgRequest, } from "./billing-account" import { WarehouseQueryError } from "./warehouse-errors" +import { ObserverAccess } from "./observer-access" // Contract for raw Autumn proxy responses. Schemas model only consumed fields // and tolerate additive upstream fields. @@ -725,7 +726,7 @@ export class BillingApiGroup extends HttpApiGroup.make("billing") payload: PreviewAttachRequest, success: PreviewAttachResult, error: [BillingManagedByBillingOrgError, ...billingRequestErrors], - }), + }).annotate(ObserverAccess, "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 2ef661fde8..1c4706cb48 100644 --- a/packages/domain/src/http/current-tenant.ts +++ b/packages/domain/src/http/current-tenant.ts @@ -3,6 +3,7 @@ import { Schema, Context as EffectContext } from "effect" import { type ActorId, AuthMode, OrgId, RoleName, UserId } from "@maple/primitives" import type { ChatTurnOrigin } from "../chat-session" import { MapleRegion } from "../organization-regions" +import { readOnlyMessage } from "../policy" import { HttpTaggedError } from "./error-policy" export class UnauthorizedError extends HttpTaggedError()( @@ -90,6 +91,21 @@ export class OrganizationWrongRegionError extends HttpTaggedError()( + "@maple/http/errors/ReadOnlyRoleError", + { message: Schema.String }, + { + status: 403, + code: "read_only_role", + title: "Read-only access", + message: readOnlyMessage, + retry: "never", + recovery: "request_access", + exposure: "public_message", + }, +) {} + /** * Who a request, tool call or job acts as. `TenantSchema` is the form the HTTP middleware * provides; the optional fields below it are set only by the MCP and chat surfaces. @@ -135,6 +151,7 @@ export class Authorization extends HttpApiMiddleware.Service< UnauthorizedError, AuthorizationUnavailableError, OrganizationAccessDeniedError, + ReadOnlyRoleError, OrganizationWrongRegionError, ], security: { @@ -184,6 +201,7 @@ export class SessionAuthorization extends HttpApiMiddleware.Service< AuthorizationUnavailableError, ApiKeyNotAcceptedError, OrganizationAccessDeniedError, + ReadOnlyRoleError, OrganizationWrongRegionError, ], security: { @@ -228,6 +246,7 @@ export class RegionlessSessionAuthorization extends HttpApiMiddleware.Service< AuthorizationUnavailableError, ApiKeyNotAcceptedError, OrganizationAccessDeniedError, + ReadOnlyRoleError, ], security: { bearer: HttpApiSecurity.bearer, diff --git a/packages/domain/src/http/digest.ts b/packages/domain/src/http/digest.ts index 7e9bad8d92..ac8ec68c6e 100644 --- a/packages/domain/src/http/digest.ts +++ b/packages/domain/src/http/digest.ts @@ -1,6 +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" export const DigestSubscriptionId = Schema.String.check(Schema.isUUID()).pipe( Schema.brand("@maple/DigestSubscriptionId"), @@ -163,25 +164,25 @@ export class DigestApiGroup extends HttpApiGroup.make("digest") payload: UpsertDigestSubscriptionRequest, success: DigestSubscriptionResponse, error: [DigestPersistenceError, DigestNotFoundError, DigestEmailUnavailableError], - }), + }).annotate(ObserverAccess, "personal"), ) .add( HttpApiEndpoint.delete("deleteSubscription", "/", { success: Schema.Void, error: DigestPersistenceError, - }), + }).annotate(ObserverAccess, "personal"), ) .add( HttpApiEndpoint.post("preview", "/preview", { success: DigestPreviewResponse, error: [DigestPersistenceError, DigestNotConfiguredError, DigestRenderError], - }), + }).annotate(ObserverAccess, "read"), ) .add( HttpApiEndpoint.post("previewWebAnalytics", "/web-analytics/preview", { success: DigestPreviewResponse, error: [DigestPersistenceError, DigestNotConfiguredError, DigestRenderError], - }), + }).annotate(ObserverAccess, "read"), ) .prefix("/internal/digest") .middleware(SessionAuthorization) {} diff --git a/packages/domain/src/http/errors.ts b/packages/domain/src/http/errors.ts index d088c1e24a..89750ffcba 100644 --- a/packages/domain/src/http/errors.ts +++ b/packages/domain/src/http/errors.ts @@ -43,6 +43,7 @@ import { IssueSeveritySource, WorkflowState, } from "../error-issues" +import { ObserverAccess } from "./observer-access" export { ActorType, ErrorIncidentReason, @@ -919,7 +920,7 @@ export class ErrorsApiGroup extends HttpApiGroup.make("errors") payload: EscalationPolicyEvaluationRequest, success: EscalationPolicyEvaluationDocument, error: ErrorPersistenceError, - }), + }).annotate(ObserverAccess, "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 e777872091..e079d9a9d5 100644 --- a/packages/domain/src/http/index.ts +++ b/packages/domain/src/http/index.ts @@ -21,6 +21,7 @@ export * from "./anomalies" export * from "./api-keys" export * from "./device-credentials" export * from "./audit-log" +export * from "./observer-access" export * from "./alerts" export * from "./escalation-chains" export * from "./oncall-schedules" @@ -35,13 +36,14 @@ export * from "./billing" export * from "./billing-account" export * from "./chat" export * as CurrentTenant from "./current-tenant" -// The tenant-resolution failures themselves, flat: `@maple/auth` raises all -// three and has no use for the namespace. (`UnauthorizedError` already reaches -// the barrel through `./warehouse`.) +// The tenant-resolution failures themselves, flat: `@maple/auth` raises the +// first three and has no use for the namespace, and the 4xx registry reads only +// flat exports. (`UnauthorizedError` already reaches the barrel through `./warehouse`.) export { AuthorizationUnavailableError, OrganizationAccessDeniedError, OrganizationWrongRegionError, + ReadOnlyRoleError, type TenantContext, } from "./current-tenant" export * from "./dashboard-sections" diff --git a/packages/domain/src/http/integrations.ts b/packages/domain/src/http/integrations.ts index c5e6fd5c21..9902e298a2 100644 --- a/packages/domain/src/http/integrations.ts +++ b/packages/domain/src/http/integrations.ts @@ -35,6 +35,7 @@ import { GcpCloudSqlSettings } from "../gcp" export { GcpCloudSqlSettings } from "../gcp" import { SupabaseAdvisorLint, SupabaseLogDrainSummary, SupabaseTopQuery } from "../supabase" +import { ObserverAccess } from "./observer-access" export { SupabaseAdvisorKind, SupabaseAdvisorLevel, @@ -1735,7 +1736,7 @@ export class IntegrationsApiGroup extends HttpApiGroup.make("integrations") HttpApiEndpoint.post("infraSurfaces", "/infra-surfaces", { payload: InfraPresenceRequest, success: InfraSurfacesResponse, - }), + }).annotate(ObserverAccess, "read"), ) .add( HttpApiEndpoint.get("cloudflareStatus", "/cloudflare/status", { @@ -1760,7 +1761,7 @@ export class IntegrationsApiGroup extends HttpApiGroup.make("integrations") IntegrationsUpstreamError, IntegrationsPersistenceError, ], - }), + }).annotate(ObserverAccess, "read"), ) .add( HttpApiEndpoint.post("cloudflareStart", "/cloudflare/start", { diff --git a/packages/domain/src/http/mcp.ts b/packages/domain/src/http/mcp.ts index 1139c8ee9e..3975ecb914 100644 --- a/packages/domain/src/http/mcp.ts +++ b/packages/domain/src/http/mcp.ts @@ -5,6 +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" // MCP hub endpoint schemas (`/mcp-servers`, a server's overview, its tool page and its sessions). // @@ -339,4 +340,6 @@ export class McpInternalApiGroup extends HttpApiGroup.make("mcpInternal") }), ) .prefix("/internal/mcp-servers") - .middleware(SessionAuthorization) {} + .middleware(SessionAuthorization) + // Every endpoint here only reads, so an observer may call it. + .annotate(ObserverAccess, "read") {} diff --git a/packages/domain/src/http/observer-access.test.ts b/packages/domain/src/http/observer-access.test.ts new file mode 100644 index 0000000000..fc4a9907c2 --- /dev/null +++ b/packages/domain/src/http/observer-access.test.ts @@ -0,0 +1,61 @@ +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 { MapleApiV2 } from "./v2/api" + +type Called = Parameters[0] +type Group = Called["group"] & { + readonly endpoints: { readonly [name: string]: Called["endpoint"] & { readonly identifier: string } } +} + +// @effect-diagnostics-next-line preferEffectRecord:off -- not a record type; Rec.values would need a cast +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 }), + ) + +const internal = MapleInternalApi.groups +const v2 = MapleApiV2.groups + +describe("isOpenToObservers", () => { + it("lets an observer 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 })), + ).toBe(true) + }) + + it("refuses changes to shared work: issue triage and dashboards", () => { + expect(open(internal.errors, "transitionIssue")).toBe(false) + expect(open(internal.errors, "commentOnIssue")).toBe(false) + expect(open(v2.dashboards, "create")).toBe(false) + expect(open(v2.dashboards, "upsertShare")).toBe(false) + }) + + 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( + true, + ) + }) + + it("allows a dry run that sits beside the writes it previews", () => { + expect(open(internal.errors, "evaluateEscalationPolicy")).toBe(true) + expect(open(internal.digest, "preview")).toBe(true) + }) + + it("lets an observer 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) + expect(open(v2.mcpConnections, "revoke")).toBe(true) + expect(open(MapleApi.groups.authSession, "cliDeviceApprove")).toBe(true) + }) +}) diff --git a/packages/domain/src/http/observer-access.ts b/packages/domain/src/http/observer-access.ts new file mode 100644 index 0000000000..ebcf0bde89 --- /dev/null +++ b/packages/domain/src/http/observer-access.ts @@ -0,0 +1,29 @@ +import { Context } from "effect" + +/** + * Why an observer, 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. + */ +export class ObserverAccess extends Context.Reference<"read" | "personal" | undefined>( + "@maple/http/ObserverAccess", + { defaultValue: () => undefined }, +) {} + +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: { + readonly endpoint: Annotated & { readonly method: string } + readonly group: Annotated +}): boolean => { + const { method } = options.endpoint + 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) + return access !== undefined +} diff --git a/packages/domain/src/http/query-engine.ts b/packages/domain/src/http/query-engine.ts index b362946620..d60d43dc93 100644 --- a/packages/domain/src/http/query-engine.ts +++ b/packages/domain/src/http/query-engine.ts @@ -44,6 +44,7 @@ import { PathsInclude, } from "@maple/query-model" import { WireRow } from "../wire" +import { ObserverAccess } from "./observer-access" /** * A timeseries bucket width. @@ -5068,5 +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 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 188938162c..bebe700627 100644 --- a/packages/domain/src/http/session-replay.ts +++ b/packages/domain/src/http/session-replay.ts @@ -6,6 +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" // Session replay schemas shared by the v2 contract and the internal replay group. @@ -169,4 +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") .annotate(AuditedRead, "session_replay.read") {} diff --git a/packages/domain/src/http/users.ts b/packages/domain/src/http/users.ts index 2276276e48..1deb76bd37 100644 --- a/packages/domain/src/http/users.ts +++ b/packages/domain/src/http/users.ts @@ -7,6 +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" // 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 @@ -458,4 +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") .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 195f3c55a0..78ce306610 100644 --- a/packages/domain/src/http/v2/agent-feedback.ts +++ b/packages/domain/src/http/v2/agent-feedback.ts @@ -12,6 +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" const isNotBlank = Schema.makeFilter((value: string) => Str.trim(value).length > 0, { description: "Must contain a non-whitespace character", @@ -134,14 +135,16 @@ export class V2AgentFeedbackApiGroup extends HttpApiGroup.make("agentFeedback") payload: V2AgentFeedbackCreateParams, success: V2AgentFeedback, error: [persistence], - }).annotateMerge( - OpenApi.annotations({ - identifier: "createAgentFeedback", - summary: "Send feedback", - description: - "Sends feedback about Maple from an agent to the Maple team: say what kind it is, which agent is sending it, and why. Needs no scope: any API key of the organization can send feedback.", - }), - ), + }) + .annotate(ObserverAccess, "personal") + .annotateMerge( + OpenApi.annotations({ + identifier: "createAgentFeedback", + summary: "Send feedback", + description: + "Sends feedback about Maple from an agent to the Maple team: say what kind it is, which agent is sending it, and why. Needs no scope: any API key of the organization can send feedback.", + }), + ), ) .add( HttpApiEndpoint.get("list", "/", { diff --git a/packages/domain/src/http/v2/auth.ts b/packages/domain/src/http/v2/auth.ts index 376558bde4..14d085c913 100644 --- a/packages/domain/src/http/v2/auth.ts +++ b/packages/domain/src/http/v2/auth.ts @@ -3,6 +3,7 @@ import { Schema, Array as Arr, String as Str } from "effect" import { ApiKeyLookupPersistenceError, type ApiKeyKind } from "../api-keys" import { AuthorizationUnavailableError, Context, UnauthorizedError } from "../current-tenant" import { + V2InsufficientPermissions, V2InsufficientScope, V2InvalidCredentials, V2InvalidDpopProof, @@ -34,6 +35,7 @@ export class AuthorizationV2 extends HttpApiMiddleware.Service< V2InvalidCredentials.schema, V2InvalidDpopProof.schema, V2InsufficientScope.schema, + V2InsufficientPermissions.schema, V2RateLimited.schema, V2OrganizationAccessDenied.schema, V2NoActiveOrganization.schema, diff --git a/packages/domain/src/http/v2/device-credentials.ts b/packages/domain/src/http/v2/device-credentials.ts index e032110215..9d50979fc4 100644 --- a/packages/domain/src/http/v2/device-credentials.ts +++ b/packages/domain/src/http/v2/device-credentials.ts @@ -18,6 +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" /** * Device-bound credentials (DPoP, RFC 9449) for the mobile Home Screen widgets. @@ -188,28 +189,32 @@ export class V2DeviceCredentialsApiGroup extends HttpApiGroup.make("deviceCreden sessionRequired, persistence, ], - }).annotateMerge( - OpenApi.annotations({ - identifier: "registerDeviceCredential", - summary: "Register this installation's device key", - description: - "Registers the public half of a device-held P-256 key for this installation and organization, revoking the caller's previous key for it in the same transaction. Registering the same key again refreshes it: the rolling 30-day expiry moves out (never past 90 days from creation) and the roles are re-pinned to the caller's. Signed-in sessions only (API keys and device tokens get `session_required`). The request must also carry a `DPoP` proof signed by the submitted key (`htm` `PUT`, `htu` this URL, no `ath`), proving the caller holds it. An installation another member holds a live credential for answers `device_installation_in_use`.", - }), - ), + }) + .annotate(ObserverAccess, "personal") + .annotateMerge( + OpenApi.annotations({ + identifier: "registerDeviceCredential", + summary: "Register this installation's device key", + description: + "Registers the public half of a device-held P-256 key for this installation and organization, revoking the caller's previous key for it in the same transaction. Registering the same key again refreshes it: the rolling 30-day expiry moves out (never past 90 days from creation) and the roles are re-pinned to the caller's. Signed-in sessions only (API keys and device tokens get `session_required`). The request must also carry a `DPoP` proof signed by the submitted key (`htm` `PUT`, `htu` this URL, no `ath`), proving the caller holds it. An installation another member holds a live credential for answers `device_installation_in_use`.", + }), + ), ) .add( HttpApiEndpoint.delete("revoke", "/:installation_id", { params: { installation_id: InstallationId }, success: V2DeviceCredentialDeleteResponse, error: [sessionRequired, persistence], - }).annotateMerge( - OpenApi.annotations({ - identifier: "revokeDeviceCredential", - summary: "Revoke this installation's device key", - description: - "Revokes the caller's own device credential for this installation and organization. Tokens already issued for it stop working on their next request. Idempotent. Signed-in sessions only; there is no path to revoke another member's device (removing the member does that).", - }), - ), + }) + .annotate(ObserverAccess, "personal") + .annotateMerge( + OpenApi.annotations({ + identifier: "revokeDeviceCredential", + summary: "Revoke this installation's device key", + description: + "Revokes the caller's own device credential for this installation and organization. Tokens already issued for it stop working on their next request. Idempotent. Signed-in sessions only; there is no path to revoke another member's device (removing the member does that).", + }), + ), ) .add( HttpApiEndpoint.get("attestationChallenge", "/:installation_id/attestation_challenge", { @@ -247,14 +252,16 @@ export class V2DeviceTokensApiGroup extends HttpApiGroup.make("deviceTokens") V2RateLimited.schema, publicError(DeviceCredentialPersistenceError), ], - }).annotateMerge( - OpenApi.annotations({ - identifier: "issueDeviceToken", - summary: "Issue a DPoP-bound access token", - description: - "Exchanges a `DPoP` proof (RFC 9449: `typ` `dpop+jwt`, `alg` `ES256`, the registered key as `jwk`, `htm` `POST`, `htu` this URL, a unique `jti` and an `iat` within 60 seconds) for a one-hour access token bound to that key. Takes no bearer credential: the proof is the credential. Fails with `invalid_device_credential` when the key is unknown, revoked or expired.", - }), - ), + }) + .annotate(ObserverAccess, "personal") + .annotateMerge( + OpenApi.annotations({ + identifier: "issueDeviceToken", + summary: "Issue a DPoP-bound access token", + description: + "Exchanges a `DPoP` proof (RFC 9449: `typ` `dpop+jwt`, `alg` `ES256`, the registered key as `jwk`, `htm` `POST`, `htu` this URL, a unique `jti` and an `iat` within 60 seconds) for a one-hour access token bound to that key. Takes no bearer credential: the proof is the credential. Fails with `invalid_device_credential` when the key is unknown, revoked or expired.", + }), + ), ) .prefix("/v2/device_tokens") .annotateMerge( diff --git a/packages/domain/src/http/v2/integrations-chat.ts b/packages/domain/src/http/v2/integrations-chat.ts index 793b977d68..3307d82d2c 100644 --- a/packages/domain/src/http/v2/integrations-chat.ts +++ b/packages/domain/src/http/v2/integrations-chat.ts @@ -14,6 +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" // 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 @@ -389,14 +390,16 @@ export class V2ChatIntegrationsApiGroup extends HttpApiGroup.make("chatIntegrati params: { connector: ChatConnectorId }, success: V2ChatIdentityDeleteResponse, error: [V2InsufficientPermissions.schema, chatNotFound, chatPersistence], - }).annotateMerge( - OpenApi.annotations({ - identifier: "deleteChatIdentity", - summary: "Unlink your chat account", - description: - "Removes the link between your chat account and your Maple user on this connector. Your organization's workspaces stay linked; only your own account is forgotten, and nothing you do on the chat platform is attributed to you afterwards. Unlinking when you hold no link is not an error. Like linking, it must be called with a signed-in session rather than an API key. Requires the `integrations:write` scope.", - }), - ), + }) + .annotate(ObserverAccess, "personal") + .annotateMerge( + OpenApi.annotations({ + identifier: "deleteChatIdentity", + summary: "Unlink your chat account", + description: + "Removes the link between your chat account and your Maple user on this connector. Your organization's workspaces stay linked; only your own account is forgotten, and nothing you do on the chat platform is attributed to you afterwards. Unlinking when you hold no link is not an error. Like linking, it must be called with a signed-in session rather than an API key. Requires the `integrations:write` scope.", + }), + ), ) .add( HttpApiEndpoint.patch("updateWorkspace", "/chat_workspaces/:id", { diff --git a/packages/domain/src/http/v2/mcp-connections.ts b/packages/domain/src/http/v2/mcp-connections.ts index 2aeee220f9..6da5af4814 100644 --- a/packages/domain/src/http/v2/mcp-connections.ts +++ b/packages/domain/src/http/v2/mcp-connections.ts @@ -7,6 +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" const mcpConnectionExample = { id: "mcpc_p12S8T8agEHUkYc825nMz6", @@ -80,14 +81,16 @@ export class V2McpConnectionsApiGroup extends HttpApiGroup.make("mcpConnections" params: { id: McpConnectionPublicId }, success: V2McpConnection, error: [notFound, persistence], - }).annotateMerge( - OpenApi.annotations({ - identifier: "revokeMcpConnection", - summary: "Revoke an MCP connection", - description: - "Ends the connection and every access token it minted, and returns the final object. The client has to be approved again to reconnect. Members may revoke their own connections; org admins may revoke any. Requires the `mcp_connections:write` scope.", - }), - ), + }) + .annotate(ObserverAccess, "personal") + .annotateMerge( + OpenApi.annotations({ + identifier: "revokeMcpConnection", + summary: "Revoke an MCP connection", + description: + "Ends the connection and every access token it minted, and returns the final object. The client has to be approved again to reconnect. Members may revoke their own connections; org admins may revoke any. Requires the `mcp_connections:write` scope.", + }), + ), ) .prefix("/v2/mcp_connections") .middleware(AuthorizationV2) diff --git a/packages/domain/src/http/v2/mobile-devices.ts b/packages/domain/src/http/v2/mobile-devices.ts index b69ac1e2a4..46fa78a8f7 100644 --- a/packages/domain/src/http/v2/mobile-devices.ts +++ b/packages/domain/src/http/v2/mobile-devices.ts @@ -11,6 +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" // 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). @@ -194,28 +195,32 @@ export class V2MobileDevicesApiGroup extends HttpApiGroup.make("mobileDevices") payload: V2MobileDeviceRegisterParams, success: V2MobileDevice, error: [persistence], - }).annotateMerge( - OpenApi.annotations({ - identifier: "registerMobileDevice", - summary: "Register or refresh a device", - description: - "Upserts the device identified by its push token for the current user and organization, re-enabling it if the platform had marked it dead. Requires the `mobile_devices:write` scope.", - }), - ), + }) + .annotate(ObserverAccess, "personal") + .annotateMerge( + OpenApi.annotations({ + identifier: "registerMobileDevice", + summary: "Register or refresh a device", + description: + "Upserts the device identified by its push token for the current user and organization, re-enabling it if the platform had marked it dead. Requires the `mobile_devices:write` scope.", + }), + ), ) .add( HttpApiEndpoint.delete("unregister", "/:token", { params: { token: DeviceToken }, success: V2MobileDeviceDeleteResponse, error: [notFound, persistence], - }).annotateMerge( - OpenApi.annotations({ - identifier: "unregisterMobileDevice", - summary: "Unregister a device", - description: - "Removes the device from the current organization; the app calls this on sign-out. Requires the `mobile_devices:write` scope.", - }), - ), + }) + .annotate(ObserverAccess, "personal") + .annotateMerge( + OpenApi.annotations({ + identifier: "unregisterMobileDevice", + summary: "Unregister a device", + description: + "Removes the device from the current organization; the app calls this on sign-out. Requires the `mobile_devices:write` scope.", + }), + ), ) .add( HttpApiEndpoint.put("registerLiveActivity", "/:token/live_activities/:incident_id", { @@ -223,28 +228,32 @@ export class V2MobileDevicesApiGroup extends HttpApiGroup.make("mobileDevices") payload: V2LiveActivityRegisterParams, success: V2LiveActivity, error: [notFound, persistence], - }).annotateMerge( - OpenApi.annotations({ - identifier: "registerLiveActivity", - summary: "Register a Live Activity update token", - description: - "Records the APNs update token for a Live Activity the device started for this incident, so Maple can update and end it. Requires the `mobile_devices:write` scope.", - }), - ), + }) + .annotate(ObserverAccess, "personal") + .annotateMerge( + OpenApi.annotations({ + identifier: "registerLiveActivity", + summary: "Register a Live Activity update token", + description: + "Records the APNs update token for a Live Activity the device started for this incident, so Maple can update and end it. Requires the `mobile_devices:write` scope.", + }), + ), ) .add( HttpApiEndpoint.delete("endLiveActivity", "/:token/live_activities/:incident_id", { params: { token: DeviceToken, incident_id: AlertIncidentPublicId }, success: V2LiveActivityDeleteResponse, error: [notFound, persistence], - }).annotateMerge( - OpenApi.annotations({ - identifier: "endLiveActivity", - summary: "Forget a Live Activity", - description: - "Called when the activity ended on the device — dismissed by the user or ended by the app — so Maple stops pushing to a dead token. Requires the `mobile_devices:write` scope.", - }), - ), + }) + .annotate(ObserverAccess, "personal") + .annotateMerge( + OpenApi.annotations({ + identifier: "endLiveActivity", + summary: "Forget a Live Activity", + description: + "Called when the activity ended on the device — dismissed by the user or ended by the app — so Maple stops pushing to a dead token. Requires the `mobile_devices:write` scope.", + }), + ), ) .prefix("/v2/mobile_devices") .middleware(AuthorizationV2) diff --git a/packages/domain/src/http/v2/widget-credentials.ts b/packages/domain/src/http/v2/widget-credentials.ts index 2c9bc656b2..dd1a91d729 100644 --- a/packages/domain/src/http/v2/widget-credentials.ts +++ b/packages/domain/src/http/v2/widget-credentials.ts @@ -5,6 +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" /** * The credential a device's Home Screen widgets fetch with. @@ -98,28 +99,32 @@ export class V2WidgetCredentialsApiGroup extends HttpApiGroup.make("widgetCreden params: { installation_id: InstallationId }, success: V2WidgetCredential, error: [publicError(ApiKeyPersistenceError)], - }).annotateMerge( - OpenApi.annotations({ - identifier: "mintWidgetCredential", - summary: "Mint this installation's widget credential", - description: - "Issues a read-only, expiring credential for this installation's Home Screen widgets, revoking whatever it had. Idempotent, so the app calls it again to roll. Requires the `widget_credentials:write` scope — which a widget credential does not have, so renewal always goes through a signed-in session.", - }), - ), + }) + .annotate(ObserverAccess, "personal") + .annotateMerge( + OpenApi.annotations({ + identifier: "mintWidgetCredential", + summary: "Mint this installation's widget credential", + description: + "Issues a read-only, expiring credential for this installation's Home Screen widgets, revoking whatever it had. Idempotent, so the app calls it again to roll. Requires the `widget_credentials:write` scope — which a widget credential does not have, so renewal always goes through a signed-in session.", + }), + ), ) .add( HttpApiEndpoint.delete("revoke", "/:installation_id", { params: { installation_id: InstallationId }, success: V2WidgetCredentialDeleteResponse, error: [publicError(ApiKeyPersistenceError)], - }).annotateMerge( - OpenApi.annotations({ - identifier: "revokeWidgetCredential", - summary: "Revoke this installation's widget credential", - description: - "Retires the installation's credential for this organization; the app calls this on sign-out and when the user leaves the organization. Idempotent — an installation with nothing to revoke is already in the requested state. Requires the `widget_credentials:write` scope.", - }), - ), + }) + .annotate(ObserverAccess, "personal") + .annotateMerge( + OpenApi.annotations({ + identifier: "revokeWidgetCredential", + summary: "Revoke this installation's widget credential", + description: + "Retires the installation's credential for this organization; the app calls this on sign-out and when the user leaves the organization. Idempotent — an installation with nothing to revoke is already in the requested state. Requires the `widget_credentials:write` scope.", + }), + ), ) .prefix("/v2/widget_credentials") .middleware(AuthorizationV2) diff --git a/packages/domain/src/policy/index.ts b/packages/domain/src/policy/index.ts index 1da45f857b..d31d176803 100644 --- a/packages/domain/src/policy/index.ts +++ b/packages/domain/src/policy/index.ts @@ -51,6 +51,13 @@ 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" + +/** What an observer 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." + /** Every permission the roles hold, for a page model to keep instead of role flags. */ export const grantsOf = (roles: ReadonlyArray): ReadonlyArray => Arr.filter(allPermissions, (permission) => can(roles, permission)) From c91c8fc98975783ff63c5f3fc7e52c950b047096 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 12:11:43 +0100 Subject: [PATCH 2/3] refactor(auth): rename the observer role to analyst --- CLAUDE.md | 4 +-- .../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 ++++++------ 33 files changed, 144 insertions(+), 144 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/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 f72b00b1d52b5dbfb422175f96f1e07a3971ed35 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 12:15:20 +0100 Subject: [PATCH 3/3] feat(auth): let analysts build and share dashboards Dashboard writes are marked AnalystAccess author, so the auth middlewares let an analyst through them and still refuse every other change. The refusal is now LimitedRoleError (limited_role), since the role is no longer read-only. --- CLAUDE.md | 7 +- .../api/src/routes/v2/dashboards.http.test.ts | 35 ++- .../src/services/auth/ApiAuthorizationLive.ts | 6 +- .../services/auth/ApiAuthorizationV2Live.ts | 8 +- .../services/auth/SessionAuthorizationLive.ts | 6 +- .../src/services/auth/analyst-gate.test.ts | 38 ++-- .../backend/src/services/auth/analyst-gate.ts | 21 +- .../anticipated-error-identifiers.ts | 2 +- .../domain/src/http/analyst-access.test.ts | 24 +- packages/domain/src/http/analyst-access.ts | 10 +- packages/domain/src/http/current-tenant.ts | 20 +- packages/domain/src/http/index.ts | 2 +- packages/domain/src/http/v2/dashboards.ts | 215 ++++++++++-------- packages/domain/src/policy/index.ts | 8 +- 14 files changed, 244 insertions(+), 158 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 42a0c57ba0..a3d988f3f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,9 +42,10 @@ 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. -- 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. +- Analysts (`org:analyst`) read everything and build dashboards. The auth middlewares refuse them every + other 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, `author` for dashboards. An + unmarked read-only POST is a 403 for them. ### Service conventions diff --git a/apps/api/src/routes/v2/dashboards.http.test.ts b/apps/api/src/routes/v2/dashboards.http.test.ts index 4a60f5b6ca..e176ce375c 100644 --- a/apps/api/src/routes/v2/dashboards.http.test.ts +++ b/apps/api/src/routes/v2/dashboards.http.test.ts @@ -171,11 +171,18 @@ const makeHarness = () => { const ORG = Schema.decodeSync(OrgId)("org_dashboard_e2e") const USER = Schema.decodeSync(UserId)("user_dashboard_e2e") - const bootstrapKey = (scopes?: ReadonlyArray) => + // `roles` makes it a key minted for a person, which acts with that person's org roles. + const bootstrapKey = (scopes?: ReadonlyArray, roles?: ReadonlyArray) => runtime.runPromise( Effect.gen(function* () { const service = yield* ApiKeysService - return yield* service.create(ORG, USER, { name: "dashboard-test", scopes }) + return yield* service.create(ORG, USER, { + name: "dashboard-test", + scopes, + ...(roles === undefined + ? undefined + : { metadataJson: { source: "maple_cli", roles, deviceName: "laptop" } }), + }) }), ) @@ -499,6 +506,30 @@ describe("v2 dashboards over HTTP", () => { await harness.dispose() }) + it("lets an analyst build, publish and delete a dashboard", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey(undefined, ["org:analyst"]) + + const created = await harness.request("POST", "/v2/dashboards", key.secret, { + name: "Signup funnel", + widgets: [], + }) + expect(created.status).toBe(200) + const path = `/v2/dashboards/${created.body.id}` + + const renamed = await harness.request("PATCH", path, key.secret, { name: "Signup funnel, weekly" }) + expect(renamed.status).toBe(200) + expect(renamed.body.name).toBe("Signup funnel, weekly") + + const shared = await harness.request("PUT", `${path}/share`, key.secret, { mode: "public" }) + expect(shared.status).toBe(200) + expect(typeof shared.body.token).toBe("string") + + expect((await harness.request("DELETE", path, key.secret)).status).toBe(200) + expect((await harness.request("GET", path, key.secret)).status).toBe(404) + await harness.dispose() + }) + // The picker asks for every template in one page; `limit` defaulting to 20 // silently truncated the catalogue and hid the last two templates. it("returns the whole template catalogue with structured requirements", async () => { diff --git a/packages/backend/src/services/auth/ApiAuthorizationLive.ts b/packages/backend/src/services/auth/ApiAuthorizationLive.ts index 11c7b604a7..d2c138d1b0 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 { refuseAnalystWrites } from "./analyst-gate" +import { checkAnalystAccess } 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* refuseAnalystWrites(tenant.roles, options).pipe( + yield* checkAnalystAccess(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* refuseAnalystWrites(tenant.roles, options) + yield* checkAnalystAccess(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 aa2c1e8b79..75a71d828a 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 { refuseAnalystWritesV2 } from "./analyst-gate" +import { checkAnalystAccessV2 } 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* refuseAnalystWritesV2(tenant.roles, options, route.path) + yield* checkAnalystAccessV2(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* refuseAnalystWritesV2(tenant.roles, options, route.path).pipe( + yield* checkAnalystAccessV2(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* refuseAnalystWritesV2(tenant.roles, options, route.path) + yield* checkAnalystAccessV2(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 3aa7548927..003c154e4d 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 { refuseAnalystWrites } from "./analyst-gate" +import { checkAnalystAccess } 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* refuseAnalystWrites(tenant.roles, options) + yield* checkAnalystAccess(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* refuseAnalystWrites(tenant.roles, options) + yield* checkAnalystAccess(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/analyst-gate.test.ts b/packages/backend/src/services/auth/analyst-gate.test.ts index abc15270e9..d48c04da5f 100644 --- a/packages/backend/src/services/auth/analyst-gate.test.ts +++ b/packages/backend/src/services/auth/analyst-gate.test.ts @@ -2,7 +2,7 @@ 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 { refuseAnalystWrites, refuseAnalystWritesV2 } from "./analyst-gate" +import { checkAnalystAccess, checkAnalystAccessV2 } from "./analyst-gate" const ANALYST = ["org:analyst"] const MEMBER = ["org:member"] @@ -12,49 +12,61 @@ const transition = { group: errors, endpoint: errors.endpoints.transitionIssue } const queries = MapleInternalApi.groups.queryEngine const traces = MapleApiV2.groups.traces const dashboards = MapleApiV2.groups.dashboards +const slos = MapleApiV2.groups.slos /** Whether the gate let the call through. */ const allowed = (gate: Effect.Effect) => Effect.map(Effect.exit(gate), Exit.isSuccess) -describe("refuseAnalystWrites", () => { +describe("checkAnalystAccess", () => { it.effect("refuses an analyst an issue transition and says how to get it", () => Effect.gen(function* () { - const error = yield* Effect.flip(refuseAnalystWrites(ANALYST, transition)) - assert.instanceOf(error, CurrentTenant.ReadOnlyRoleError) + const error = yield* Effect.flip(checkAnalystAccess(ANALYST, transition)) + assert.instanceOf(error, CurrentTenant.LimitedRoleError) expect(error.message).toContain("Ask an org admin for the Member role") }), ) it.effect("lets a member, and Maple's own agents with no roles, make the same change", () => Effect.gen(function* () { - expect(yield* allowed(refuseAnalystWrites(MEMBER, transition))).toBe(true) - expect(yield* allowed(refuseAnalystWrites([], transition))).toBe(true) + expect(yield* allowed(checkAnalystAccess(MEMBER, transition))).toBe(true) + expect(yield* allowed(checkAnalystAccess([], transition))).toBe(true) }), ) it.effect("lets an analyst run a dashboard query", () => Effect.gen(function* () { const query = { group: queries, endpoint: queries.endpoints.executeBatch } - expect(yield* allowed(refuseAnalystWrites(ANALYST, query))).toBe(true) + expect(yield* allowed(checkAnalystAccess(ANALYST, query))).toBe(true) }), ) }) -describe("refuseAnalystWritesV2", () => { +describe("checkAnalystAccessV2", () => { const create = { group: dashboards, endpoint: dashboards.endpoints.create } 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(refuseAnalystWritesV2(ANALYST, search, "/v2/traces/search"))).toBe(true) + expect(yield* allowed(checkAnalystAccessV2(ANALYST, search, "/v2/traces/search"))).toBe(true) }), ) - it.effect("refuses an analyst a new dashboard and lets a member create one", () => + it.effect("lets an analyst create a dashboard and publish a share link for it", () => Effect.gen(function* () { - 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) + const share = { group: dashboards, endpoint: dashboards.endpoints.upsertShare } + expect(yield* allowed(checkAnalystAccessV2(ANALYST, create, "/v2/dashboards"))).toBe(true) + expect(yield* allowed(checkAnalystAccessV2(ANALYST, share, "/v2/dashboards/:id/share"))).toBe( + true, + ) + }), + ) + + it.effect("refuses an analyst a new SLO and lets a member create one", () => + Effect.gen(function* () { + const slo = { group: slos, endpoint: slos.endpoints.create } + const error = yield* Effect.flip(checkAnalystAccessV2(ANALYST, slo, "/v2/slos")) + expect(error.message).toContain("Analysts can read Maple and build dashboards") + expect(yield* allowed(checkAnalystAccessV2(MEMBER, slo, "/v2/slos"))).toBe(true) }), ) }) diff --git a/packages/backend/src/services/auth/analyst-gate.ts b/packages/backend/src/services/auth/analyst-gate.ts index a3afc86ef6..eeddbd6ca8 100644 --- a/packages/backend/src/services/auth/analyst-gate.ts +++ b/packages/backend/src/services/auth/analyst-gate.ts @@ -6,29 +6,28 @@ import { Effect } from "effect" 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 - * `AnalystAccess`. + * Fails for an analyst on every endpoint that operates the organization. It runs in the auth layers, + * so an endpoint added later is refused until it, or its whole group, is marked `AnalystAccess`. */ -export const refuseAnalystWrites = ( +export const checkAnalystAccess = ( roles: ReadonlyArray, called: Called, -): Effect.Effect => - Policy.isReadOnly(roles) && !isOpenToAnalysts(called) - ? Effect.fail(new CurrentTenant.ReadOnlyRoleError({ message: Policy.readOnlyMessage })) +): Effect.Effect => + Policy.isAnalyst(roles) && !isOpenToAnalysts(called) + ? Effect.fail(new CurrentTenant.LimitedRoleError({ message: Policy.analystMessage })) : Effect.void /** - * {@link refuseAnalystWrites} for `/v2`, where the scope registry already names the POSTs that + * {@link checkAnalystAccess} 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 refuseAnalystWritesV2 = ( +export const checkAnalystAccessV2 = ( roles: ReadonlyArray, called: Called, routePath: string, ): Effect.Effect> => - Policy.isReadOnly(roles) && + Policy.isAnalyst(roles) && !isOpenToAnalysts(called) && requiredScopeForRoute(called.endpoint.method, routePath)?.access !== "read" - ? Effect.fail(V2InsufficientPermissions.make(Policy.readOnlyMessage)) + ? Effect.fail(V2InsufficientPermissions.make(Policy.analystMessage)) : Effect.void diff --git a/packages/domain/src/generated/anticipated-error-identifiers.ts b/packages/domain/src/generated/anticipated-error-identifiers.ts index a1ef0e1e27..ccbd3a0471 100644 --- a/packages/domain/src/generated/anticipated-error-identifiers.ts +++ b/packages/domain/src/generated/anticipated-error-identifiers.ts @@ -92,6 +92,7 @@ export const ANTICIPATED_ERROR_IDENTIFIER_LIST: ReadonlyArray = [ "@maple/http/errors/IntegrationsPlanRequiredError", "@maple/http/errors/IntegrationsRevokedError", "@maple/http/errors/IntegrationsValidationError", + "@maple/http/errors/LimitedRoleError", "@maple/http/errors/McpConnectionNotFoundError", "@maple/http/errors/McpOAuthAuthorizationConflictError", "@maple/http/errors/McpOAuthAuthorizationExpiredError", @@ -111,7 +112,6 @@ export const ANTICIPATED_ERROR_IDENTIFIER_LIST: ReadonlyArray = [ "@maple/http/errors/PreviewSignInInvalidKeyError", "@maple/http/errors/QueryEngineValidationError", "@maple/http/errors/RawSqlValidationError", - "@maple/http/errors/ReadOnlyRoleError", "@maple/http/errors/RecommendationIssueNotFoundError", "@maple/http/errors/ScrapeTargetLimitReachedError", "@maple/http/errors/ScrapeTargetNotFoundError", diff --git a/packages/domain/src/http/analyst-access.test.ts b/packages/domain/src/http/analyst-access.test.ts index cb31658008..48548a5498 100644 --- a/packages/domain/src/http/analyst-access.test.ts +++ b/packages/domain/src/http/analyst-access.test.ts @@ -31,11 +31,29 @@ describe("isOpenToAnalysts", () => { ).toBe(true) }) - it("refuses changes to shared work: issue triage and dashboards", () => { + it("refuses the work of running the product: issue triage, SLOs and alert rules", () => { expect(open(internal.errors, "transitionIssue")).toBe(false) expect(open(internal.errors, "commentOnIssue")).toBe(false) - expect(open(v2.dashboards, "create")).toBe(false) - expect(open(v2.dashboards, "upsertShare")).toBe(false) + expect(open(v2.slos, "create")).toBe(false) + expect(open(v2.alertRules, "create")).toBe(false) + }) + + it("lets an analyst change dashboards in every way a member can", () => { + const writes = [ + "create", + "importPerses", + "instantiateTemplate", + "update", + "delete", + "restoreVersion", + "upsertShare", + "rotateShare", + "revokeShare", + "upsertWidgetShare", + "rotateWidgetShare", + "revokeWidgetShare", + ] + expect(Arr.filter(writes, (name) => !open(v2.dashboards, name))).toEqual([]) }) it("keeps every GET of a group that also changes things", () => { diff --git a/packages/domain/src/http/analyst-access.ts b/packages/domain/src/http/analyst-access.ts index 8b7a4159bd..7b760b179c 100644 --- a/packages/domain/src/http/analyst-access.ts +++ b/packages/domain/src/http/analyst-access.ts @@ -1,11 +1,11 @@ import { Context } from "effect" /** - * 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 analyst. + * Why an analyst may call an endpoint that is not a GET: `read` only looks (a query with a body), + * `personal` changes the caller's own state, `author` builds what analysts own, dashboards. Unset + * means the endpoint operates the organization, and the auth middlewares refuse it to an analyst. */ -export class AnalystAccess extends Context.Reference<"read" | "personal" | undefined>( +export class AnalystAccess extends Context.Reference<"read" | "personal" | "author" | undefined>( "@maple/http/AnalystAccess", { defaultValue: () => undefined }, ) {} @@ -14,7 +14,7 @@ interface Annotated { readonly annotations: Context.Context } -/** Whether a read-only role may call the endpoint: a GET, or one marked {@link AnalystAccess}. */ +/** Whether an analyst may call the endpoint: a GET, or one marked {@link AnalystAccess}. */ export const isOpenToAnalysts = (options: { readonly endpoint: Annotated & { readonly method: string } readonly group: Annotated diff --git a/packages/domain/src/http/current-tenant.ts b/packages/domain/src/http/current-tenant.ts index c76c0bdfb3..b21a3f23ea 100644 --- a/packages/domain/src/http/current-tenant.ts +++ b/packages/domain/src/http/current-tenant.ts @@ -3,7 +3,7 @@ import { Schema, Context as EffectContext } from "effect" import { type ActorId, AuthMode, OrgId, RoleName, UserId } from "@maple/primitives" import type { ChatTurnOrigin } from "../chat-session" import { MapleRegion } from "../organization-regions" -import { readOnlyMessage } from "../policy" +import { analystMessage } from "../policy" import { HttpTaggedError } from "./error-policy" export class UnauthorizedError extends HttpTaggedError()( @@ -91,15 +91,15 @@ export class OrganizationWrongRegionError extends HttpTaggedError()( - "@maple/http/errors/ReadOnlyRoleError", +/** An analyst asked to operate the organization. 403: the session is fine, the role does not reach it. */ +export class LimitedRoleError extends HttpTaggedError()( + "@maple/http/errors/LimitedRoleError", { message: Schema.String }, { status: 403, - code: "read_only_role", - title: "Read-only access", - message: readOnlyMessage, + code: "limited_role", + title: "Limited role", + message: analystMessage, retry: "never", recovery: "request_access", exposure: "public_message", @@ -151,7 +151,7 @@ export class Authorization extends HttpApiMiddleware.Service< UnauthorizedError, AuthorizationUnavailableError, OrganizationAccessDeniedError, - ReadOnlyRoleError, + LimitedRoleError, OrganizationWrongRegionError, ], security: { @@ -201,7 +201,7 @@ export class SessionAuthorization extends HttpApiMiddleware.Service< AuthorizationUnavailableError, ApiKeyNotAcceptedError, OrganizationAccessDeniedError, - ReadOnlyRoleError, + LimitedRoleError, OrganizationWrongRegionError, ], security: { @@ -246,7 +246,7 @@ export class RegionlessSessionAuthorization extends HttpApiMiddleware.Service< AuthorizationUnavailableError, ApiKeyNotAcceptedError, OrganizationAccessDeniedError, - ReadOnlyRoleError, + LimitedRoleError, ], security: { bearer: HttpApiSecurity.bearer, diff --git a/packages/domain/src/http/index.ts b/packages/domain/src/http/index.ts index 491c80a7a0..0ec2620b07 100644 --- a/packages/domain/src/http/index.ts +++ b/packages/domain/src/http/index.ts @@ -43,7 +43,7 @@ export { AuthorizationUnavailableError, OrganizationAccessDeniedError, OrganizationWrongRegionError, - ReadOnlyRoleError, + LimitedRoleError, type TenantContext, } from "./current-tenant" export * from "./dashboard-sections" diff --git a/packages/domain/src/http/v2/dashboards.ts b/packages/domain/src/http/v2/dashboards.ts index c30028f28b..bd65d03d18 100644 --- a/packages/domain/src/http/v2/dashboards.ts +++ b/packages/domain/src/http/v2/dashboards.ts @@ -50,6 +50,7 @@ import { ShareWidgetNotFoundError, } from "../share" import { WireValue } from "../../wire" +import { AnalystAccess } from "../analyst-access" import { AuditedRead } from "../audit-log" import { AuthorizationV2 } from "./auth" import { ListOf, ListQuery, Timestamp } from "./envelopes" @@ -529,28 +530,32 @@ export class V2DashboardsApiGroup extends HttpApiGroup.make("dashboards") payload: V2DashboardCreateParams, success: V2DashboardMutation, error: dashboardCreateErrors, - }).annotateMerge( - OpenApi.annotations({ - identifier: "createDashboard", - summary: "Create a dashboard", - description: - "Creates a dashboard and returns the committed object, with optional ElectricSQL reconciliation metadata when available.", - }), - ), + }) + .annotate(AnalystAccess, "author") + .annotateMerge( + OpenApi.annotations({ + identifier: "createDashboard", + summary: "Create a dashboard", + description: + "Creates a dashboard and returns the committed object, with optional ElectricSQL reconciliation metadata when available.", + }), + ), ) .add( HttpApiEndpoint.post("importPerses", "/import/perses", { payload: V2DashboardPersesImportParams, success: V2DashboardPersesImportResponse, error: dashboardCreateErrors, - }).annotateMerge( - OpenApi.annotations({ - identifier: "importPersesDashboard", - summary: "Import a Perses dashboard", - description: - "Converts a Perses dashboard into Maple's dashboard model and returns any conversion warnings.", - }), - ), + }) + .annotate(AnalystAccess, "author") + .annotateMerge( + OpenApi.annotations({ + identifier: "importPersesDashboard", + summary: "Import a Perses dashboard", + description: + "Converts a Perses dashboard into Maple's dashboard model and returns any conversion warnings.", + }), + ), ) .add( HttpApiEndpoint.get("listTemplates", "/templates", { @@ -592,14 +597,16 @@ export class V2DashboardsApiGroup extends HttpApiGroup.make("dashboards") dashboardTemplateNotFound, ...dashboardCreateErrors, ], - }).annotateMerge( - OpenApi.annotations({ - identifier: "instantiateDashboardTemplate", - summary: "Instantiate a dashboard template", - description: - "Builds and persists a new dashboard from a template and its required parameters.", - }), - ), + }) + .annotate(AnalystAccess, "author") + .annotateMerge( + OpenApi.annotations({ + identifier: "instantiateDashboardTemplate", + summary: "Instantiate a dashboard template", + description: + "Builds and persists a new dashboard from a template and its required parameters.", + }), + ), ) .add( HttpApiEndpoint.get("retrieve", "/:id", { @@ -642,28 +649,32 @@ export class V2DashboardsApiGroup extends HttpApiGroup.make("dashboards") payload: V2DashboardUpdateParams, success: V2DashboardMutation, error: [dashboardNotFound, ...dashboardUpdateErrors], - }).annotateMerge( - OpenApi.annotations({ - identifier: "updateDashboard", - summary: "Update a dashboard", - description: - "Applies a partial JSON update; omitted fields retain their current values. Send `expected_version` to fail with 409 rather than overwrite a newer change.", - }), - ), + }) + .annotate(AnalystAccess, "author") + .annotateMerge( + OpenApi.annotations({ + identifier: "updateDashboard", + summary: "Update a dashboard", + description: + "Applies a partial JSON update; omitted fields retain their current values. Send `expected_version` to fail with 409 rather than overwrite a newer change.", + }), + ), ) .add( HttpApiEndpoint.delete("delete", "/:id", { params: { id: DashboardPublicId }, success: V2DashboardDeleteResponse, error: [dashboardPersistence, dashboardNotFound], - }).annotateMerge( - OpenApi.annotations({ - identifier: "deleteDashboard", - summary: "Delete a dashboard", - description: - "Permanently deletes a dashboard and returns a tombstone, with optional ElectricSQL reconciliation metadata when available.", - }), - ), + }) + .annotate(AnalystAccess, "author") + .annotateMerge( + OpenApi.annotations({ + identifier: "deleteDashboard", + summary: "Delete a dashboard", + description: + "Permanently deletes a dashboard and returns a tombstone, with optional ElectricSQL reconciliation metadata when available.", + }), + ), ) .add( HttpApiEndpoint.get("listVersions", "/:id/versions", { @@ -707,13 +718,15 @@ export class V2DashboardsApiGroup extends HttpApiGroup.make("dashboards") params: { id: DashboardPublicId, version_id: DashboardVersionPublicId }, success: V2DashboardMutation, error: [dashboardNotFound, dashboardVersionNotFound, ...dashboardUpdateErrors], - }).annotateMerge( - OpenApi.annotations({ - identifier: "restoreDashboardVersion", - summary: "Restore a dashboard version", - description: "Restores a historical snapshot as the dashboard's new current version.", - }), - ), + }) + .annotate(AnalystAccess, "author") + .annotateMerge( + OpenApi.annotations({ + identifier: "restoreDashboardVersion", + summary: "Restore a dashboard version", + description: "Restores a historical snapshot as the dashboard's new current version.", + }), + ), ) .add( HttpApiEndpoint.get("listShares", "/:id/shares", { @@ -757,14 +770,16 @@ export class V2DashboardsApiGroup extends HttpApiGroup.make("dashboards") dashboardStoredConfigInvalid, shareWidgetNotFound, ], - }).annotateMerge( - OpenApi.annotations({ - identifier: "upsertDashboardWidgetShare", - summary: "Share a single widget", - description: - "Creates a share link scoped to one widget, or changes the mode of the one it already has. The link only resolves while the dashboard itself is shared, and never grants more than the dashboard's own mode: unsharing the board disables it, sharing the board again restores it. A `public` widget share on a `public` dashboard may be embedded in an iframe.", - }), - ), + }) + .annotate(AnalystAccess, "author") + .annotateMerge( + OpenApi.annotations({ + identifier: "upsertDashboardWidgetShare", + summary: "Share a single widget", + description: + "Creates a share link scoped to one widget, or changes the mode of the one it already has. The link only resolves while the dashboard itself is shared, and never grants more than the dashboard's own mode: unsharing the board disables it, sharing the board again restores it. A `public` widget share on a `public` dashboard may be embedded in an iframe.", + }), + ), ) .add( HttpApiEndpoint.post("rotateWidgetShare", "/:id/widgets/:widget_id/share/rotate", { @@ -779,28 +794,32 @@ export class V2DashboardsApiGroup extends HttpApiGroup.make("dashboards") dashboardNotFound, dashboardStoredConfigInvalid, ], - }).annotateMerge( - OpenApi.annotations({ - identifier: "rotateDashboardWidgetShare", - summary: "Regenerate a widget's share link", - description: - "Revokes the widget's current link and mints a replacement. Any embed using the old URL stops rendering immediately.", - }), - ), + }) + .annotate(AnalystAccess, "author") + .annotateMerge( + OpenApi.annotations({ + identifier: "rotateDashboardWidgetShare", + summary: "Regenerate a widget's share link", + description: + "Revokes the widget's current link and mints a replacement. Any embed using the old URL stops rendering immediately.", + }), + ), ) .add( HttpApiEndpoint.delete("revokeWidgetShare", "/:id/widgets/:widget_id/share", { params: { id: DashboardPublicId, widget_id: Schema.String }, success: V2DashboardShareDeleteResponse, error: [sharePersistence, dashboardPersistence, dashboardNotFound, dashboardStoredConfigInvalid], - }).annotateMerge( - OpenApi.annotations({ - identifier: "revokeDashboardWidgetShare", - summary: "Stop sharing a widget", - description: - "Revokes the widget's share link. Idempotent — revoking an unshared widget succeeds and reports `deleted: true`.", - }), - ), + }) + .annotate(AnalystAccess, "author") + .annotateMerge( + OpenApi.annotations({ + identifier: "revokeDashboardWidgetShare", + summary: "Stop sharing a widget", + description: + "Revokes the widget's share link. Idempotent — revoking an unshared widget succeeds and reports `deleted: true`.", + }), + ), ) .add( HttpApiEndpoint.get("retrieveShare", "/:id/share", { @@ -829,14 +848,16 @@ export class V2DashboardsApiGroup extends HttpApiGroup.make("dashboards") dashboardNotFound, dashboardStoredConfigInvalid, ], - }).annotateMerge( - OpenApi.annotations({ - identifier: "upsertDashboardShare", - summary: "Share a dashboard", - description: - "Creates the dashboard's share link, or changes the mode of the one it already has. Changing the mode keeps the same link, so the returned token is unchanged.", - }), - ), + }) + .annotate(AnalystAccess, "author") + .annotateMerge( + OpenApi.annotations({ + identifier: "upsertDashboardShare", + summary: "Share a dashboard", + description: + "Creates the dashboard's share link, or changes the mode of the one it already has. Changing the mode keeps the same link, so the returned token is unchanged.", + }), + ), ) .add( HttpApiEndpoint.post("rotateShare", "/:id/share/rotate", { @@ -851,28 +872,32 @@ export class V2DashboardsApiGroup extends HttpApiGroup.make("dashboards") dashboardNotFound, dashboardStoredConfigInvalid, ], - }).annotateMerge( - OpenApi.annotations({ - identifier: "rotateDashboardShare", - summary: "Regenerate a dashboard's share link", - description: - "Revokes the current link and mints a replacement in one step. The previous URL stops resolving immediately.", - }), - ), + }) + .annotate(AnalystAccess, "author") + .annotateMerge( + OpenApi.annotations({ + identifier: "rotateDashboardShare", + summary: "Regenerate a dashboard's share link", + description: + "Revokes the current link and mints a replacement in one step. The previous URL stops resolving immediately.", + }), + ), ) .add( HttpApiEndpoint.delete("revokeShare", "/:id/share", { params: { id: DashboardPublicId }, success: V2DashboardShareDeleteResponse, error: [sharePersistence, dashboardPersistence, dashboardNotFound, dashboardStoredConfigInvalid], - }).annotateMerge( - OpenApi.annotations({ - identifier: "revokeDashboardShare", - summary: "Stop sharing a dashboard", - description: - "Revokes the dashboard's share link. Idempotent — revoking an unshared dashboard succeeds and reports `deleted: true`.", - }), - ), + }) + .annotate(AnalystAccess, "author") + .annotateMerge( + OpenApi.annotations({ + identifier: "revokeDashboardShare", + summary: "Stop sharing a dashboard", + description: + "Revokes the dashboard's share link. Idempotent — revoking an unshared dashboard succeeds and reports `deleted: true`.", + }), + ), ) .prefix("/v2/dashboards") .middleware(AuthorizationV2) diff --git a/packages/domain/src/policy/index.ts b/packages/domain/src/policy/index.ts index d80afc066f..2ec42b59e5 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 analyst, with no higher role beside it. */ -export const isReadOnly = (roles: ReadonlyArray): boolean => highestRole(roles) === "analyst" +/** Whether the roles are an analyst's, with no higher role beside it: they read, and build dashboards. */ +export const isAnalyst = (roles: ReadonlyArray): boolean => highestRole(roles) === "analyst" /** What an analyst is told when a change is refused, on every surface. */ -export const readOnlyMessage = - "Analysts can read Maple but not change it. Ask an org admin for the Member role to make changes." +export const analystMessage = + "Analysts can read Maple and build dashboards, but not change anything else. Ask an org admin for the Member role to do more." /** Every permission the roles hold, for a page model to keep instead of role flags. */ export const grantsOf = (roles: ReadonlyArray): ReadonlyArray =>