diff --git a/CLAUDE.md b/CLAUDE.md index 189c1c0d4b..a3d988f3f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +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`) 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/apps/api/src/routes/v2/mobile-devices.http.test.ts b/apps/api/src/routes/v2/mobile-devices.http.test.ts index e7728dd97a..08fc58a2b4 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 analyst register and remove their own device", async () => { + const harness = makeHarness() + try { + const key = await harness.bootstrapKey(undefined, ["org:analyst"]) + 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..f863cf37f8 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 analyst read an issue and refuses to let them triage it", async () => { + const harness = makeHarness() + 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) + + 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("Analysts 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..92fdacb02b 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 analyst search traces, a POST that only reads", async () => { + const harness = makeHarness() + 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, + }) + 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..d2c138d1b0 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 { checkAnalystAccess } from "./analyst-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* checkAnalystAccess(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* 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 ed35cc5578..75a71d828a 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 { checkAnalystAccessV2 } from "./analyst-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* checkAnalystAccessV2(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* checkAnalystAccessV2(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* 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 db5a65b60c..003c154e4d 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 { checkAnalystAccess } from "./analyst-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* checkAnalystAccess(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* 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 new file mode 100644 index 0000000000..d48c04da5f --- /dev/null +++ b/packages/backend/src/services/auth/analyst-gate.test.ts @@ -0,0 +1,72 @@ +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 { checkAnalystAccess, checkAnalystAccessV2 } from "./analyst-gate" + +const ANALYST = ["org:analyst"] +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 +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("checkAnalystAccess", () => { + it.effect("refuses an analyst an issue transition and says how to get it", () => + Effect.gen(function* () { + 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(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(checkAnalystAccess(ANALYST, query))).toBe(true) + }), + ) +}) + +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(checkAnalystAccessV2(ANALYST, search, "/v2/traces/search"))).toBe(true) + }), + ) + + it.effect("lets an analyst create a dashboard and publish a share link for it", () => + Effect.gen(function* () { + 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 new file mode 100644 index 0000000000..eeddbd6ca8 --- /dev/null +++ b/packages/backend/src/services/auth/analyst-gate.ts @@ -0,0 +1,33 @@ +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] + +/** + * 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 checkAnalystAccess = ( + roles: ReadonlyArray, + called: Called, +): Effect.Effect => + Policy.isAnalyst(roles) && !isOpenToAnalysts(called) + ? Effect.fail(new CurrentTenant.LimitedRoleError({ message: Policy.analystMessage })) + : Effect.void + +/** + * {@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 checkAnalystAccessV2 = ( + roles: ReadonlyArray, + called: Called, + routePath: string, +): Effect.Effect> => + Policy.isAnalyst(roles) && + !isOpenToAnalysts(called) && + requiredScopeForRoute(called.endpoint.method, routePath)?.access !== "read" + ? 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 9099172dd0..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", diff --git a/packages/domain/src/http/ai-models.ts b/packages/domain/src/http/ai-models.ts index 7b6acc8097..555f03a866 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 { 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 @@ -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 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 8b3ef7dd3b..92357cff53 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 { 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 @@ -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 analyst may call it. + .annotate(AnalystAccess, "read") {} diff --git a/packages/domain/src/http/analyst-access.test.ts b/packages/domain/src/http/analyst-access.test.ts new file mode 100644 index 0000000000..48548a5498 --- /dev/null +++ b/packages/domain/src/http/analyst-access.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "@effect/vitest" +import { Array as Arr } from "effect" +import { MapleApi } from "./api" +import { MapleInternalApi } from "./internal-api" +import { isOpenToAnalysts } from "./analyst-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 && isOpenToAnalysts({ group, endpoint }), + ) + +const internal = MapleInternalApi.groups +const v2 = MapleApiV2.groups + +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) => isOpenToAnalysts({ group: internal.queryEngine, endpoint })), + ).toBe(true) + }) + + 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.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", () => { + const reads = Arr.filter(endpointsOf(internal.errors), (endpoint) => endpoint.method === "GET") + expect(reads.length).toBeGreaterThan(0) + expect(Arr.every(reads, (endpoint) => isOpenToAnalysts({ 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 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) + expect(open(v2.mcpConnections, "revoke")).toBe(true) + expect(open(MapleApi.groups.authSession, "cliDeviceApprove")).toBe(true) + }) +}) diff --git a/packages/domain/src/http/analyst-access.ts b/packages/domain/src/http/analyst-access.ts new file mode 100644 index 0000000000..7b760b179c --- /dev/null +++ b/packages/domain/src/http/analyst-access.ts @@ -0,0 +1,29 @@ +import { Context } from "effect" + +/** + * 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" | "author" | undefined>( + "@maple/http/AnalystAccess", + { defaultValue: () => undefined }, +) {} + +interface Annotated { + readonly annotations: Context.Context +} + +/** 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 +}): 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, 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 45709c81e2..f2c40962ce 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 { AnalystAccess } from "./analyst-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(AnalystAccess, "personal"), ) .add( HttpApiEndpoint.delete("cliSessionRevoke", "/cli/session", { success: CliDeviceActionResponse, error: [CliDeviceConflictError, CliDevicePersistenceError], - }), + }).annotate(AnalystAccess, "personal"), ) .prefix("/api/auth") .middleware(Authorization) {} @@ -297,7 +298,7 @@ export class AuthSessionApiGroup extends HttpApiGroup.make("authSession") CliDeviceConflictError, CliDevicePersistenceError, ], - }), + }).annotate(AnalystAccess, "personal"), ) .add( HttpApiEndpoint.post("cliDeviceDeny", "/cli/device/:userCode/deny", { @@ -309,7 +310,7 @@ export class AuthSessionApiGroup extends HttpApiGroup.make("authSession") CliDeviceConflictError, CliDevicePersistenceError, ], - }), + }).annotate(AnalystAccess, "personal"), ) .add( HttpApiEndpoint.get("mcpOAuthAuthorizationInspect", "/mcp/oauth/authorization/:requestId", { @@ -332,7 +333,7 @@ export class AuthSessionApiGroup extends HttpApiGroup.make("authSession") McpOAuthAuthorizationConflictError, McpOAuthPersistenceError, ], - }), + }).annotate(AnalystAccess, "personal"), ) .add( HttpApiEndpoint.post("mcpOAuthAuthorizationDeny", "/mcp/oauth/authorization/:requestId/deny", { @@ -344,7 +345,7 @@ export class AuthSessionApiGroup extends HttpApiGroup.make("authSession") McpOAuthAuthorizationConflictError, McpOAuthPersistenceError, ], - }), + }).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 7eb1fb1f1f..ae110490ae 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 { AnalystAccess } from "./analyst-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(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 2ef661fde8..b21a3f23ea 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 { analystMessage } from "../policy" import { HttpTaggedError } from "./error-policy" export class UnauthorizedError extends HttpTaggedError()( @@ -90,6 +91,21 @@ export class OrganizationWrongRegionError extends HttpTaggedError()( + "@maple/http/errors/LimitedRoleError", + { message: Schema.String }, + { + status: 403, + code: "limited_role", + title: "Limited role", + message: analystMessage, + 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, + LimitedRoleError, OrganizationWrongRegionError, ], security: { @@ -184,6 +201,7 @@ export class SessionAuthorization extends HttpApiMiddleware.Service< AuthorizationUnavailableError, ApiKeyNotAcceptedError, OrganizationAccessDeniedError, + LimitedRoleError, OrganizationWrongRegionError, ], security: { @@ -228,6 +246,7 @@ export class RegionlessSessionAuthorization extends HttpApiMiddleware.Service< AuthorizationUnavailableError, ApiKeyNotAcceptedError, OrganizationAccessDeniedError, + LimitedRoleError, ], security: { bearer: HttpApiSecurity.bearer, diff --git a/packages/domain/src/http/digest.ts b/packages/domain/src/http/digest.ts index 7e9bad8d92..c0e38e6bc2 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 { AnalystAccess } from "./analyst-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(AnalystAccess, "personal"), ) .add( HttpApiEndpoint.delete("deleteSubscription", "/", { success: Schema.Void, error: DigestPersistenceError, - }), + }).annotate(AnalystAccess, "personal"), ) .add( HttpApiEndpoint.post("preview", "/preview", { success: DigestPreviewResponse, error: [DigestPersistenceError, DigestNotConfiguredError, DigestRenderError], - }), + }).annotate(AnalystAccess, "read"), ) .add( HttpApiEndpoint.post("previewWebAnalytics", "/web-analytics/preview", { success: DigestPreviewResponse, error: [DigestPersistenceError, DigestNotConfiguredError, DigestRenderError], - }), + }).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 d088c1e24a..b46fae0a47 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 { AnalystAccess } from "./analyst-access" export { ActorType, ErrorIncidentReason, @@ -919,7 +920,7 @@ export class ErrorsApiGroup extends HttpApiGroup.make("errors") payload: EscalationPolicyEvaluationRequest, success: EscalationPolicyEvaluationDocument, error: ErrorPersistenceError, - }), + }).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 e777872091..0ec2620b07 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 "./analyst-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, + LimitedRoleError, 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..a0f0d24bd9 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 { AnalystAccess } from "./analyst-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(AnalystAccess, "read"), ) .add( HttpApiEndpoint.get("cloudflareStatus", "/cloudflare/status", { @@ -1760,7 +1761,7 @@ export class IntegrationsApiGroup extends HttpApiGroup.make("integrations") IntegrationsUpstreamError, IntegrationsPersistenceError, ], - }), + }).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 1139c8ee9e..a278f9c6ef 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 { AnalystAccess } from "./analyst-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 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 b362946620..afbd27dc42 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 { AnalystAccess } from "./analyst-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 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 188938162c..623f91f91a 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 { AnalystAccess } from "./analyst-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 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 2276276e48..05b76487b8 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 { 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 @@ -458,4 +459,6 @@ export class UsersInternalApiGroup extends HttpApiGroup.make("usersInternal") ) .prefix("/internal/users") .middleware(SessionAuthorization) + // 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 195f3c55a0..a894ae206c 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 { AnalystAccess } from "../analyst-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(AnalystAccess, "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/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/http/v2/device-credentials.ts b/packages/domain/src/http/v2/device-credentials.ts index e032110215..a9000a67ed 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 { AnalystAccess } from "../analyst-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(AnalystAccess, "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(AnalystAccess, "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(AnalystAccess, "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..704970eef5 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 { 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 @@ -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(AnalystAccess, "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..2b2a67af52 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 { AnalystAccess } from "../analyst-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(AnalystAccess, "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..ba55eb443f 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 { 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). @@ -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(AnalystAccess, "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(AnalystAccess, "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(AnalystAccess, "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(AnalystAccess, "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..cd48e0a652 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 { AnalystAccess } from "../analyst-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(AnalystAccess, "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(AnalystAccess, "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..2ec42b59e5 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 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 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 => Arr.filter(allPermissions, (permission) => can(roles, permission))