Blog article

Roles and permissions — fine-grained access control

Published on July 1, 2022

Any platform serious about business use has to answer three questions convincingly: who can see what, who can change what, and who decides who can do either. The honest answer is always layered — not because the underlying rules are complicated, but because real organizations are. A customer-service supervisor reads everything in their region and edits most of it, but can't touch the salary fields on employee records. A junior analyst sees a sanitized version of the same data. An auditor has read-only access to a carefully chosen slice, and an external partner has access only to the records that explicitly relate to their account. Our permission model is designed to express those rules cleanly, without turning into a maze of exception handling.

The core structure is roles: named collections of permissions, each of which grants a specific capability. Users are assigned to one or more roles, and they inherit the union of those roles' permissions — subject to priority, which we'll come back to in a moment. Roles are duplicable, so the typical starting point for a new role is copy the closest existing role and adjust from there. That keeps role definitions tidy and makes it easy to understand what makes one role different from another.

Permissions themselves are authored in a matrix. Every permissionable capability is a row; every role is a column. The cells say yes, no, or specific-grant-with-scope. Because the matrix covers every type, every module, every property, every query, every view, and every system capability, it's the single place to look for any permission question. "Can the Support Supervisor edit Ticket records?" is one cell. "Can the HR Reader see the Salary property of Employee?" is another. That consistency matters when a tenant's role set grows — it's a surface you can audit, not a scattering of flags across the codebase.

Fine-grained scope is what keeps the model expressive. Permissions exist at the module, type, property, query, view, and action levels. A role can be allowed to see a type but not to edit it, allowed to see most properties of that type but not a sensitive subset, allowed to run certain queries but not others, allowed to use some views but not the administrative ones. That gives implementers a precise tool, which is what's needed for regulatory work and sensitive data. Hiding just the salary property of the Employee type from non-managers is a single cell, not an architectural project.

When users have multiple roles, conflict resolution follows role priority. Higher-priority roles win when two roles disagree about the same permission. That keeps the edge cases predictable: a senior user who is also in a narrow, restrictive role doesn't lose their senior-level access, because the senior role outranks the narrow one. Priority is explicit and configurable; the behavior isn't hidden.

Deny overrides cover the case where you want to grant "everything" but still carve out a specific exception. A role can have the catch-all ALL permission on a type and, on top of that, a specific deny for a single sensitive property. The matrix handles both cleanly, so the broad policy and the narrow exception coexist without tension.

A handful of system-level roles are built in because most tenants want the same starting set. Superuser for unrestricted access. Editor for broad read-write. Reader for read-only. Publisher for the common case of managing publicly-facing content. Tenant Admin for delegated administration — the role that lets an operator give a customer the power to manage their own tenant (users, roles, invitations) without handing over the keys to the platform itself. That delegation is important for scalable operations: customers can run themselves day to day without every user management change queuing up at the platform operator.

Free edit is a small but useful detail: for high-trust roles like Superuser and Editor, the platform offers a mode where the usual property-permission guardrails give way to full edit access. It's an explicit opt-in per role, not a default, and it exists so that maintenance work on the platform's own configuration — where the user is, by nature, meant to be touching everything — doesn't get held up by per-property rules that don't apply to the administrator.

Public pages get their own treatment. Content exposed to unauthenticated visitors is governed by a parallel set of permissions that operate outside the normal role system, so the same implementation can carry both internal access rules and public-facing rules without them interfering. The public-pages article covers that in detail.

Performance is handled with a quiet design choice: resolved permissions are cached per user, so the matrix doesn't get evaluated from scratch on every page load. When roles change, the cache invalidates appropriately. Users don't notice the caching; they just notice that permissions feel fast, which is how they should feel.

Through the REST API, the same permissions apply. An integration authenticated as a specific user sees exactly what that user would see — no more, no less. That's a subtle point worth underlining: the permission system isn't a UI concern, it's a platform concern, and every data-access path respects it uniformly. Cross-references to the system permissions article, the authentication article, and the tenant customizations article round out the picture. But the matrix is where the work lives, and for most tenants, the matrix is the one screen they set up early and rarely need to revisit.