From bdcd2bc9611403b9f22f0a148d8a0115a6643e27 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 00:15:15 +0100 Subject: [PATCH 1/2] feat(policy): add the observer role below member Observer is a read-only org role for people who follow the product without operating it. It ranks below member, so every permission in the table already excludes it. A role string this code does not know now reads as observer, the least a session can hold, where it read as member. A tenant with no roles still reads as member: only Maple's own agents arrive without one. Nothing assigns the role yet, and the Members page does not offer it. --- packages/domain/src/policy/policy.test.ts | 50 ++++++++++++++++++++++- packages/domain/src/policy/roles.ts | 48 +++++++++++++++++----- 2 files changed, 85 insertions(+), 13 deletions(-) diff --git a/packages/domain/src/policy/policy.test.ts b/packages/domain/src/policy/policy.test.ts index 13167e3b1b..ae4febfa64 100644 --- a/packages/domain/src/policy/policy.test.ts +++ b/packages/domain/src/policy/policy.test.ts @@ -25,12 +25,20 @@ describe("Policy.can", () => { } }) - it("reads members, unknown Clerk roles and no roles as member", () => { - for (const roles of [["org:member"], ["org:billing"], []]) { + it("gives members and a tenant with no roles the member permissions, and no more", () => { + for (const roles of [["org:member"], []]) { + expect(Policy.can(roles, "ingest_keys:view_public")).toBe(true) expect(Policy.can(roles, "alerts:manage")).toBe(false) } }) + it("holds nothing a member has for an observer or a Clerk role it does not know", () => { + for (const roles of [["org:observer"], ["org:billing"]]) { + expect(Policy.can(roles, "ingest_keys:view_public")).toBe(false) + expect(Policy.grantsOf(roles)).toEqual([]) + } + }) + it("uses the highest of several roles", () => { expect(Policy.can(["org:member", "org:admin"], "billing:manage")).toBe(true) }) @@ -59,6 +67,21 @@ describe("Policy.check", () => { }), ) + it.effect("tells an observer which roles hold a member permission", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + Policy.check( + ["org:observer"], + "ingest_keys:view_public", + (denial) => new Forbidden({ message: denial.message }), + ), + ) + expect(error.message).toBe( + "Only org admins, engineers and members can view the public ingest key.", + ) + }), + ) + it.effect("names only admins for an admin permission", () => Effect.gen(function* () { const error = yield* Effect.flip( @@ -95,4 +118,27 @@ describe("roleOf", () => { "admin", ]) }) + + it("reads the observer key as observer, below every assignable role", () => { + expect(Policy.roleOf("org:observer")).toBe("observer") + for (const entry of Policy.assignableRoles) { + expect(Policy.outranks(entry.role, "observer")).toBe(true) + } + }) +}) + +describe("highestRole", () => { + it("keeps an observer an observer instead of lifting them to member", () => { + expect(Policy.highestRole(["org:observer"])).toBe("observer") + expect(Policy.highestRole(["org:observer", "org:billing"])).toBe("observer") + }) + + it("takes the highest when an observer role sits beside a higher one", () => { + expect(Policy.highestRole(["org:observer", "org:engineer"])).toBe("engineer") + }) + + it("retires a member-pinned credential when its owner becomes an observer", () => { + const pinned = Policy.highestRole(["org:member"]) + expect(Policy.outranks(pinned, Policy.highestRole(["org:observer"]))).toBe(true) + }) }) diff --git a/packages/domain/src/policy/roles.ts b/packages/domain/src/policy/roles.ts index 0904f7c53a..db865fd59a 100644 --- a/packages/domain/src/policy/roles.ts +++ b/packages/domain/src/policy/roles.ts @@ -1,14 +1,22 @@ -import { Array as Arr, Option, Schema } from "effect" +import { Array as Arr, Option, Order, Schema } from "effect" /** * The org roles, lowest first. Each one holds every permission of the roles below it, so a * permission names only its lowest role. `root` is Maple's own operator access and self-hosted * mode; it is never assigned in Clerk. */ -export const Role = Schema.Literals(["member", "engineer", "admin", "root"]) +export const Role = Schema.Literals(["observer", "member", "engineer", "admin", "root"]) export type Role = typeof Role.Type -const RANK = { member: 0, engineer: 1, admin: 2, root: 3 } as const satisfies Record +const RANK = { + observer: 0, + member: 1, + engineer: 2, + admin: 3, + root: 4, +} as const satisfies Record + +const byRank: Order.Order = Order.mapInput(Order.Number, (role) => RANK[role]) /** What the Members page shows for a role a person can be given in Clerk. */ export const assignableRoles = [ @@ -35,23 +43,39 @@ export const assignableRoles = [ export type AssignableRole = (typeof assignableRoles)[number] export type ClerkRoleKey = AssignableRole["clerkKey"] -/** A Clerk or session role string as a {@link Role}. Anything unknown reads as member, the safe reading. */ +/** Read-only, for people who follow the product without operating it. Not offered on the Members page yet. */ +const observerRole = { + role: "observer", + clerkKey: "org:observer", + label: "Observer", + summary: "Follows incidents, reliability, product analytics and cost. Changes nothing.", +} as const satisfies { role: Role; clerkKey: string; label: string; summary: string } + +const clerkRoles = [observerRole, ...assignableRoles] as const + +/** + * A Clerk or session role string as a {@link Role}. A role this code does not know reads as + * observer, the least it can hold. + */ export const roleOf = (name: string): Role => { if (name === "root") return "root" return Option.getOrElse( Option.map( - Arr.findFirst(assignableRoles, (entry) => entry.clerkKey === name), + Arr.findFirst(clerkRoles, (entry) => entry.clerkKey === name), (entry): Role => entry.role, ), - (): Role => "member", + (): Role => "observer", ) } -/** The highest role among a session's role strings; `member` for none. */ +/** + * The highest role among a session's role strings. No roles at all reads as member: a person's + * Clerk membership always carries one, so only Maple's own agents arrive without. + */ export const highestRole = (roles: ReadonlyArray): Role => - Arr.reduce(roles, "member" as Role, (best, name) => { - const role = roleOf(name) - return RANK[role] > RANK[best] ? role : best + Arr.match(Arr.map(roles, roleOf), { + onEmpty: (): Role => "member", + onNonEmpty: Arr.max(byRank), }) export const isAtLeast = (role: Role, min: Role): boolean => RANK[role] >= RANK[min] @@ -62,8 +86,10 @@ export const outranks = (role: Role, other: Role): boolean => RANK[role] > RANK[ /** "org admins and engineers": who holds a permission whose lowest role is `min`, for messages. */ export const holdersPhrase = (min: Role): string => { switch (min) { + case "observer": + return "people in the organization" case "member": - return "org members" + return "org admins, engineers and members" case "engineer": return "org admins and engineers" case "admin": From 6fab3c23bfa7d71a3be7aac48c34bce9144c710b Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 12:11:36 +0100 Subject: [PATCH 2/2] refactor(policy): name the lowest role analyst The role also builds dashboards, so observer undersold it. Key org:analyst. --- packages/domain/src/policy/policy.test.ts | 28 +++++++++++------------ packages/domain/src/policy/roles.ts | 24 +++++++++---------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/domain/src/policy/policy.test.ts b/packages/domain/src/policy/policy.test.ts index ae4febfa64..ca5294c9f7 100644 --- a/packages/domain/src/policy/policy.test.ts +++ b/packages/domain/src/policy/policy.test.ts @@ -32,8 +32,8 @@ describe("Policy.can", () => { } }) - it("holds nothing a member has for an observer or a Clerk role it does not know", () => { - for (const roles of [["org:observer"], ["org:billing"]]) { + it("holds nothing a member has for an analyst or a Clerk role it does not know", () => { + for (const roles of [["org:analyst"], ["org:billing"]]) { expect(Policy.can(roles, "ingest_keys:view_public")).toBe(false) expect(Policy.grantsOf(roles)).toEqual([]) } @@ -67,11 +67,11 @@ describe("Policy.check", () => { }), ) - it.effect("tells an observer which roles hold a member permission", () => + it.effect("tells an analyst which roles hold a member permission", () => Effect.gen(function* () { const error = yield* Effect.flip( Policy.check( - ["org:observer"], + ["org:analyst"], "ingest_keys:view_public", (denial) => new Forbidden({ message: denial.message }), ), @@ -119,26 +119,26 @@ describe("roleOf", () => { ]) }) - it("reads the observer key as observer, below every assignable role", () => { - expect(Policy.roleOf("org:observer")).toBe("observer") + it("reads the analyst key as analyst, below every assignable role", () => { + expect(Policy.roleOf("org:analyst")).toBe("analyst") for (const entry of Policy.assignableRoles) { - expect(Policy.outranks(entry.role, "observer")).toBe(true) + expect(Policy.outranks(entry.role, "analyst")).toBe(true) } }) }) describe("highestRole", () => { - it("keeps an observer an observer instead of lifting them to member", () => { - expect(Policy.highestRole(["org:observer"])).toBe("observer") - expect(Policy.highestRole(["org:observer", "org:billing"])).toBe("observer") + it("keeps an analyst an analyst instead of lifting them to member", () => { + expect(Policy.highestRole(["org:analyst"])).toBe("analyst") + expect(Policy.highestRole(["org:analyst", "org:billing"])).toBe("analyst") }) - it("takes the highest when an observer role sits beside a higher one", () => { - expect(Policy.highestRole(["org:observer", "org:engineer"])).toBe("engineer") + it("takes the highest when an analyst role sits beside a higher one", () => { + expect(Policy.highestRole(["org:analyst", "org:engineer"])).toBe("engineer") }) - it("retires a member-pinned credential when its owner becomes an observer", () => { + it("retires a member-pinned credential when its owner becomes an analyst", () => { const pinned = Policy.highestRole(["org:member"]) - expect(Policy.outranks(pinned, Policy.highestRole(["org:observer"]))).toBe(true) + expect(Policy.outranks(pinned, Policy.highestRole(["org:analyst"]))).toBe(true) }) }) diff --git a/packages/domain/src/policy/roles.ts b/packages/domain/src/policy/roles.ts index db865fd59a..d390c2c72a 100644 --- a/packages/domain/src/policy/roles.ts +++ b/packages/domain/src/policy/roles.ts @@ -5,11 +5,11 @@ import { Array as Arr, Option, Order, Schema } from "effect" * permission names only its lowest role. `root` is Maple's own operator access and self-hosted * mode; it is never assigned in Clerk. */ -export const Role = Schema.Literals(["observer", "member", "engineer", "admin", "root"]) +export const Role = Schema.Literals(["analyst", "member", "engineer", "admin", "root"]) export type Role = typeof Role.Type const RANK = { - observer: 0, + analyst: 0, member: 1, engineer: 2, admin: 3, @@ -43,19 +43,19 @@ export const assignableRoles = [ export type AssignableRole = (typeof assignableRoles)[number] export type ClerkRoleKey = AssignableRole["clerkKey"] -/** Read-only, for people who follow the product without operating it. Not offered on the Members page yet. */ -const observerRole = { - role: "observer", - clerkKey: "org:observer", - label: "Observer", - summary: "Follows incidents, reliability, product analytics and cost. Changes nothing.", +/** For people who follow the product and build dashboards without operating it. Not offered on the Members page yet. */ +const analystRole = { + role: "analyst", + clerkKey: "org:analyst", + label: "Analyst", + summary: "Follows incidents, reliability, product analytics and cost, and builds dashboards and funnels.", } as const satisfies { role: Role; clerkKey: string; label: string; summary: string } -const clerkRoles = [observerRole, ...assignableRoles] as const +const clerkRoles = [analystRole, ...assignableRoles] as const /** * A Clerk or session role string as a {@link Role}. A role this code does not know reads as - * observer, the least it can hold. + * analyst, the least it can hold. */ export const roleOf = (name: string): Role => { if (name === "root") return "root" @@ -64,7 +64,7 @@ export const roleOf = (name: string): Role => { Arr.findFirst(clerkRoles, (entry) => entry.clerkKey === name), (entry): Role => entry.role, ), - (): Role => "observer", + (): Role => "analyst", ) } @@ -86,7 +86,7 @@ export const outranks = (role: Role, other: Role): boolean => RANK[role] > RANK[ /** "org admins and engineers": who holds a permission whose lowest role is `min`, for messages. */ export const holdersPhrase = (min: Role): string => { switch (min) { - case "observer": + case "analyst": return "people in the organization" case "member": return "org admins, engineers and members"