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":