Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions packages/domain/src/policy/policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 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([])
}
})

it("uses the highest of several roles", () => {
expect(Policy.can(["org:member", "org:admin"], "billing:manage")).toBe(true)
})
Expand Down Expand Up @@ -59,6 +67,21 @@ describe("Policy.check", () => {
}),
)

it.effect("tells an analyst which roles hold a member permission", () =>
Effect.gen(function* () {
const error = yield* Effect.flip(
Policy.check(
["org:analyst"],
"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(
Expand Down Expand Up @@ -95,4 +118,27 @@ describe("roleOf", () => {
"admin",
])
})

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, "analyst")).toBe(true)
}
})
})

describe("highestRole", () => {
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 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 analyst", () => {
const pinned = Policy.highestRole(["org:member"])
expect(Policy.outranks(pinned, Policy.highestRole(["org:analyst"]))).toBe(true)
})
})
48 changes: 37 additions & 11 deletions packages/domain/src/policy/roles.ts
Original file line number Diff line number Diff line change
@@ -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(["analyst", "member", "engineer", "admin", "root"])
export type Role = typeof Role.Type

const RANK = { member: 0, engineer: 1, admin: 2, root: 3 } as const satisfies Record<Role, number>
const RANK = {
analyst: 0,
member: 1,
engineer: 2,
admin: 3,
root: 4,
} as const satisfies Record<Role, number>

const byRank: Order.Order<Role> = 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 = [
Expand All @@ -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. */
/** 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 = [analystRole, ...assignableRoles] as const

/**
* A Clerk or session role string as a {@link Role}. A role this code does not know reads as
* analyst, 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 => "analyst",
)
}

/** 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<string>): 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]
Expand All @@ -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 "analyst":
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":
Expand Down
Loading