Entitlements that fail closed.
In Fractional Coordinator’s client portal, access lives on the membership, unknown grants mean nothing, and a route that forgets to declare a grant is locked, not open.
Fractional Coordinator’s client portal serves institutions under civil rights compliance engagements. Each institution sees only its own engagements, files and investigations. Inside an institution, not every member sees every tab, and some tabs are unlocked by buying a course, a Knowledge Hub membership or a Playbook license.
That is three kinds of rule stacked together: tenancy, per‑member access and purchases. The mistakes in this kind of system are rarely in the rules themselves. They are in where the rules live, and in what happens when someone forgets one.
Three ways it goes wrong
- Gating in the interface. Hiding a tab is not access control. A Next.js layout does not even re‑run on client navigation, and the API behind the tab answers whoever asks.
- Grants on the user. People change institutions. If access hangs off the user, it follows them to the next one.
- Open by default. A new endpoint ships without a check, and nothing fails. The reference design I inherited treated every tab as granted, which made half the permission matrix decorative.
Grants hang off the membership
A grant row points at the organization membership, not the user, and cascades with it. Deleting a membership deletes every grant it held in the same statement, and deactivating one cuts access at once, because the guard only resolves active memberships. There is no clean‑up job to forget.
export const entitlementGrant = pgTable('entitlement_grant', {
id: text('id').primaryKey(),
organizationMemberId: text('organization_member_id')
.notNull()
.references(() => organizationMember.id, { onDelete: 'cascade' }),
grantKey: text('grant_key').notNull(),
grantedAt: timestamp('granted_at', { withTimezone: true }).notNull().defaultNow(),
grantedByUserId: text('granted_by_user_id').references(() => user.id, {
onDelete: 'set null',
}),
});
There is deliberately no enabled column. Presence means granted. Granting is an insert that ignores conflicts and revoking is a delete, so two admins clicking the same chip at the same moment both end up where they meant to. A boolean invites a read‑modify‑write toggle, and a toggle racing a toggle loses an update.
An unknown key grants nothing
The grant_key column is plain text, not a Postgres enum, because the vocabulary grows with the firm’s content. A new course should not need a migration. The keys are defined once, in a Zod enum in the shared @repo/contracts package that both the API and the portals import.
When the API reads grants, it keeps only keys the current release knows. A stale row, a typo or a key from a future release is dropped instead of trusted. Text in the database, a closed set in the code: the flexibility of the first without the looseness.
Forgetting a check locks the route
Every portal controller runs behind one guard, and every route has to say what it needs. Most say it with @RequireGrant('tab-investigations'). The handful that every member needs before they can discover anything else say @NoGrantRequired(), explicitly. A route that says nothing is refused.
const required = this.reflector.getAllAndOverride<PortalGrantMetadata | undefined>(
PORTAL_GRANT,
[context.getHandler(), context.getClass()],
);
if (required === undefined) {
throw new ForbiddenException({
code: 'GRANT_NOT_DECLARED',
message: 'This route declares no entitlement',
});
}
This is the single most useful line in the system. The failure mode of forgetting is now a 403 in development, caught the first time anyone clicks, instead of an open endpoint discovered in an audit.
Order matters inside the guard
After the declaration check, the guard resolves the caller’s membership and its grants in one query, then refuses in a fixed order: not a member of any institution, the institution’s access has ended, the institution is in its read‑only wind‑down and this is a write, and finally the grant is missing. Each refusal has its own error code, so the portal can explain why instead of showing a generic error.
Two rules ride on the same guard. Staff can preview the portal as a member, and a preview refuses every non‑GET request, so looking can never become acting. And writes that must keep working while an institution winds down, such as marking a message read, opt in with @AllowedWhenReadOnly() instead of the guard guessing.
Roles seed grants, they never imply them
Members have roles, from institution admin to read‑only. It would be easy to let a role answer “may they?”. Nothing in the API does. When a member is invited, their role’s defaults are written as ordinary grant rows, and from then on the rows are the only authority. Baseline tabs are grants too, so there is exactly one code path for access, with no special case for the tabs everybody gets.
The portal does receive the grant list in its session payload, and uses it to decide which tabs to draw. It is a rendering hint. If the portal is wrong, the API still says no.
What it costs
A membership lookup on every portal request, and a decorator on every route, including the boring ones. The decorator friction is the point. The lookup is one indexed query, and the grant index leads with grant_key because the hottest read is not one member’s grants but the seat counts across an institution list.
In exchange, the answer to “who can see this?” is a table, not an investigation.