Give three staff members and two client contacts access to the same portal, and you have already made an access-control decision — whether you meant to or not. Someone can see invoices. Someone can edit the CMS. Someone can toggle a system setting that reprices every plan. The only question is whether those boundaries were designed or inherited by accident.

Most subscription platforms answer this with a single field: a role column that reads admin or user, and a scattering of if user.is_admin checks. That works until the day an agency asks for a support person who can read tickets but not change billing, or a client who manages their own content but never sees another tenant's data. At that point a boolean role runs out of room.

VBWD — a self-hosted, source-available subscription platform built on Python/Flask, PostgreSQL, Vue 3, and Docker — takes a different position. It separates access control into three distinct systems, each answering a different question. This article walks through all three, how they combine, how plugins extend them, and where the design costs you effort rather than saving it.

The three role systems

The core idea: "who is this person" and "what can they do in the backoffice" and "what can they do as a customer" are three different questions. VBWD models them separately.

(A) The user role enum. Every account carries a User.role — an enum with coarse values such as ADMIN or SUPER_ADMIN. This is the identity-level classification: it answers "what kind of account is this?" It is deliberately blunt. You do not encode "can edit CMS" here; you encode whether the account is an ordinary customer, a staff member, or the owner. Think of it as the outermost gate, not the fine-grained one.

(B) Admin roles and permissions that gate /admin. The backoffice is protected by a granular RBAC layer. Roles here are collections of named permissions — strings like cms.manage, users.manage, or settings.system. A person with cms.manage can touch content; without settings.system they cannot reach the system configuration screens. This is the layer that lets you build a support role, an editor role, or a billing-only role without writing new code. The permissions are checked, not the enum value.

(C) User access levels that gate /user. The customer-facing side has its own access levels. These decide what a subscriber can reach in their own portal — which features, which pages, which entitlements light up. A trial user and a paid user are both role = USER at the enum level, but their access levels differ, and that difference is what the /user surface reads. This is where subscription tiers turn into visible capability.

All three converge on one method: User.has_permission. Whether a route lives under /admin or /user, the check is a permission lookup against what that user actually holds, not a hard-coded role comparison scattered through the codebase. That single seam is what keeps the three systems from turning into a tangle of special cases. The platform's architecture overview describes how this sits inside the layered Routes → Services → Repositories structure.

How they combine

The three systems are not alternatives you pick between — they stack. A request travels through them in sequence, and each can stop it.

Consider a staff member opening the CMS editor in the backoffice. First, the enum (A) establishes that this is a staff-type account and not an anonymous visitor or plain customer. Then the admin permission check (B) asks whether the account holds cms.manage. If both pass, the editor loads. The user access-level system (C) never enters the picture here, because the CMS editor is an /admin surface, not a /user one.

Now flip it. A subscriber opens a premium feature in their own portal. The enum (A) confirms an ordinary user account. The admin layer (B) is irrelevant — this is not the backoffice. The access level (C) decides whether the subscriber's current tier includes that feature. Same person, same login, completely different gate depending on which side of the platform they touched.

The practical consequence is data minimisation. Staff see only what their permission set grants. A support agent with users.manage but not settings.system can help a customer without ever seeing — or accidentally changing — the pricing engine. A content editor never loads the user table. Each person's view of the system is bounded by their permissions rather than by their good judgment, which is the property you want when a portal has more than two people in it.

Per-plugin permissions

VBWD is an agnostic-core plugin platform: the core knows nothing about tarot readings, marketplaces, or datasets, and every domain feature lives in a plugin. That principle extends to permissions. Each plugin can declare its own permissions.

When you install a plugin that adds, say, a booking system, it does not ask you to hand-edit a central permission list. It declares the permissions it needs — a bookings.manage, perhaps a bookings.view — and those names become available to the RBAC layer like any built-in permission. You can then assign them to whatever admin roles make sense for your deployment. The core stays agnostic; the plugin brings its own vocabulary. You can see the range of available plugins on the plugins catalogue.

This matters for anyone assembling a portal from parts. You are not limited to the permissions the platform authors imagined. If your deployment runs a marketplace plugin and a CMS, your permission map is the union of both plugins' declarations plus the core's — and a single admin role can span them or stay narrow. The documentation covers how a plugin registers permissions during its enable step.

One operational detail worth internalising: RBAC is seeded on install. A routine (seed_default_rbac) owns the default permissions, roles, and access levels — it creates the baseline map so a fresh deployment is not a blank slate you have to hand-build. When a plugin ships permissions, the seed is also the mechanism that upserts them so they exist as real, assignable rows rather than strings referenced by code that has nothing to grant.

A worked multi-client example

Picture an agency running one VBWD instance that serves several client brands, plus its own internal staff. Here is how the three systems map to real people.

The agency owner. Enum: SUPER_ADMIN. Admin role: everything, including settings.system. This is the one account that can reprice plans and reconfigure the platform. You want exactly one or two of these, and you want to know who they are.

An agency content editor. Enum: ADMIN (a staff account). Admin role: a custom "Editor" role holding cms.manage and nothing else — no users.manage, no settings.system. This person builds landing pages and blog posts across client brands but cannot see the user list or touch billing. If their laptop is compromised, the blast radius is content, not customer data.

An agency support agent. Enum: ADMIN. Admin role: a "Support" role with users.manage to look up accounts and resolve issues, deliberately without settings.system or cms.manage. They can help a customer reset a subscription without being able to edit the public site or change a tax rate.

A client's own portal user. Enum: USER. No admin role at all — the /admin backoffice is simply unreachable. Their experience is governed entirely by access levels (C): their subscription tier determines which features their /user portal exposes. Upgrade the plan, the access level changes, more of the portal lights up. Downgrade, and it contracts. No code change, no redeploy.

Notice that three of these four people share, or nearly share, an enum value — two ADMINs and a SUPER_ADMIN — yet do wildly different jobs. The enum did not distinguish them; the admin permission sets did. And the client user, sitting at USER, is shaped by a system the staff never touch. That is the separation working as intended: coarse identity, granular backoffice, independent customer surface. The features overview shows more of what the customer-facing side can expose per tier.

Honest limits

This design is not free, and pretending otherwise would waste your time.

Three systems is more to learn than one flag. A single is_admin boolean fits in your head immediately. Three layers — enum, admin permissions, user access levels — take longer to internalise, and new team members will occasionally reach for the wrong one. The payoff is real, but so is the onboarding cost. Budget for it.

You must design the permission map deliberately. The seed gives you a sane default, but a default is not a policy. If you never sit down and decide which roles exist and which permissions each holds, you will drift toward giving everyone the broad role because it is easier — which quietly defeats the entire point. The system rewards a deliberate map and punishes an accidental one.

Misconfigured access levels can over- or under-expose. Because access levels (C) drive what customers see, a mistake here is customer-visible in both directions: a paying subscriber locked out of a feature they bought, or a trial user handed something they did not pay for. Neither is caught by a compiler. The only reliable defence is to audit — periodically review which access levels grant which features, and which admin roles hold which permissions, against what you actually intend. The flexibility that lets you model any org is the same flexibility that lets you misconfigure one.

Takeaway

Access control in a multi-client portal is not one question but three: who is this account, what may it do in the backoffice, and what may it do as a customer. VBWD answers them with three separate systems — the User.role enum, admin roles built from granular permissions like cms.manage and settings.system, and user access levels — all resolved through a single has_permission check and extensible by any plugin. The result is data minimisation by construction: staff see what they need and no more. The cost is a steeper model to learn and a permission map you must design and audit rather than inherit. For agencies and integrators running more than a couple of people against one instance, that trade is usually the right one.

VBWD is source-available under BSL 1.1 — free for commercial use while annual VBWD-attributable sales stay below the value of 6.7 BTC per year, with a commercial license required above that. It is not, and never has been, public-domain or CC0; the source is open to read and run, not unlicensed.

Next step: map your own org onto the three systems before you invite anyone in — sketch the admin roles you actually need, decide which access levels back which tiers, then read how a plugin declares its permissions in the VBWD documentation and wire the map to match.