diff --git a/CLAUDE.md b/CLAUDE.md index 189c1c0d4b..e91207cae8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,9 @@ with `packageManager`. - LLM code (Effect AI + `@yielded/agent`) is only in `apps/ai`; Maple-specific wiring lives in `apps/ai/src/platform/Llm.ts`. - Shared services live in `packages/backend`, imported via `@maple/backend/*` subpaths. +- Observers (`org:observer`) only read. The auth middlewares refuse them every non-GET endpoint unless + it is marked `ObserverAccess` (`packages/domain/src/http/observer-access.ts`): `read` for a POST that + only queries, `personal` for the caller's own state. An unmarked read-only POST is a 403 for them. ### Service conventions diff --git a/apps/api/src/routes/v2/mobile-devices.http.test.ts b/apps/api/src/routes/v2/mobile-devices.http.test.ts index e7728dd97a..61c527929c 100644 --- a/apps/api/src/routes/v2/mobile-devices.http.test.ts +++ b/apps/api/src/routes/v2/mobile-devices.http.test.ts @@ -90,11 +90,17 @@ const makeHarness = () => { return { status: response.status, body: text.length === 0 ? null : JSON.parse(text) } } - const bootstrapKey = (scopes?: ReadonlyArray) => + const bootstrapKey = (scopes?: ReadonlyArray, roles?: ReadonlyArray) => runtime.runPromise( Effect.gen(function* () { const service = yield* ApiKeysService - return yield* service.create(ORG, USER, { name: "mobile-test", scopes }) + return yield* service.create(ORG, USER, { + name: "mobile-test", + scopes, + ...(roles === undefined + ? undefined + : { metadataJson: { source: "maple_cli", roles, deviceName: "laptop" } }), + }) }), ) @@ -184,6 +190,26 @@ describe("v2 mobile devices", () => { } }) + it("lets an observer register and remove their own device", async () => { + const harness = makeHarness() + try { + const key = await harness.bootstrapKey(undefined, ["org:observer"]) + const created = await harness.request("PUT", `/v2/mobile_devices/${TOKEN}`, key.secret, { + platform: "ios", + environment: "sandbox", + bundle_id: "com.maple.mobile", + }) + expect(created.status).toBe(200) + expect(created.body.token).toBe(TOKEN) + + const removed = await harness.request("DELETE", `/v2/mobile_devices/${TOKEN}`, key.secret) + expect(removed.status).toBe(200) + expect(removed.body.deleted).toBe(true) + } finally { + await harness.dispose() + } + }) + it("records and forgets a Live Activity's update token", async () => { const harness = makeHarness() try { diff --git a/apps/api/src/routes/v2/phase1-resources.http.test.ts b/apps/api/src/routes/v2/phase1-resources.http.test.ts index 9eedd4b3b2..b37296d26b 100644 --- a/apps/api/src/routes/v2/phase1-resources.http.test.ts +++ b/apps/api/src/routes/v2/phase1-resources.http.test.ts @@ -726,13 +726,16 @@ const makeHarness = ( } } - const bootstrapKey = (scopes?: ReadonlyArray) => + const bootstrapKey = (scopes?: ReadonlyArray, roles?: ReadonlyArray) => runtime.runPromise( Effect.gen(function* () { const service = yield* ApiKeysService return yield* service.create(ORG, USER, { name: "phase1-test", scopes, + ...(roles === undefined + ? undefined + : { metadataJson: { source: "maple_cli", roles, deviceName: "laptop" } }), }) }), ) @@ -899,6 +902,24 @@ describe("v2 error_issues over HTTP", () => { await harness.dispose() }) + it("lets an observer read an issue and refuses to let them triage it", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey(undefined, ["org:observer"]) + + const read = await harness.request("GET", `/v2/error_issues/${ISS_ID}`, { token: key.secret }) + expect(read.status).toBe(200) + + const refused = await harness.request("POST", `/v2/error_issues/${ISS_ID}/transitions`, { + token: key.secret, + body: { to_state: "todo", expected_state: "triage" }, + }) + expect(refused.status).toBe(403) + expect(refused.body.error.code).toBe("insufficient_permissions") + expect(refused.body.error.message).toContain("Observers can read Maple") + expect(harness.issueActions()).toEqual([]) + await harness.dispose() + }) + it("maps refused transitions to 409, missing issues to 404, and needs write scope", async () => { const harness = makeHarness() const reader = await harness.bootstrapKey(["error_issues:read"]) diff --git a/apps/api/src/routes/v2/telemetry.http.test.ts b/apps/api/src/routes/v2/telemetry.http.test.ts index f44f2d58b1..205b616bda 100644 --- a/apps/api/src/routes/v2/telemetry.http.test.ts +++ b/apps/api/src/routes/v2/telemetry.http.test.ts @@ -378,11 +378,17 @@ const makeHarness = ( const runtime = ManagedRuntime.make(servicesLive) const org = Schema.decodeSync(OrgId)("org_telemetry_e2e") const user = Schema.decodeSync(UserId)("user_telemetry_e2e") - const bootstrapKey = (scopes?: ReadonlyArray) => + const bootstrapKey = (scopes?: ReadonlyArray, roles?: ReadonlyArray) => runtime.runPromise( Effect.gen(function* () { const service = yield* ApiKeysService - return yield* service.create(org, user, { name: "telemetry-test", scopes }) + return yield* service.create(org, user, { + name: "telemetry-test", + scopes, + ...(roles === undefined + ? undefined + : { metadataJson: { source: "maple_cli", roles, deviceName: "laptop" } }), + }) }), ) const request = async (method: string, path: string, token: string, body?: unknown) => { @@ -565,6 +571,17 @@ describe("v2 telemetry reads over HTTP", () => { await harness.dispose() }) + it("lets an observer search traces, a POST that only reads", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey(undefined, ["org:observer"]) + const searched = await harness.request("POST", "/v2/traces/search", key.secret, { + start_time: START, + end_time: END, + }) + expect(searched.status).toBe(200) + expect(searched.body.data[0]).toMatchObject({ object: "trace", id: TRACE_ID }) + }) + it("requires windows, validates log IDs, and treats telemetry POSTs as scoped reads", async () => { const harness = makeHarness() const tracesKey = await harness.bootstrapKey(["traces:read"]) diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json index 179457999a..cdb95c5afb 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json @@ -7638,7 +7638,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -7807,7 +7807,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -7931,7 +7931,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -8078,7 +8078,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -8203,7 +8203,7 @@ } } }, - "description": "The @maple/http/errors/AlertForbiddenError failure. HTTP 403. | The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/errors/AlertForbiddenError failure. HTTP 403. | The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -8337,7 +8337,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -8528,7 +8528,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -8743,7 +8743,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -8867,7 +8867,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -9017,7 +9017,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -9174,7 +9174,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -9298,7 +9298,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -9442,7 +9442,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -9584,7 +9584,7 @@ } } }, - "description": "The @maple/http/errors/DeviceCredentialSessionRequiredError failure. HTTP 403. | The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/errors/DeviceCredentialSessionRequiredError failure. HTTP 403. | The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -9733,7 +9733,7 @@ } } }, - "description": "The @maple/http/errors/DeviceCredentialSessionRequiredError failure. HTTP 403. | The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/errors/DeviceCredentialSessionRequiredError failure. HTTP 403. | The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "409": { "content": { @@ -9875,7 +9875,7 @@ } } }, - "description": "The @maple/http/errors/DeviceCredentialSessionRequiredError failure. HTTP 403. | The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/errors/DeviceCredentialSessionRequiredError failure. HTTP 403. | The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -10118,7 +10118,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -10399,7 +10399,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -10524,7 +10524,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -10680,7 +10680,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -10834,7 +10834,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -10988,7 +10988,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -11142,7 +11142,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -11286,7 +11286,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -11431,7 +11431,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -11566,7 +11566,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -11691,7 +11691,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -11822,7 +11822,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -11971,7 +11971,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -12110,7 +12110,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -12267,7 +12267,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -12446,7 +12446,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -12581,7 +12581,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -12731,7 +12731,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -12908,7 +12908,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -13053,7 +13053,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -13188,7 +13188,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -13323,7 +13323,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -13457,7 +13457,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "404": { "content": { @@ -13609,7 +13609,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -13739,7 +13739,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { @@ -13863,7 +13863,7 @@ } } }, - "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." + "description": "The @maple/http/v2/InsufficientScopeError failure. HTTP 403. | The @maple/http/v2/InsufficientPermissionsError failure. HTTP 403. | The @maple/http/v2/OrganizationAccessDeniedError failure. HTTP 403. | The @maple/http/v2/NoActiveOrganizationError failure. HTTP 403." }, "429": { "content": { diff --git a/packages/backend/src/services/auth/ApiAuthorizationLive.ts b/packages/backend/src/services/auth/ApiAuthorizationLive.ts index 2a82f2200b..95ba60f791 100644 --- a/packages/backend/src/services/auth/ApiAuthorizationLive.ts +++ b/packages/backend/src/services/auth/ApiAuthorizationLive.ts @@ -11,6 +11,7 @@ import { withAuditedRead } from "@maple/backend/services/audit/audit-access" import { Env } from "@maple/backend/platform/Env" import { OrganizationRegionService } from "@maple/backend/services/org/OrganizationRegionService" import { API_KEY_DEFAULT_ROLES, getBearerToken } from "./bearer-token" +import { refuseObserverWrites } from "./observer-gate" export const ApiAuthorizationLive = Layer.effect( CurrentTenant.Authorization, @@ -74,6 +75,9 @@ export const ApiAuthorizationLive = Layer.effect( roles: resolved.roles ?? API_KEY_DEFAULT_ROLES, authMode: "self_hosted", }) + yield* refuseObserverWrites(tenant.roles, options).pipe( + Effect.tapError((error) => recordDenied(error.message)), + ) return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, tenant), Effect.provideService(CurrentAuditActor, { @@ -99,6 +103,7 @@ export const ApiAuthorizationLive = Layer.effect( yield* annotateAuthSpan("session", { orgId: tenant.orgId, userId: tenant.userId }) // API keys are minted per instance; only a session can name an org from elsewhere. yield* regions.ensureServedHere(tenant.orgId) + yield* refuseObserverWrites(tenant.roles, options) return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, new CurrentTenant.TenantSchema(tenant)), Effect.provideService(CurrentAuditActor, { type: "user", source: "dashboard" }), diff --git a/packages/backend/src/services/auth/ApiAuthorizationV2Live.ts b/packages/backend/src/services/auth/ApiAuthorizationV2Live.ts index ed35cc5578..6bd974d9d3 100644 --- a/packages/backend/src/services/auth/ApiAuthorizationV2Live.ts +++ b/packages/backend/src/services/auth/ApiAuthorizationV2Live.ts @@ -33,6 +33,7 @@ import { deviceRateLimitKey, } from "./ApiV2RateLimiter" import { API_KEY_DEFAULT_ROLES, getBearerToken } from "./bearer-token" +import { refuseObserverWritesV2 } from "./observer-gate" /** The access token of an RFC 9449 `Authorization: DPoP ` request. */ const getDpopToken = (headers: Record): string | undefined => { @@ -180,6 +181,7 @@ export const ApiAuthorizationV2Live = Layer.effect( authMode: "self_hosted", scopes: resolved.scopes, }) + yield* refuseObserverWritesV2(tenant.roles, options, route.path) const label = "Device credential" return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, tenant), @@ -300,6 +302,9 @@ export const ApiAuthorizationV2Live = Layer.effect( authMode: "self_hosted", ...(resolved.scopes !== null ? { scopes: resolved.scopes } : undefined), }) + yield* refuseObserverWritesV2(tenant.roles, options, route.path).pipe( + Effect.tapError((error) => recordDenied(error.message)), + ) return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, tenant), Effect.provideService(CurrentAuditActor, { @@ -341,6 +346,8 @@ export const ApiAuthorizationV2Live = Layer.effect( .ensureServedHere(tenant.orgId) .pipe(Effect.mapError((error) => V2OrganizationAccessDenied.make(error.message))) } + const { route } = yield* HttpRouter.RouteContext + yield* refuseObserverWritesV2(tenant.roles, options, route.path) return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, new CurrentTenant.TenantSchema(tenant)), Effect.provideService(CurrentAuditActor, { type: "user", source: "dashboard" }), diff --git a/packages/backend/src/services/auth/SessionAuthorizationLive.ts b/packages/backend/src/services/auth/SessionAuthorizationLive.ts index db5a65b60c..7760b8ab5a 100644 --- a/packages/backend/src/services/auth/SessionAuthorizationLive.ts +++ b/packages/backend/src/services/auth/SessionAuthorizationLive.ts @@ -10,6 +10,7 @@ import { withAuditedRead } from "@maple/backend/services/audit/audit-access" import { Env } from "@maple/backend/platform/Env" import { OrganizationRegionService } from "@maple/backend/services/org/OrganizationRegionService" import { getBearerToken } from "./bearer-token" +import { refuseObserverWrites } from "./observer-gate" /** * Authorization for the internal API: Clerk sessions only. @@ -47,6 +48,7 @@ export const SessionAuthorizationLive = Layer.effect( const tenant = yield* resolveTenant(request.headers) yield* annotateAuthSpan("session", { orgId: tenant.orgId, userId: tenant.userId }) yield* regions.ensureServedHere(tenant.orgId) + yield* refuseObserverWrites(tenant.roles, options) const actor = { type: "user", source: "dashboard" } as const return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, new CurrentTenant.TenantSchema(tenant)), @@ -85,6 +87,7 @@ export const RegionlessSessionAuthorizationLive = Layer.effect( } const tenant = yield* resolveTenant(request.headers) yield* annotateAuthSpan("session", { orgId: tenant.orgId, userId: tenant.userId }) + yield* refuseObserverWrites(tenant.roles, options) const actor = { type: "user", source: "dashboard" } as const return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, new CurrentTenant.TenantSchema(tenant)), diff --git a/packages/backend/src/services/auth/observer-gate.test.ts b/packages/backend/src/services/auth/observer-gate.test.ts new file mode 100644 index 0000000000..b59b4a992c --- /dev/null +++ b/packages/backend/src/services/auth/observer-gate.test.ts @@ -0,0 +1,60 @@ +import { assert, describe, expect, it } from "@effect/vitest" +import { CurrentTenant, MapleInternalApi } from "@maple/domain/http" +import { MapleApiV2 } from "@maple/domain/http/v2" +import { Effect, Exit } from "effect" +import { refuseObserverWrites, refuseObserverWritesV2 } from "./observer-gate" + +const OBSERVER = ["org:observer"] +const MEMBER = ["org:member"] + +const errors = MapleInternalApi.groups.errors +const transition = { group: errors, endpoint: errors.endpoints.transitionIssue } +const queries = MapleInternalApi.groups.queryEngine +const traces = MapleApiV2.groups.traces +const dashboards = MapleApiV2.groups.dashboards + +/** Whether the gate let the call through. */ +const allowed = (gate: Effect.Effect) => Effect.map(Effect.exit(gate), Exit.isSuccess) + +describe("refuseObserverWrites", () => { + it.effect("refuses an observer an issue transition and says how to get it", () => + Effect.gen(function* () { + const error = yield* Effect.flip(refuseObserverWrites(OBSERVER, transition)) + assert.instanceOf(error, CurrentTenant.ReadOnlyRoleError) + expect(error.message).toContain("Ask an org admin for the Member role") + }), + ) + + it.effect("lets a member, and Maple's own agents with no roles, make the same change", () => + Effect.gen(function* () { + expect(yield* allowed(refuseObserverWrites(MEMBER, transition))).toBe(true) + expect(yield* allowed(refuseObserverWrites([], transition))).toBe(true) + }), + ) + + it.effect("lets an observer run a dashboard query", () => + Effect.gen(function* () { + const query = { group: queries, endpoint: queries.endpoints.executeBatch } + expect(yield* allowed(refuseObserverWrites(OBSERVER, query))).toBe(true) + }), + ) +}) + +describe("refuseObserverWritesV2", () => { + const create = { group: dashboards, endpoint: dashboards.endpoints.create } + + it.effect("lets an observer search traces, which the scope registry names as a read", () => + Effect.gen(function* () { + const search = { group: traces, endpoint: traces.endpoints.search } + expect(yield* allowed(refuseObserverWritesV2(OBSERVER, search, "/v2/traces/search"))).toBe(true) + }), + ) + + it.effect("refuses an observer a new dashboard and lets a member create one", () => + Effect.gen(function* () { + const error = yield* Effect.flip(refuseObserverWritesV2(OBSERVER, create, "/v2/dashboards")) + expect(error.message).toContain("Observers can read Maple") + expect(yield* allowed(refuseObserverWritesV2(MEMBER, create, "/v2/dashboards"))).toBe(true) + }), + ) +}) diff --git a/packages/backend/src/services/auth/observer-gate.ts b/packages/backend/src/services/auth/observer-gate.ts new file mode 100644 index 0000000000..d3bc7b0025 --- /dev/null +++ b/packages/backend/src/services/auth/observer-gate.ts @@ -0,0 +1,34 @@ +import { CurrentTenant, isOpenToObservers } from "@maple/domain/http" +import { requiredScopeForRoute, V2InsufficientPermissions } from "@maple/domain/http/v2" +import * as Policy from "@maple/domain/policy" +import { Effect } from "effect" + +type Called = Parameters[0] + +/** + * Refuses a read-only role every endpoint that changes the organization. It runs in the auth + * layers, so an endpoint added later is refused until it, or its whole group, is marked + * `ObserverAccess`. + */ +export const refuseObserverWrites = ( + roles: ReadonlyArray, + called: Called, +): Effect.Effect => + Policy.isReadOnly(roles) && !isOpenToObservers(called) + ? Effect.fail(new CurrentTenant.ReadOnlyRoleError({ message: Policy.readOnlyMessage })) + : Effect.void + +/** + * {@link refuseObserverWrites} for `/v2`, where the scope registry already names the POSTs that + * only read. `routePath` is the matched route template, as that registry expects. + */ +export const refuseObserverWritesV2 = ( + roles: ReadonlyArray, + called: Called, + routePath: string, +): Effect.Effect> => + Policy.isReadOnly(roles) && + !isOpenToObservers(called) && + requiredScopeForRoute(called.endpoint.method, routePath)?.access !== "read" + ? Effect.fail(V2InsufficientPermissions.make(Policy.readOnlyMessage)) + : Effect.void diff --git a/packages/domain/src/generated/anticipated-error-identifiers.ts b/packages/domain/src/generated/anticipated-error-identifiers.ts index 9099172dd0..a1ef0e1e27 100644 --- a/packages/domain/src/generated/anticipated-error-identifiers.ts +++ b/packages/domain/src/generated/anticipated-error-identifiers.ts @@ -111,6 +111,7 @@ export const ANTICIPATED_ERROR_IDENTIFIER_LIST: ReadonlyArray = [ "@maple/http/errors/PreviewSignInInvalidKeyError", "@maple/http/errors/QueryEngineValidationError", "@maple/http/errors/RawSqlValidationError", + "@maple/http/errors/ReadOnlyRoleError", "@maple/http/errors/RecommendationIssueNotFoundError", "@maple/http/errors/ScrapeTargetLimitReachedError", "@maple/http/errors/ScrapeTargetNotFoundError", diff --git a/packages/domain/src/http/ai-models.ts b/packages/domain/src/http/ai-models.ts index 7b6acc8097..d15ad4d15e 100644 --- a/packages/domain/src/http/ai-models.ts +++ b/packages/domain/src/http/ai-models.ts @@ -1,6 +1,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/http-api" import { Schema } from "effect" import { SessionAuthorization } from "./current-tenant" +import { ObserverAccess } from "./observer-access" // AI model detection: a model string as an instrumentation reported it // (`gen_ai.request.model`, an OpenRouter id, a Bedrock id) → the model's @@ -73,4 +74,6 @@ export class AiModelsInternalApiGroup extends HttpApiGroup.make("aiModelsInterna }), ) .prefix("/internal/ai-models") - .middleware(SessionAuthorization) {} + .middleware(SessionAuthorization) + // Every endpoint here only reads, so an observer may call it. + .annotate(ObserverAccess, "read") {} diff --git a/packages/domain/src/http/ai-sessions.ts b/packages/domain/src/http/ai-sessions.ts index 8b3ef7dd3b..c20c2ed8da 100644 --- a/packages/domain/src/http/ai-sessions.ts +++ b/packages/domain/src/http/ai-sessions.ts @@ -6,6 +6,7 @@ import { BucketSeconds } from "./query-engine" import { SessionAuthorization } from "./current-tenant" import { HttpTaggedError } from "./error-policy" import { warehouseReadHttpErrors } from "./warehouse" +import { ObserverAccess } from "./observer-access" // Backed by the `maple_ai.*` span attributes the ingest gateway stamps at // decode time; a session is resolved at trace granularity by @@ -1099,4 +1100,6 @@ export class AiSessionsInternalApiGroup extends HttpApiGroup.make("aiSessionsInt }), ) .prefix("/internal/ai-sessions") - .middleware(SessionAuthorization) {} + .middleware(SessionAuthorization) + // Every endpoint here only reads, so an observer may call it. + .annotate(ObserverAccess, "read") {} diff --git a/packages/domain/src/http/auth.ts b/packages/domain/src/http/auth.ts index 45709c81e2..72e2981dee 100644 --- a/packages/domain/src/http/auth.ts +++ b/packages/domain/src/http/auth.ts @@ -2,6 +2,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/http-api" import { Schema } from "effect" import { OrgId, UserId } from "@maple/primitives" import { Authorization, SessionAuthorization, TenantSchema, UnauthorizedError } from "./current-tenant" +import { ObserverAccess } from "./observer-access" export class CliDeviceStartRequest extends Schema.Class("CliDeviceStartRequest")({ deviceName: Schema.String, @@ -264,13 +265,13 @@ export class AuthApiGroup extends HttpApiGroup.make("auth") HttpApiEndpoint.post("sessionRefresh", "/session/refresh", { success: SelfHostedLoginResponse, error: [SelfHostedAuthDisabledError, UnauthorizedError], - }), + }).annotate(ObserverAccess, "personal"), ) .add( HttpApiEndpoint.delete("cliSessionRevoke", "/cli/session", { success: CliDeviceActionResponse, error: [CliDeviceConflictError, CliDevicePersistenceError], - }), + }).annotate(ObserverAccess, "personal"), ) .prefix("/api/auth") .middleware(Authorization) {} @@ -297,7 +298,7 @@ export class AuthSessionApiGroup extends HttpApiGroup.make("authSession") CliDeviceConflictError, CliDevicePersistenceError, ], - }), + }).annotate(ObserverAccess, "personal"), ) .add( HttpApiEndpoint.post("cliDeviceDeny", "/cli/device/:userCode/deny", { @@ -309,7 +310,7 @@ export class AuthSessionApiGroup extends HttpApiGroup.make("authSession") CliDeviceConflictError, CliDevicePersistenceError, ], - }), + }).annotate(ObserverAccess, "personal"), ) .add( HttpApiEndpoint.get("mcpOAuthAuthorizationInspect", "/mcp/oauth/authorization/:requestId", { @@ -332,7 +333,7 @@ export class AuthSessionApiGroup extends HttpApiGroup.make("authSession") McpOAuthAuthorizationConflictError, McpOAuthPersistenceError, ], - }), + }).annotate(ObserverAccess, "personal"), ) .add( HttpApiEndpoint.post("mcpOAuthAuthorizationDeny", "/mcp/oauth/authorization/:requestId/deny", { @@ -344,7 +345,7 @@ export class AuthSessionApiGroup extends HttpApiGroup.make("authSession") McpOAuthAuthorizationConflictError, McpOAuthPersistenceError, ], - }), + }).annotate(ObserverAccess, "personal"), ) .prefix("/api/auth") .middleware(SessionAuthorization) {} diff --git a/packages/domain/src/http/billing.ts b/packages/domain/src/http/billing.ts index 7eb1fb1f1f..23b430de22 100644 --- a/packages/domain/src/http/billing.ts +++ b/packages/domain/src/http/billing.ts @@ -15,6 +15,7 @@ import { LinkBillingOrgRequest, } from "./billing-account" import { WarehouseQueryError } from "./warehouse-errors" +import { ObserverAccess } from "./observer-access" // Contract for raw Autumn proxy responses. Schemas model only consumed fields // and tolerate additive upstream fields. @@ -725,7 +726,7 @@ export class BillingApiGroup extends HttpApiGroup.make("billing") payload: PreviewAttachRequest, success: PreviewAttachResult, error: [BillingManagedByBillingOrgError, ...billingRequestErrors], - }), + }).annotate(ObserverAccess, "read"), ) .add( HttpApiEndpoint.post("openCustomerPortal", "/portal", { diff --git a/packages/domain/src/http/current-tenant.ts b/packages/domain/src/http/current-tenant.ts index 2ef661fde8..1c4706cb48 100644 --- a/packages/domain/src/http/current-tenant.ts +++ b/packages/domain/src/http/current-tenant.ts @@ -3,6 +3,7 @@ import { Schema, Context as EffectContext } from "effect" import { type ActorId, AuthMode, OrgId, RoleName, UserId } from "@maple/primitives" import type { ChatTurnOrigin } from "../chat-session" import { MapleRegion } from "../organization-regions" +import { readOnlyMessage } from "../policy" import { HttpTaggedError } from "./error-policy" export class UnauthorizedError extends HttpTaggedError()( @@ -90,6 +91,21 @@ export class OrganizationWrongRegionError extends HttpTaggedError()( + "@maple/http/errors/ReadOnlyRoleError", + { message: Schema.String }, + { + status: 403, + code: "read_only_role", + title: "Read-only access", + message: readOnlyMessage, + retry: "never", + recovery: "request_access", + exposure: "public_message", + }, +) {} + /** * Who a request, tool call or job acts as. `TenantSchema` is the form the HTTP middleware * provides; the optional fields below it are set only by the MCP and chat surfaces. @@ -135,6 +151,7 @@ export class Authorization extends HttpApiMiddleware.Service< UnauthorizedError, AuthorizationUnavailableError, OrganizationAccessDeniedError, + ReadOnlyRoleError, OrganizationWrongRegionError, ], security: { @@ -184,6 +201,7 @@ export class SessionAuthorization extends HttpApiMiddleware.Service< AuthorizationUnavailableError, ApiKeyNotAcceptedError, OrganizationAccessDeniedError, + ReadOnlyRoleError, OrganizationWrongRegionError, ], security: { @@ -228,6 +246,7 @@ export class RegionlessSessionAuthorization extends HttpApiMiddleware.Service< AuthorizationUnavailableError, ApiKeyNotAcceptedError, OrganizationAccessDeniedError, + ReadOnlyRoleError, ], security: { bearer: HttpApiSecurity.bearer, diff --git a/packages/domain/src/http/digest.ts b/packages/domain/src/http/digest.ts index 7e9bad8d92..ac8ec68c6e 100644 --- a/packages/domain/src/http/digest.ts +++ b/packages/domain/src/http/digest.ts @@ -1,6 +1,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/http-api" import { DateTime, Option, Schema } from "effect" import { SessionAuthorization } from "./current-tenant" +import { ObserverAccess } from "./observer-access" export const DigestSubscriptionId = Schema.String.check(Schema.isUUID()).pipe( Schema.brand("@maple/DigestSubscriptionId"), @@ -163,25 +164,25 @@ export class DigestApiGroup extends HttpApiGroup.make("digest") payload: UpsertDigestSubscriptionRequest, success: DigestSubscriptionResponse, error: [DigestPersistenceError, DigestNotFoundError, DigestEmailUnavailableError], - }), + }).annotate(ObserverAccess, "personal"), ) .add( HttpApiEndpoint.delete("deleteSubscription", "/", { success: Schema.Void, error: DigestPersistenceError, - }), + }).annotate(ObserverAccess, "personal"), ) .add( HttpApiEndpoint.post("preview", "/preview", { success: DigestPreviewResponse, error: [DigestPersistenceError, DigestNotConfiguredError, DigestRenderError], - }), + }).annotate(ObserverAccess, "read"), ) .add( HttpApiEndpoint.post("previewWebAnalytics", "/web-analytics/preview", { success: DigestPreviewResponse, error: [DigestPersistenceError, DigestNotConfiguredError, DigestRenderError], - }), + }).annotate(ObserverAccess, "read"), ) .prefix("/internal/digest") .middleware(SessionAuthorization) {} diff --git a/packages/domain/src/http/errors.ts b/packages/domain/src/http/errors.ts index d088c1e24a..89750ffcba 100644 --- a/packages/domain/src/http/errors.ts +++ b/packages/domain/src/http/errors.ts @@ -43,6 +43,7 @@ import { IssueSeveritySource, WorkflowState, } from "../error-issues" +import { ObserverAccess } from "./observer-access" export { ActorType, ErrorIncidentReason, @@ -919,7 +920,7 @@ export class ErrorsApiGroup extends HttpApiGroup.make("errors") payload: EscalationPolicyEvaluationRequest, success: EscalationPolicyEvaluationDocument, error: ErrorPersistenceError, - }), + }).annotate(ObserverAccess, "read"), ) .add( HttpApiEndpoint.get("listIssueEscalations", "/issues/:issueId/escalations", { diff --git a/packages/domain/src/http/index.ts b/packages/domain/src/http/index.ts index e777872091..e079d9a9d5 100644 --- a/packages/domain/src/http/index.ts +++ b/packages/domain/src/http/index.ts @@ -21,6 +21,7 @@ export * from "./anomalies" export * from "./api-keys" export * from "./device-credentials" export * from "./audit-log" +export * from "./observer-access" export * from "./alerts" export * from "./escalation-chains" export * from "./oncall-schedules" @@ -35,13 +36,14 @@ export * from "./billing" export * from "./billing-account" export * from "./chat" export * as CurrentTenant from "./current-tenant" -// The tenant-resolution failures themselves, flat: `@maple/auth` raises all -// three and has no use for the namespace. (`UnauthorizedError` already reaches -// the barrel through `./warehouse`.) +// The tenant-resolution failures themselves, flat: `@maple/auth` raises the +// first three and has no use for the namespace, and the 4xx registry reads only +// flat exports. (`UnauthorizedError` already reaches the barrel through `./warehouse`.) export { AuthorizationUnavailableError, OrganizationAccessDeniedError, OrganizationWrongRegionError, + ReadOnlyRoleError, type TenantContext, } from "./current-tenant" export * from "./dashboard-sections" diff --git a/packages/domain/src/http/integrations.ts b/packages/domain/src/http/integrations.ts index c5e6fd5c21..9902e298a2 100644 --- a/packages/domain/src/http/integrations.ts +++ b/packages/domain/src/http/integrations.ts @@ -35,6 +35,7 @@ import { GcpCloudSqlSettings } from "../gcp" export { GcpCloudSqlSettings } from "../gcp" import { SupabaseAdvisorLint, SupabaseLogDrainSummary, SupabaseTopQuery } from "../supabase" +import { ObserverAccess } from "./observer-access" export { SupabaseAdvisorKind, SupabaseAdvisorLevel, @@ -1735,7 +1736,7 @@ export class IntegrationsApiGroup extends HttpApiGroup.make("integrations") HttpApiEndpoint.post("infraSurfaces", "/infra-surfaces", { payload: InfraPresenceRequest, success: InfraSurfacesResponse, - }), + }).annotate(ObserverAccess, "read"), ) .add( HttpApiEndpoint.get("cloudflareStatus", "/cloudflare/status", { @@ -1760,7 +1761,7 @@ export class IntegrationsApiGroup extends HttpApiGroup.make("integrations") IntegrationsUpstreamError, IntegrationsPersistenceError, ], - }), + }).annotate(ObserverAccess, "read"), ) .add( HttpApiEndpoint.post("cloudflareStart", "/cloudflare/start", { diff --git a/packages/domain/src/http/mcp.ts b/packages/domain/src/http/mcp.ts index 1139c8ee9e..3975ecb914 100644 --- a/packages/domain/src/http/mcp.ts +++ b/packages/domain/src/http/mcp.ts @@ -5,6 +5,7 @@ import { TinybirdDateTime } from "../query-engine" import { BucketSeconds } from "./query-engine" import { SessionAuthorization } from "./current-tenant" import { warehouseReadHttpErrors } from "./warehouse" +import { ObserverAccess } from "./observer-access" // MCP hub endpoint schemas (`/mcp-servers`, a server's overview, its tool page and its sessions). // @@ -339,4 +340,6 @@ export class McpInternalApiGroup extends HttpApiGroup.make("mcpInternal") }), ) .prefix("/internal/mcp-servers") - .middleware(SessionAuthorization) {} + .middleware(SessionAuthorization) + // Every endpoint here only reads, so an observer may call it. + .annotate(ObserverAccess, "read") {} diff --git a/packages/domain/src/http/observer-access.test.ts b/packages/domain/src/http/observer-access.test.ts new file mode 100644 index 0000000000..fc4a9907c2 --- /dev/null +++ b/packages/domain/src/http/observer-access.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "@effect/vitest" +import { Array as Arr } from "effect" +import { MapleApi } from "./api" +import { MapleInternalApi } from "./internal-api" +import { isOpenToObservers } from "./observer-access" +import { MapleApiV2 } from "./v2/api" + +type Called = Parameters[0] +type Group = Called["group"] & { + readonly endpoints: { readonly [name: string]: Called["endpoint"] & { readonly identifier: string } } +} + +// @effect-diagnostics-next-line preferEffectRecord:off -- not a record type; Rec.values would need a cast +const endpointsOf = (group: Group) => Object.values(group.endpoints) + +const open = (group: Group, name: string): boolean => + Arr.some( + endpointsOf(group), + (endpoint) => endpoint.identifier === name && isOpenToObservers({ group, endpoint }), + ) + +const internal = MapleInternalApi.groups +const v2 = MapleApiV2.groups + +describe("isOpenToObservers", () => { + it("lets an observer run every dashboard query, which are all POSTs", () => { + const queries = endpointsOf(internal.queryEngine) + expect(queries.length).toBeGreaterThan(0) + expect( + Arr.every(queries, (endpoint) => isOpenToObservers({ group: internal.queryEngine, endpoint })), + ).toBe(true) + }) + + it("refuses changes to shared work: issue triage and dashboards", () => { + expect(open(internal.errors, "transitionIssue")).toBe(false) + expect(open(internal.errors, "commentOnIssue")).toBe(false) + expect(open(v2.dashboards, "create")).toBe(false) + expect(open(v2.dashboards, "upsertShare")).toBe(false) + }) + + it("keeps every GET of a group that also changes things", () => { + const reads = Arr.filter(endpointsOf(internal.errors), (endpoint) => endpoint.method === "GET") + expect(reads.length).toBeGreaterThan(0) + expect(Arr.every(reads, (endpoint) => isOpenToObservers({ group: internal.errors, endpoint }))).toBe( + true, + ) + }) + + it("allows a dry run that sits beside the writes it previews", () => { + expect(open(internal.errors, "evaluateEscalationPolicy")).toBe(true) + expect(open(internal.digest, "preview")).toBe(true) + }) + + it("lets an observer manage their own digest, devices and connections", () => { + expect(open(internal.digest, "upsertSubscription")).toBe(true) + expect(open(internal.digest, "deleteSubscription")).toBe(true) + expect(open(v2.mobileDevices, "register")).toBe(true) + expect(open(v2.mcpConnections, "revoke")).toBe(true) + expect(open(MapleApi.groups.authSession, "cliDeviceApprove")).toBe(true) + }) +}) diff --git a/packages/domain/src/http/observer-access.ts b/packages/domain/src/http/observer-access.ts new file mode 100644 index 0000000000..ebcf0bde89 --- /dev/null +++ b/packages/domain/src/http/observer-access.ts @@ -0,0 +1,29 @@ +import { Context } from "effect" + +/** + * Why an observer, who only reads, may still call an endpoint that is not a GET: `read` only looks + * (a query with a body), `personal` changes the caller's own state. Unset means the endpoint changes + * the organization, and the auth middlewares refuse it to an observer. + */ +export class ObserverAccess extends Context.Reference<"read" | "personal" | undefined>( + "@maple/http/ObserverAccess", + { defaultValue: () => undefined }, +) {} + +interface Annotated { + readonly annotations: Context.Context +} + +/** Whether a read-only role may call the endpoint: a GET, or one marked {@link ObserverAccess}. */ +export const isOpenToObservers = (options: { + readonly endpoint: Annotated & { readonly method: string } + readonly group: Annotated +}): boolean => { + const { method } = options.endpoint + if (method === "GET" || method === "HEAD") return true + // A group-level `.annotate` lands on the group only, so both are read; the endpoint wins. + const access = + Context.get(options.endpoint.annotations, ObserverAccess) ?? + Context.get(options.group.annotations, ObserverAccess) + return access !== undefined +} diff --git a/packages/domain/src/http/query-engine.ts b/packages/domain/src/http/query-engine.ts index b362946620..d60d43dc93 100644 --- a/packages/domain/src/http/query-engine.ts +++ b/packages/domain/src/http/query-engine.ts @@ -44,6 +44,7 @@ import { PathsInclude, } from "@maple/query-model" import { WireRow } from "../wire" +import { ObserverAccess } from "./observer-access" /** * A timeseries bucket width. @@ -5068,5 +5069,7 @@ export class QueryEngineApiGroup extends HttpApiGroup.make("queryEngine") ) .prefix("/internal/query-engine") .middleware(SessionAuthorization) + // Every endpoint here only reads, so an observer may call it. + .annotate(ObserverAccess, "read") // Every endpoint here reads telemetry for the dashboard. .annotate(AuditedRead, "telemetry.read") {} diff --git a/packages/domain/src/http/session-replay.ts b/packages/domain/src/http/session-replay.ts index 188938162c..bebe700627 100644 --- a/packages/domain/src/http/session-replay.ts +++ b/packages/domain/src/http/session-replay.ts @@ -6,6 +6,7 @@ import { AuditedRead } from "./audit-log" import { SessionAuthorization } from "./current-tenant" import { QueryEngineExecutionError, QueryEngineTimeoutError } from "./query-engine" import { warehouseHttpErrors } from "./warehouse" +import { ObserverAccess } from "./observer-access" // Session replay schemas shared by the v2 contract and the internal replay group. @@ -169,4 +170,6 @@ export class SessionReplaysInternalApiGroup extends HttpApiGroup.make("sessionRe ) .prefix("/internal/session-replays") .middleware(SessionAuthorization) + // Every endpoint here only reads, so an observer may call it. + .annotate(ObserverAccess, "read") .annotate(AuditedRead, "session_replay.read") {} diff --git a/packages/domain/src/http/users.ts b/packages/domain/src/http/users.ts index 2276276e48..1deb76bd37 100644 --- a/packages/domain/src/http/users.ts +++ b/packages/domain/src/http/users.ts @@ -7,6 +7,7 @@ import { TinybirdDateTime } from "../query-engine" import { AuditedRead } from "./audit-log" import { SessionAuthorization } from "./current-tenant" import { warehouseReadHttpErrors } from "./warehouse" +import { ObserverAccess } from "./observer-access" // End users endpoint schemas (`/users`): the people Maple's customers identified with `identify()` // or a `user.id` span attribute, read by `ch/queries/users.ts` and `user-activity.ts`. Times are @@ -458,4 +459,6 @@ export class UsersInternalApiGroup extends HttpApiGroup.make("usersInternal") ) .prefix("/internal/users") .middleware(SessionAuthorization) + // Every endpoint here only reads, so an observer may call it. + .annotate(ObserverAccess, "read") .annotate(AuditedRead, "telemetry.read") {} diff --git a/packages/domain/src/http/v2/agent-feedback.ts b/packages/domain/src/http/v2/agent-feedback.ts index 195f3c55a0..78ce306610 100644 --- a/packages/domain/src/http/v2/agent-feedback.ts +++ b/packages/domain/src/http/v2/agent-feedback.ts @@ -12,6 +12,7 @@ import { ListOf, ListQuery, Timestamp, wireExample } from "./envelopes" import { V2ParameterInvalid } from "./errors" import { publicError } from "./public-error" import { AgentFeedbackPublicId } from "./resource-ids" +import { ObserverAccess } from "../observer-access" const isNotBlank = Schema.makeFilter((value: string) => Str.trim(value).length > 0, { description: "Must contain a non-whitespace character", @@ -134,14 +135,16 @@ export class V2AgentFeedbackApiGroup extends HttpApiGroup.make("agentFeedback") payload: V2AgentFeedbackCreateParams, success: V2AgentFeedback, error: [persistence], - }).annotateMerge( - OpenApi.annotations({ - identifier: "createAgentFeedback", - summary: "Send feedback", - description: - "Sends feedback about Maple from an agent to the Maple team: say what kind it is, which agent is sending it, and why. Needs no scope: any API key of the organization can send feedback.", - }), - ), + }) + .annotate(ObserverAccess, "personal") + .annotateMerge( + OpenApi.annotations({ + identifier: "createAgentFeedback", + summary: "Send feedback", + description: + "Sends feedback about Maple from an agent to the Maple team: say what kind it is, which agent is sending it, and why. Needs no scope: any API key of the organization can send feedback.", + }), + ), ) .add( HttpApiEndpoint.get("list", "/", { diff --git a/packages/domain/src/http/v2/auth.ts b/packages/domain/src/http/v2/auth.ts index 376558bde4..14d085c913 100644 --- a/packages/domain/src/http/v2/auth.ts +++ b/packages/domain/src/http/v2/auth.ts @@ -3,6 +3,7 @@ import { Schema, Array as Arr, String as Str } from "effect" import { ApiKeyLookupPersistenceError, type ApiKeyKind } from "../api-keys" import { AuthorizationUnavailableError, Context, UnauthorizedError } from "../current-tenant" import { + V2InsufficientPermissions, V2InsufficientScope, V2InvalidCredentials, V2InvalidDpopProof, @@ -34,6 +35,7 @@ export class AuthorizationV2 extends HttpApiMiddleware.Service< V2InvalidCredentials.schema, V2InvalidDpopProof.schema, V2InsufficientScope.schema, + V2InsufficientPermissions.schema, V2RateLimited.schema, V2OrganizationAccessDenied.schema, V2NoActiveOrganization.schema, diff --git a/packages/domain/src/http/v2/device-credentials.ts b/packages/domain/src/http/v2/device-credentials.ts index e032110215..9d50979fc4 100644 --- a/packages/domain/src/http/v2/device-credentials.ts +++ b/packages/domain/src/http/v2/device-credentials.ts @@ -18,6 +18,7 @@ import { V2RateLimited } from "./errors" import { publicError } from "./public-error" import { DeviceCredentialPublicId } from "./resource-ids" import { InstallationId } from "./widget-credentials" +import { ObserverAccess } from "../observer-access" /** * Device-bound credentials (DPoP, RFC 9449) for the mobile Home Screen widgets. @@ -188,28 +189,32 @@ export class V2DeviceCredentialsApiGroup extends HttpApiGroup.make("deviceCreden sessionRequired, persistence, ], - }).annotateMerge( - OpenApi.annotations({ - identifier: "registerDeviceCredential", - summary: "Register this installation's device key", - description: - "Registers the public half of a device-held P-256 key for this installation and organization, revoking the caller's previous key for it in the same transaction. Registering the same key again refreshes it: the rolling 30-day expiry moves out (never past 90 days from creation) and the roles are re-pinned to the caller's. Signed-in sessions only (API keys and device tokens get `session_required`). The request must also carry a `DPoP` proof signed by the submitted key (`htm` `PUT`, `htu` this URL, no `ath`), proving the caller holds it. An installation another member holds a live credential for answers `device_installation_in_use`.", - }), - ), + }) + .annotate(ObserverAccess, "personal") + .annotateMerge( + OpenApi.annotations({ + identifier: "registerDeviceCredential", + summary: "Register this installation's device key", + description: + "Registers the public half of a device-held P-256 key for this installation and organization, revoking the caller's previous key for it in the same transaction. Registering the same key again refreshes it: the rolling 30-day expiry moves out (never past 90 days from creation) and the roles are re-pinned to the caller's. Signed-in sessions only (API keys and device tokens get `session_required`). The request must also carry a `DPoP` proof signed by the submitted key (`htm` `PUT`, `htu` this URL, no `ath`), proving the caller holds it. An installation another member holds a live credential for answers `device_installation_in_use`.", + }), + ), ) .add( HttpApiEndpoint.delete("revoke", "/:installation_id", { params: { installation_id: InstallationId }, success: V2DeviceCredentialDeleteResponse, error: [sessionRequired, persistence], - }).annotateMerge( - OpenApi.annotations({ - identifier: "revokeDeviceCredential", - summary: "Revoke this installation's device key", - description: - "Revokes the caller's own device credential for this installation and organization. Tokens already issued for it stop working on their next request. Idempotent. Signed-in sessions only; there is no path to revoke another member's device (removing the member does that).", - }), - ), + }) + .annotate(ObserverAccess, "personal") + .annotateMerge( + OpenApi.annotations({ + identifier: "revokeDeviceCredential", + summary: "Revoke this installation's device key", + description: + "Revokes the caller's own device credential for this installation and organization. Tokens already issued for it stop working on their next request. Idempotent. Signed-in sessions only; there is no path to revoke another member's device (removing the member does that).", + }), + ), ) .add( HttpApiEndpoint.get("attestationChallenge", "/:installation_id/attestation_challenge", { @@ -247,14 +252,16 @@ export class V2DeviceTokensApiGroup extends HttpApiGroup.make("deviceTokens") V2RateLimited.schema, publicError(DeviceCredentialPersistenceError), ], - }).annotateMerge( - OpenApi.annotations({ - identifier: "issueDeviceToken", - summary: "Issue a DPoP-bound access token", - description: - "Exchanges a `DPoP` proof (RFC 9449: `typ` `dpop+jwt`, `alg` `ES256`, the registered key as `jwk`, `htm` `POST`, `htu` this URL, a unique `jti` and an `iat` within 60 seconds) for a one-hour access token bound to that key. Takes no bearer credential: the proof is the credential. Fails with `invalid_device_credential` when the key is unknown, revoked or expired.", - }), - ), + }) + .annotate(ObserverAccess, "personal") + .annotateMerge( + OpenApi.annotations({ + identifier: "issueDeviceToken", + summary: "Issue a DPoP-bound access token", + description: + "Exchanges a `DPoP` proof (RFC 9449: `typ` `dpop+jwt`, `alg` `ES256`, the registered key as `jwk`, `htm` `POST`, `htu` this URL, a unique `jti` and an `iat` within 60 seconds) for a one-hour access token bound to that key. Takes no bearer credential: the proof is the credential. Fails with `invalid_device_credential` when the key is unknown, revoked or expired.", + }), + ), ) .prefix("/v2/device_tokens") .annotateMerge( diff --git a/packages/domain/src/http/v2/integrations-chat.ts b/packages/domain/src/http/v2/integrations-chat.ts index 793b977d68..3307d82d2c 100644 --- a/packages/domain/src/http/v2/integrations-chat.ts +++ b/packages/domain/src/http/v2/integrations-chat.ts @@ -14,6 +14,7 @@ import { wireExample, Timestamp } from "./envelopes" import { V2CallbackHostUnavailable, V2InsufficientPermissions } from "./errors" import { publicErrors } from "./public-error" import { PublicId, PublicIdPrefixes } from "./public-id" +import { ObserverAccess } from "../observer-access" // The chat-platform surface: one contract for every chat platform Maple can // connect to. The platform is a value (`connector`), never a path segment or a @@ -389,14 +390,16 @@ export class V2ChatIntegrationsApiGroup extends HttpApiGroup.make("chatIntegrati params: { connector: ChatConnectorId }, success: V2ChatIdentityDeleteResponse, error: [V2InsufficientPermissions.schema, chatNotFound, chatPersistence], - }).annotateMerge( - OpenApi.annotations({ - identifier: "deleteChatIdentity", - summary: "Unlink your chat account", - description: - "Removes the link between your chat account and your Maple user on this connector. Your organization's workspaces stay linked; only your own account is forgotten, and nothing you do on the chat platform is attributed to you afterwards. Unlinking when you hold no link is not an error. Like linking, it must be called with a signed-in session rather than an API key. Requires the `integrations:write` scope.", - }), - ), + }) + .annotate(ObserverAccess, "personal") + .annotateMerge( + OpenApi.annotations({ + identifier: "deleteChatIdentity", + summary: "Unlink your chat account", + description: + "Removes the link between your chat account and your Maple user on this connector. Your organization's workspaces stay linked; only your own account is forgotten, and nothing you do on the chat platform is attributed to you afterwards. Unlinking when you hold no link is not an error. Like linking, it must be called with a signed-in session rather than an API key. Requires the `integrations:write` scope.", + }), + ), ) .add( HttpApiEndpoint.patch("updateWorkspace", "/chat_workspaces/:id", { diff --git a/packages/domain/src/http/v2/mcp-connections.ts b/packages/domain/src/http/v2/mcp-connections.ts index 2aeee220f9..6da5af4814 100644 --- a/packages/domain/src/http/v2/mcp-connections.ts +++ b/packages/domain/src/http/v2/mcp-connections.ts @@ -7,6 +7,7 @@ import { wireExample, ListOf, ListQuery, Timestamp } from "./envelopes" import { V2ParameterInvalid } from "./errors" import { publicError } from "./public-error" import { McpConnectionPublicId } from "./resource-ids" +import { ObserverAccess } from "../observer-access" const mcpConnectionExample = { id: "mcpc_p12S8T8agEHUkYc825nMz6", @@ -80,14 +81,16 @@ export class V2McpConnectionsApiGroup extends HttpApiGroup.make("mcpConnections" params: { id: McpConnectionPublicId }, success: V2McpConnection, error: [notFound, persistence], - }).annotateMerge( - OpenApi.annotations({ - identifier: "revokeMcpConnection", - summary: "Revoke an MCP connection", - description: - "Ends the connection and every access token it minted, and returns the final object. The client has to be approved again to reconnect. Members may revoke their own connections; org admins may revoke any. Requires the `mcp_connections:write` scope.", - }), - ), + }) + .annotate(ObserverAccess, "personal") + .annotateMerge( + OpenApi.annotations({ + identifier: "revokeMcpConnection", + summary: "Revoke an MCP connection", + description: + "Ends the connection and every access token it minted, and returns the final object. The client has to be approved again to reconnect. Members may revoke their own connections; org admins may revoke any. Requires the `mcp_connections:write` scope.", + }), + ), ) .prefix("/v2/mcp_connections") .middleware(AuthorizationV2) diff --git a/packages/domain/src/http/v2/mobile-devices.ts b/packages/domain/src/http/v2/mobile-devices.ts index b69ac1e2a4..46fa78a8f7 100644 --- a/packages/domain/src/http/v2/mobile-devices.ts +++ b/packages/domain/src/http/v2/mobile-devices.ts @@ -11,6 +11,7 @@ import { AuthorizationV2 } from "./auth" import { wireExample, ListOf, Timestamp } from "./envelopes" import { publicError } from "./public-error" import { AlertIncidentPublicId, MobileDevicePublicId } from "./resource-ids" +import { ObserverAccess } from "../observer-access" // Code points, not UTF-16 units: identical for these ASCII values, and the only length the // JSON Schema `minLength` can state exactly (Effect halves a UTF-16 bound when it exports one). @@ -194,28 +195,32 @@ export class V2MobileDevicesApiGroup extends HttpApiGroup.make("mobileDevices") payload: V2MobileDeviceRegisterParams, success: V2MobileDevice, error: [persistence], - }).annotateMerge( - OpenApi.annotations({ - identifier: "registerMobileDevice", - summary: "Register or refresh a device", - description: - "Upserts the device identified by its push token for the current user and organization, re-enabling it if the platform had marked it dead. Requires the `mobile_devices:write` scope.", - }), - ), + }) + .annotate(ObserverAccess, "personal") + .annotateMerge( + OpenApi.annotations({ + identifier: "registerMobileDevice", + summary: "Register or refresh a device", + description: + "Upserts the device identified by its push token for the current user and organization, re-enabling it if the platform had marked it dead. Requires the `mobile_devices:write` scope.", + }), + ), ) .add( HttpApiEndpoint.delete("unregister", "/:token", { params: { token: DeviceToken }, success: V2MobileDeviceDeleteResponse, error: [notFound, persistence], - }).annotateMerge( - OpenApi.annotations({ - identifier: "unregisterMobileDevice", - summary: "Unregister a device", - description: - "Removes the device from the current organization; the app calls this on sign-out. Requires the `mobile_devices:write` scope.", - }), - ), + }) + .annotate(ObserverAccess, "personal") + .annotateMerge( + OpenApi.annotations({ + identifier: "unregisterMobileDevice", + summary: "Unregister a device", + description: + "Removes the device from the current organization; the app calls this on sign-out. Requires the `mobile_devices:write` scope.", + }), + ), ) .add( HttpApiEndpoint.put("registerLiveActivity", "/:token/live_activities/:incident_id", { @@ -223,28 +228,32 @@ export class V2MobileDevicesApiGroup extends HttpApiGroup.make("mobileDevices") payload: V2LiveActivityRegisterParams, success: V2LiveActivity, error: [notFound, persistence], - }).annotateMerge( - OpenApi.annotations({ - identifier: "registerLiveActivity", - summary: "Register a Live Activity update token", - description: - "Records the APNs update token for a Live Activity the device started for this incident, so Maple can update and end it. Requires the `mobile_devices:write` scope.", - }), - ), + }) + .annotate(ObserverAccess, "personal") + .annotateMerge( + OpenApi.annotations({ + identifier: "registerLiveActivity", + summary: "Register a Live Activity update token", + description: + "Records the APNs update token for a Live Activity the device started for this incident, so Maple can update and end it. Requires the `mobile_devices:write` scope.", + }), + ), ) .add( HttpApiEndpoint.delete("endLiveActivity", "/:token/live_activities/:incident_id", { params: { token: DeviceToken, incident_id: AlertIncidentPublicId }, success: V2LiveActivityDeleteResponse, error: [notFound, persistence], - }).annotateMerge( - OpenApi.annotations({ - identifier: "endLiveActivity", - summary: "Forget a Live Activity", - description: - "Called when the activity ended on the device — dismissed by the user or ended by the app — so Maple stops pushing to a dead token. Requires the `mobile_devices:write` scope.", - }), - ), + }) + .annotate(ObserverAccess, "personal") + .annotateMerge( + OpenApi.annotations({ + identifier: "endLiveActivity", + summary: "Forget a Live Activity", + description: + "Called when the activity ended on the device — dismissed by the user or ended by the app — so Maple stops pushing to a dead token. Requires the `mobile_devices:write` scope.", + }), + ), ) .prefix("/v2/mobile_devices") .middleware(AuthorizationV2) diff --git a/packages/domain/src/http/v2/widget-credentials.ts b/packages/domain/src/http/v2/widget-credentials.ts index 2c9bc656b2..dd1a91d729 100644 --- a/packages/domain/src/http/v2/widget-credentials.ts +++ b/packages/domain/src/http/v2/widget-credentials.ts @@ -5,6 +5,7 @@ import { OrgId } from "@maple/primitives" import { AuthorizationV2 } from "./auth" import { wireExample, Timestamp } from "./envelopes" import { publicError } from "./public-error" +import { ObserverAccess } from "../observer-access" /** * The credential a device's Home Screen widgets fetch with. @@ -98,28 +99,32 @@ export class V2WidgetCredentialsApiGroup extends HttpApiGroup.make("widgetCreden params: { installation_id: InstallationId }, success: V2WidgetCredential, error: [publicError(ApiKeyPersistenceError)], - }).annotateMerge( - OpenApi.annotations({ - identifier: "mintWidgetCredential", - summary: "Mint this installation's widget credential", - description: - "Issues a read-only, expiring credential for this installation's Home Screen widgets, revoking whatever it had. Idempotent, so the app calls it again to roll. Requires the `widget_credentials:write` scope — which a widget credential does not have, so renewal always goes through a signed-in session.", - }), - ), + }) + .annotate(ObserverAccess, "personal") + .annotateMerge( + OpenApi.annotations({ + identifier: "mintWidgetCredential", + summary: "Mint this installation's widget credential", + description: + "Issues a read-only, expiring credential for this installation's Home Screen widgets, revoking whatever it had. Idempotent, so the app calls it again to roll. Requires the `widget_credentials:write` scope — which a widget credential does not have, so renewal always goes through a signed-in session.", + }), + ), ) .add( HttpApiEndpoint.delete("revoke", "/:installation_id", { params: { installation_id: InstallationId }, success: V2WidgetCredentialDeleteResponse, error: [publicError(ApiKeyPersistenceError)], - }).annotateMerge( - OpenApi.annotations({ - identifier: "revokeWidgetCredential", - summary: "Revoke this installation's widget credential", - description: - "Retires the installation's credential for this organization; the app calls this on sign-out and when the user leaves the organization. Idempotent — an installation with nothing to revoke is already in the requested state. Requires the `widget_credentials:write` scope.", - }), - ), + }) + .annotate(ObserverAccess, "personal") + .annotateMerge( + OpenApi.annotations({ + identifier: "revokeWidgetCredential", + summary: "Revoke this installation's widget credential", + description: + "Retires the installation's credential for this organization; the app calls this on sign-out and when the user leaves the organization. Idempotent — an installation with nothing to revoke is already in the requested state. Requires the `widget_credentials:write` scope.", + }), + ), ) .prefix("/v2/widget_credentials") .middleware(AuthorizationV2) diff --git a/packages/domain/src/policy/index.ts b/packages/domain/src/policy/index.ts index 1da45f857b..d31d176803 100644 --- a/packages/domain/src/policy/index.ts +++ b/packages/domain/src/policy/index.ts @@ -51,6 +51,13 @@ export const check = ( ): Effect.Effect => can(roles, permission) ? Effect.void : Effect.fail(onDenied(denialFor(permission))) +/** Whether the roles may only read: an observer, with no higher role beside it. */ +export const isReadOnly = (roles: ReadonlyArray): boolean => highestRole(roles) === "observer" + +/** What an observer is told when a change is refused, on every surface. */ +export const readOnlyMessage = + "Observers can read Maple but not change it. Ask an org admin for the Member role to make changes." + /** Every permission the roles hold, for a page model to keep instead of role flags. */ export const grantsOf = (roles: ReadonlyArray): ReadonlyArray => Arr.filter(allPermissions, (permission) => can(roles, permission))