Semilattices in the real world: combining and capping permissions with fp-ts
Permission systems usually start out small. A user logs in, a document opens up, and the request needs to know what they may do with it. The first sketch is usually a single role mapped to a fixed list of capabilities, and for a year or two it works perfectly. Then the org chart grows. The user belongs to the team that owns the file, which grants a baseline. They hold a role on it, which grants a little more. And a colleague has shared it with them directly, with whatever capabilities the colleague chose to give. By the time the third source arrives the request has stopped asking what the user's role is and started asking what every source they draw on adds up to, held to whatever this document permits. An archived file stays locked to reading and commenting however much the user's sources pile on top.
Written by hand the resolver works. Start from an empty set, walk the sources folding their capabilities in,
then drop anything the document forbids. There is nothing wrong with that loop. But combining and capping are
not bookkeeping the resolver has to invent: they are two operations a set of permissions already supports, and
one permission already knows how it sits relative to another. Once we write those facts down, resolution reads
as two named steps and the hand-written loop never gets written. fp-ts
gives each direction its own little instance: a JoinSemilattice for combining grants, a
MeetSemilattice for capping them, and together they make a lattice.
Modeling the domain
A capability is more than a bare name. Support needs a label to read on an audit, and the capabilities line up
from least to most privileged, so reading sits below deleting. Those are two facts a string cannot hold, so
the name becomes a kind and the capability becomes a small type wrapped around it, the way
the Bounded post promoted a discount's kind
to a Promotion. We list the kinds once, least to most privileged, and read the kind type
straight off them:
// src/capability.ts
import { pipe } from 'fp-ts/function'
import * as Eq from 'fp-ts/Eq'
import * as O from 'fp-ts/Ord'
import * as N from 'fp-ts/number'
import * as S from 'fp-ts/string'
/**
* every kind of capability, listed once from least to most privileged
*/
const kinds = ['read', 'comment', 'write', 'delete'] as const
export type CapabilityKind = (typeof kinds)[number]
/**
* a capability is a kind together with a label support can read
*/
export interface Capability {
readonly kind: CapabilityKind
readonly label: string
}
One value per kind lives in a registry keyed by the kind itself. Because the key type is
CapabilityKind, the compiler refuses to let a kind exist without a value, or a value without a
kind, so the two can never drift. The full roster falls straight out of it:
// src/capability.ts
/**
* one value per kind, reached by name from anywhere
*/
export const capabilities: Record<CapabilityKind, Capability> = {
read: { kind: 'read', label: 'Read' },
comment: { kind: 'comment', label: 'Comment' },
write: { kind: 'write', label: 'Write' },
delete: { kind: 'delete', label: 'Delete' },
}
/**
* the full roster of capabilities, derived from the registry
*/
export const all: ReadonlySet<Capability> = new Set(Object.values(capabilities))
Now the instances, each named for what it means rather than which typeclass it is. Two capabilities are the
same when their kinds match, whatever else they carry, so sameKind borrows the string Eq and
reads the kind off each one through the
contramap we already know.
byPrivilege borrows N.Ord the same way, ranking each capability by where its kind
sits in the list:
// src/capability.ts
/**
* two capabilities are the same when their kinds match
*/
export const sameKind: Eq.Eq<Capability> = pipe(
S.Eq,
Eq.contramap((capability: Capability) => capability.kind),
)
/**
* capabilities are ranked least to most privileged, by position in the kinds list
*/
export const byPrivilege: O.Ord<Capability> = pipe(
N.Ord,
O.contramap((capability: Capability) => kinds.indexOf(capability.kind)),
)
A permission is a set of those capabilities, so a role grant and a resource's allowance turn out to be the same shape. It lives in its own module, consuming the capability one as a namespace:
// src/permission.ts
import * as Capability from './capability'
/**
* a grant and an allowance are the same shape: a set of capabilities
*/
export type Permission = ReadonlySet<Capability.Capability>
A capability is an object now, so a set cannot tell two of them apart on its own; it needs to know that two
values with the same kind are one capability. That is what Capability.sameKind carries, and it
is what the set operations ahead lean on.
Join and meet
Sets come with a natural ordering that has nothing to do with size: one permission sits below another when
every capability it holds is also in the other, that is, when it is a subset. {read} sits below
{read, write}; {write} and {delete} sit side by side, neither below
the other. That last part is the break from the discount rates we ordered in
the Bounded post, which all lay on a single
line. Permissions branch.
On an ordering like that, two values still have a least upper bound, the smallest permission that sits above
both, and a greatest lower bound, the largest that sits below both. fp-ts names those two operations in
separate modules. JoinSemilattice supplies the upper one:
interface JoinSemilattice<A> {
readonly join: (x: A, y: A) => A
}
For permissions the least upper bound is the union: the smallest set that contains everything in either grant.
MeetSemilattice supplies the lower one:
interface MeetSemilattice<A> {
readonly meet: (x: A, y: A) => A
}
And there the greatest lower bound is the intersection: the largest set contained in both.
Each gets a bounded version that adds the edge of the order, the way Bounded added top and
bottom to an Ord. A join climbs, so its floor is the empty permission, named
zero:
interface BoundedJoinSemilattice<A> extends JoinSemilattice<A> {
readonly zero: A
}
A meet descends, so its ceiling is the full permission, named one:
interface BoundedMeetSemilattice<A> extends MeetSemilattice<A> {
readonly one: A
}
Nothing here redefines what the operations mean; each bounded interface inherits its operation and only pins
down where the order ends. On a totally ordered type, where every value lies on one line, join
collapses to max and meet to min, exactly the pair the Bounded post
leaned on. Permissions branch instead of lining up, so we reach for the general version: the most permissive
combination from join, the most restrictive from meet.
Combining grants with join
Start with the upper direction. Pooling two grants keeps every capability either source allows, which is the
union, and the empty permission is the floor a user starts from before any source applies. fp-ts ships a
union for ReadonlySet; hand it Capability.sameKind so it can tell
members apart, and the bounded join semilattice is that union with its floor attached:
// src/permission.ts
import * as RS from 'fp-ts/ReadonlySet'
import { BoundedJoinSemilattice } from 'fp-ts/BoundedJoinSemilattice'
import * as Capability from './capability'
const union = RS.union(Capability.sameKind)
/**
* nothing granted before any source applies
*/
export const none: Permission = RS.empty
/**
* pooling grants keeps every capability either source allows
*/
export const grant: BoundedJoinSemilattice<Permission> = {
join: union,
zero: none,
}
We never wrote join by hand. The union came from the ReadonlySet module; we only
named the empty value that marks the floor.
A user draws on a list of sources, not a tidy pair, so we need to fold the whole list into one grant.
The Monoids post built that fold out of a
concat and an empty, and a bounded join semilattice already is one:
join is an associative concat and zero is its empty. So
it drops straight into the same concatAll:
// src/permission.ts
import * as M from 'fp-ts/Monoid'
/**
* join concats, zero is empty: a bounded join semilattice is a Monoid
*/
const grantMonoid: M.Monoid<Permission> = {
concat: grant.join,
empty: grant.zero,
}
/**
* fold every source's grant into a single grant
*/
export const earned = M.concatAll(grantMonoid)
Each source the user draws on is a declared grant: the team baseline, the role, the colleague's share. The fold pools them all into one, and the empty list has an honest answer instead of an edge case:
import * as assert from 'node:assert'
import { capabilities as C } from './capability'
import * as Permission from './permission'
/**
* the owning team grants every member a baseline
*/
const teamGrant: Permission.Permission = new Set([C.read, C.comment])
/**
* the user's role on the document grants more
*/
const editorRole: Permission.Permission = new Set([C.read, C.comment, C.write])
/**
* a colleague shared the document with them, with deleting allowed
*/
const sharedGrant: Permission.Permission = new Set([C.read, C.delete])
const pooled = Permission.earned([teamGrant, editorRole, sharedGrant])
const noSources = Permission.earned([])
// every capability either source allows
assert.deepStrictEqual(pooled, new Set([C.read, C.comment, C.write, C.delete]))
// the empty set, the zero floor: no sources, no access
assert.deepStrictEqual(noSources, new Set())
Capping with meet
The fold answers what a user's sources add up to. It does not answer what a particular document will allow, and those are different questions: a user can hold the Editor role everywhere and still land on a file that is read-only for everyone. That ceiling is the lower direction. Capping keeps only the capabilities both the grant and the resource allow, which is the intersection, and the full permission is the ceiling a resource starts from when it restricts nothing. The bounded meet semilattice is that intersection with its ceiling attached:
// src/permission.ts
import * as RS from 'fp-ts/ReadonlySet'
import { BoundedMeetSemilattice } from 'fp-ts/BoundedMeetSemilattice'
import * as Capability from './capability'
const intersection = RS.intersection(Capability.sameKind)
/**
* capping keeps only the capabilities both sides allow
*/
export const cap: BoundedMeetSemilattice<Permission> = {
meet: intersection,
one: Capability.all,
}
one is Capability.all, the full roster from the capability module. Intersecting any
grant with the full roster leaves it untouched, which is exactly what a resource that restricts nothing
should do.
Resolution is the two directions in order: fold the roles up with join, then hold the result
down with meet. The function is curried so the resource's cap is supplied first and a list of
roles pipes through second, and a resource that restricts nothing earns its own short name through partial
application:
// src/permission.ts
import { pipe } from 'fp-ts/function'
/**
* everything a user's roles earn, held to what the resource allows;
* curried so the cap is supplied first and roles flow through second
*/
export const resolve =
(allowed: Permission) =>
(roles: ReadonlyArray<Permission>): Permission =>
pipe(
roles,
earned,
(granted) => cap.meet(granted, allowed),
)
/**
* a resource that restricts nothing: partial application names the helper
*/
export const resolveUncapped = resolve(cap.one)
The cap holds however the roles stack, and the empty case still has a valid answer:
import * as assert from 'node:assert'
import { pipe } from 'fp-ts/function'
import { capabilities as C } from './capability'
import * as Permission from './permission'
const teamGrant: Permission.Permission = new Set([C.read, C.comment])
const editorRole: Permission.Permission = new Set([C.read, C.comment, C.write])
const sharedGrant: Permission.Permission = new Set([C.read, C.delete])
/**
* an archived document: readable and commentable, nothing more
*/
const archived: Permission.Permission = new Set([C.read, C.comment])
const effective = pipe(
[teamGrant, editorRole, sharedGrant],
Permission.resolve(archived),
)
const denied = pipe(
[],
Permission.resolve(archived),
)
// write and delete are capped away, however the sources stack
assert.deepStrictEqual(effective, new Set([C.read, C.comment]))
// no sources resolves to none, deny by default
assert.deepStrictEqual(denied, new Set())
Explaining the result
This is where the richer capability earns its keep. A resolved permission is a set, which has no order of its
own, but a capability does: byPrivilege, the ranking we built earlier. Sorting the set through
it and reading off each label turns the answer into something support can put in front of a person, least
privilege first:
// src/permission.ts
import * as RA from 'fp-ts/ReadonlyArray'
import * as RS from 'fp-ts/ReadonlySet'
import * as Capability from './capability'
/**
* list what a permission allows, least to most privileged
*/
export const describe = (permission: Permission): ReadonlyArray<string> =>
pipe(
permission,
RS.toReadonlyArray(Capability.byPrivilege),
RA.map((capability) => capability.label),
)
import * as assert from 'node:assert'
import * as Permission from './permission'
const labels = Permission.describe(effective)
// least to most privileged, the order a person can read
assert.deepStrictEqual(labels, ['Read', 'Comment'])
The label and the order were never needed to resolve access; join and meet ran on
the kind alone. They are what lets the same value explain itself afterward, the way
the Bounded post carried a kind so support
could see why a rate had applied.
A new capability
Resolution has been live for a few weeks when product adds sharing. A user should be able to hand a document
to someone else, so share joins the kinds, in its place in the privilege order, and a matching
value joins the registry:
// src/capability.ts
/**
* the new kind takes its place in the privilege order
*/
const kinds = ['read', 'comment', 'write', 'delete', 'share'] as const
export const capabilities: Record<CapabilityKind, Capability> = {
// ...the existing four...
share: { kind: 'share', label: 'Share' },
}
The compiler makes those two edits travel together: a kind in the list with no value in the registry, or a
value with no kind, fails to type-check, because the registry's key type is CapabilityKind.
Everything else follows on its own. union and intersection already combine and cap
whatever a set holds, so grant, cap, resolve, and
describe do not move. The detail worth pausing on is one: because all
is built from the registry, the meet's ceiling widens to admit share on its own, and so does
resolveUncapped, the partial application built from it. Spell the roster out by hand anywhere
and an uncapped request would quietly strip the new capability until you remembered to update it; reading
it off the registry is what keeps the ceiling honest.
import * as assert from 'node:assert'
import { pipe } from 'fp-ts/function'
import { capabilities as C } from './capability'
import * as Permission from './permission'
const editorRole: Permission.Permission = new Set([C.read, C.comment, C.write])
/**
* a colleague shared the document, this time with re-sharing allowed
*/
const sharedGrant: Permission.Permission = new Set([C.read, C.share])
/**
* a live document places no cap of its own, so the uncapped partial application
* is exactly the right helper
*/
const live = pipe(
[editorRole, sharedGrant],
Permission.resolveUncapped,
)
// share flows through; nothing in resolve had to move
assert.deepStrictEqual(live, new Set([C.read, C.comment, C.write, C.share]))
Conclusion
The two semilattices give a permission two directions to move. join climbs: it folds every grant
a user collects into the most permissive combination, starting from zero, the empty set that
means no access. meet descends: it holds that combination down to what a resource allows,
starting from one, the full set that caps nothing. Resolution is the two in sequence, and each
is a named operation a reader can find and test on its own rather than a branch buried in a loop.
The earlier posts ranked products in the sorting post,
adapted primitives across types in the
contramap post, merged whole entities in
the Monoids post, and gave a value its limits
in the Bounded post. Bounded's
max and min were this same pair seen on a single line; permissions branch instead
of lining up, so we reached for the general join and meet. The pattern holds: define small pieces with care,
and let composition do the heavy lifting.