Permission design is one of those topics that is genuinely easy to get wrong in both directions — you either ship isAdmin: boolean and paint yourself into a corner six months later, or you reach for a full policy engine on week two of an MVP. This post is about how we thought through that spectrum for Storyie, what we chose, and what we deliberately left behind.
TL;DR
- For most solo or small-team products, the right progression is: simple boolean flags → RBAC → Feature Gate as a separate layer. Skip ABAC unless you have specific signals.
- Storyie uses four hierarchical roles (
owner,admin,member,viewer) stored per-tenant. A single integer comparison handles every permission check. - Flags that don't fit the hierarchy (like
isBillingContact) are fine — in very small numbers, named precisely, and checked in exactly one place. - Feature Gate is not a permission system. "Can this user act?" and "does this plan include this feature?" are separate questions. We answer them separately.
- ABAC is powerful, but the overhead — policy engine, debugging complexity, test surface — is rarely justified below enterprise scale.
Approach | In a sentence | Best fit |
|---|---|---|
Boolean flags |
| MVP, one or two access levels, no context |
RBAC | Users get roles; roles carry permissions | SaaS with team/tenant concepts |
ABAC | Every decision evaluated against subject + resource + action + environment | Enterprise, complex compliance requirements |
Boolean flags — the fastest start
The simplest permission system is no permission system at all:
interface User {
id: string;
email: string;
isAdmin: boolean;
isPro: boolean;
}if (!user.isAdmin) {
throw new Error("Admin only");
}This is genuinely good for an early-stage product. There is nothing to migrate, nothing to explain, and nothing to misread. The DB row is the source of truth.
The cliff arrives when requirements compound:
// This gets painful fast
if (user.isAdmin || user.isEditor || (user.isMember && resource.isPublic)) {
// ...
}Past three or four flags, the conditional tree is a liability. More critically, flags on a user row cannot express context — "this user is an admin in team A and a viewer in team B" requires a join table, not a new column.
Use it until: you need context-scoped access levels (per-team, per-tenant, per-workspace), or a paid tier with meaningful feature differentiation. When either appears, it is time to graduate.
RBAC — roles as a container for permissions
The RBAC model assigns roles to users, and roles carry permissions. For Storyie, the assignment happens per-tenant:
User → (tenant_members table) → Role → PermissionsWe defined four roles and made them explicitly hierarchical with integers:
const ROLE_HIERARCHY: Record<MemberRole, number> = {
owner: 4,
admin: 3,
member: 2,
viewer: 1,
};A permission check becomes a comparison:
export async function hasPermission(
tenantId: string,
userId: string,
requiredRole: MemberRole,
): Promise<boolean> {
const member = await tenantQueries.getTenantMember(tenantId, userId);
if (!member) return false;
return ROLE_HIERARCHY[member.role] >= ROLE_HIERARCHY[requiredRole];
}Why we chose RBAC
Three reasons made RBAC the clear choice for Storyie.
Context-scoped access. Once we introduced multi-tenant structure, "what can this user do in this tenant?" became the central question. A single isAdmin column on the user row cannot answer it. A tenant_members join table with a role column can.
A clean hierarchy. Storyie's roles are strictly additive — every higher role can do everything a lower role can. That property makes the integer comparison valid and keeps the permission check surface tiny. If roles had crossed capabilities ("editors can publish but not invite; billing contacts can invite but not publish"), we would need something richer.
A clear upgrade path. Today, most Storyie tenants are single-user. The RBAC wiring already handles team invites: adding a collaborator is an INSERT into tenant_members. The permission check logic does not change at all.
Where RBAC has limits
RBAC is not a fit for every constraint:
- Role proliferation. If exceptions pile up, you add roles. Eventually you have 15 roles and can't explain what each one covers.
- Exceptions within a role. "This user has the
memberrole but is also allowed to export data" doesn't map cleanly to a hierarchy. - Time/environment conditions. "Editable only during business hours" is not a role attribute.
We ran into the exception case immediately with billing. Billing access in Storyie belongs to the owner by definition, but a tenant owner can designate a separate billing contact who is not an owner. That is a genuine cross-cutting concern:
export async function requireBillingAccess(tenantId: string, userId: string): Promise<void> {
const member = await tenantQueries.getTenantMember(tenantId, userId);
if (member?.role === "owner") return;
if (member?.isBillingContact) return;
throw new PermissionError(/* ... */);
}isBillingContact is a narrow, precisely named flag that lives in tenant_members and is checked in exactly one function. It is the minimum viable escape hatch — not a pattern to repeat freely.
ABAC — flexibility at a cost
ABAC evaluates access by combining attributes from the subject (who), the resource (what), the action, and the environment (when/where):
// Conceptual ABAC policy
const policy = {
subject: { role: "member", department: "engineering" },
resource: { type: "diary", sensitivity: "internal" },
action: "edit",
condition: { time: "business_hours", ip: "office_range" },
};The model is genuinely more expressive. It avoids role proliferation by letting attribute combinations carry the load. It can encode time, location, and resource sensitivity natively.
Why we passed on ABAC
The complexity isn't justified at our scale. Storyie doesn't have departments, IP ranges, or sensitivity classifications. The attributes that would drive an ABAC policy engine simply don't exist in our domain.
Debugging is harder. With RBAC, "why was I denied?" is answered by looking up the user's role and comparing the integer. With a policy engine evaluating a combination of attributes, tracing the denial requires replaying the policy against the logged request context. That is fine for a compliance team with tooling; it is friction for a solo dev at 11pm.
The tooling carries real overhead. OPA, Cedar, Casbin — each has a learning curve, a policy definition language, and testing infrastructure. The value has to justify the weight.
When to reconsider
ABAC becomes the right answer when specific signals appear:
- Role count has crossed 10 and keeps climbing
- "This role, except for these resource types" carve-outs are frequent
- Different resource types have meaningfully different permission structures
- Compliance requirements demand auditable, policy-driven access decisions
If two or more of these are true, the ABAC investment will pay off. Otherwise, lean on well-named RBAC roles and use flags conservatively for the exceptions.
Feature Gate — a separate layer for plan limits
RBAC answers "what can this user do in this context?" Feature Gate answers a different question entirely: "does this tenant's plan include this capability?"
RBAC: "Can this user perform this action in this tenant?"
Feature Gate: "Does this tenant's plan allow this operation?"In Storyie these two checks run in sequence inside every Server Action:
export async function updateDiary(diaryId: string, data: DiaryInput) {
const { tenantId, userId } = await requireAuth();
// RBAC — verify the user's role in this tenant
await requirePermission(tenantId, userId, "member");
// Feature Gate — verify the tenant's plan covers this action
const permission = await canPerformAction(tenantId, "create_diaries", "diaries_count");
if (!permission.allowed) {
// Prompt to upgrade
}
// Business logic
// ...
}Keeping these layers separate means you can change pricing tiers without touching role logic, and you can restructure roles without touching plan limits. "Free plan: 31 entries/month, Pro plan: unlimited" is a plan concern, not a role concern. If we modelled it as a role, we would need a role per pricing scenario, and roles would start proliferating for the wrong reasons.
Decision flowchart for solo devs
Do you have a billing tier?
├── No → Do you need more than two access levels?
│ ├── No → Simple boolean flags
│ └── Yes → Lightweight RBAC (2-3 roles)
└── Yes → Do you have team or organization concepts?
├── No → Boolean flags + Feature Gate
└── Yes → RBAC + Feature Gate
└── 10+ roles or complex attribute conditions? → Consider ABACStoryie sits in "billing tier present, team concept present (now and planned)" — so RBAC + Feature Gate.
Three implementation principles
Start with fewer roles than you think you need
Four roles (owner, admin, member, viewer) is already on the generous side for a product at our stage. Starting with just owner and member is defensible. You can always add a role later; removing one requires migrating existing data and is proportionally painful.
Escape hatches are fine — in small numbers
When something doesn't fit the role hierarchy, a named flag is better than forcing it into a role. The discipline is to ask, for each flag: "could this be a role?" If the answer is no, add the flag, name it precisely, and check it in exactly one function. The moment you're on your fourth flag, the flags have become attributes and you're building ABAC without the policy engine — which is the worst outcome.
Put permission checks at a consistent call site
In Storyie, every Server Action calls requireAuth(), then requirePermission(), then the Feature Gate check — in that order, at the top of the function body. The pattern is consistent enough that a missing check is conspicuous in review. Permission logic that is scattered across middleware, query helpers, and UI conditionals is permission logic that leaks.
export async function updateDiary(diaryId: string, data: DiaryInput) {
const { tenantId, userId } = await requireAuth();
// RBAC check
await requirePermission(tenantId, userId, "member");
// Feature Gate check
await checkContentCreationLimit(tenantId, "diary");
// Business logic only after both pass
// ...
}Retrospective
Decision | Rationale |
|---|---|
Chose RBAC | Multi-tenant structure required context-scoped roles; clean hierarchy made integer comparison sufficient |
Skipped ABAC | No attribute-based conditions in the domain; debugging overhead not worth it at this scale |
Separated Feature Gate | Plan limits and role permissions are orthogonal concerns; mixing them causes role proliferation |
Used | A cross-cutting exception that genuinely doesn't fit the hierarchy — kept narrow and isolated |
Permission design doesn't have a universal correct answer. The right choice is the one that fits the product's current shape and its near-term roadmap — and that you can actually operate as a small team. For most solo-dev or early-stage SaaS products, RBAC with a flat hierarchy and a separate Feature Gate layer covers almost everything without pulling in complexity that won't pay for itself until you're much larger.
Related Posts
- Building a Monorepo with pnpm and TypeScript — workspace conventions that underpin how the permission packages are shared
- Multi-tenant subdomain routing in Next.js — the tenant structure that RBAC is built on top of
- Database schema design with Drizzle ORM — how the
tenant_memberstable and role columns are defined
Try Storyie
If you want to see multi-tenant permission design in practice, create a Storyie account — the same RBAC logic described here controls every read and write in the app. Or pick up the iOS app and see how the permission layer stays invisible when it's working correctly.