Skip to content
Open
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 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)
})
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
})
})
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(["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<Role, number>
const RANK = {
observer: 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 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. */
Comment thread
Makisuo marked this conversation as resolved.
Resolved
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

Check warning on line 54 in packages/domain/src/policy/roles.ts

View check run for this annotation

Maple Review Bot / Maple / review

correctness: An `observer` member renders as Member in the Members page and cannot be promoted to member

`roleOf("org:observer")` now answers `observer`, but `observerRole` stays out of `assignableRoles`, so `entryOf` (`apps/web/src/page/settings/kit/org-membership.ts:13`) finds no entry and falls back to the member one: the row reads "Member" with `org:member` preselected for someone who holds no grants. Picking Member then equals `toOrgRole(member.role)`, so `members/update.ts:585` drops it as a no-op.

/**
* 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<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 @@
/** "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":
Expand Down
Loading