Technical guide · 1,965 words
Role-Based Access Control Explained for Product Teams
Access control is the part of a product that decides who can do what, to which data, and under which conditions. For a product team, the goal is not to make a permissions screen that looks flexible. The goal is to make access predictable: a person or
Access control is the part of a product that decides who can do what, to which data, and under which conditions. For a product team, the goal is not to make a permissions screen that looks flexible. The goal is to make access predictable: a person or integration requests an action, the system evaluates a small set of rules, and the result is allowed or denied consistently.
Role-based access control (RBAC) is a practical way to reach that goal. It separates a user’s job-shaped role from the individual permissions that role grants, then applies those grants to a resource and an action. Good RBAC also accounts for organization, project, and data-source boundaries, API credentials, audit history, and a deny-by-default posture.
Roles and permissions solve different problems
A permission is a single capability. members.read might allow listing member records; members.write might allow creating or updating them; members.delete might allow deletion. The permission answers “what operation is possible?” It should not, by itself, answer which employee receives it.
A role is a named bundle of permissions that represents a responsibility. An Editor might receive read and write permissions for selected sources, while a Viewer receives read-only access. A role answers “which collection of capabilities does this person have?” Keeping the concepts separate lets a team change a role’s grants without rewriting every user record, and create custom roles when built-in roles are not precise enough.
Do not model roles as a single integer that is compared in scattered route handlers. A role name is useful for display, but authorization should resolve to explicit permission grants. A role can be represented conceptually like this:
type Permission = `${string}.${"read" | "write" | "delete"}`;
type Role = {
id: string;
name: string;
permissions: Permission[];
};This shape makes the policy visible. It also makes it possible to test that an Editor can write a source without accidentally gaining delete access simply because “Editor” sorts above “Viewer.”
Think in resource-action grants
The most useful mental model is a grant tuple:
subject + scope + resource + action = decision
The subject may be a signed-in member or an API key. The scope identifies the organization, project, and data source in question. The resource is the protected object, such as a record or endpoint. The action is usually read, write, or delete.
Dashier’s permission-key convention follows the resource-action form: {data-source-slug}.read, {data-source-slug}.write, and {data-source-slug}.delete. This is more useful than a vague permission such as manage_data, because it can be checked against a concrete source and operation. It also gives product teams a vocabulary for access matrices, API documentation, and audit events.
The distinction between write and delete deserves care. A user who can update a record does not automatically need to remove it. Likewise, list, create, update, and delete routes should not silently share one broad “data access” check. In the current REST shape, list and create use /api/v1/{data-source-slug}, while get, update, and delete use /api/v1/{data-source-slug}/{record-id}. Each route should resolve its method and required capability explicitly.
Scope access from organization to source
RBAC is not safe if it checks a role but loses track of where that role applies. Dashier’s hierarchy is organization → project → data source → record. Every request must remain inside the organization selected by the authenticated identity, and project and source lookups must be constrained to their parent IDs.
That means a request should not resolve a data source by slug alone. A slug is a useful API identifier, but it is unique per project rather than globally meaningful. The authorization path should first establish the organization and project, then find the source through project_id, and only then evaluate the endpoint policy and record operation.
Project membership can narrow access further. A member may belong to an organization while being allowed to work only in certain projects. A source allowlist can narrow it again: even if the member’s role permits reading data generally, the member should not see a source that is outside the assigned set. These checks are scope checks, not replacements for permission checks; both are needed.
For every read or write, keep the same sequence:
- Resolve the organization and project from trusted identity or a validated API key.
- Confirm that the project belongs to that organization.
- Resolve the data source through that project.
- Confirm project and source membership restrictions.
- Check the method’s endpoint policy and the subject’s role or key scope.
- Perform the record operation with server-side organization scoping.
The last step matters because authorization at the route boundary is not enough. Database queries and row-level security must still prevent cross-organization reads or writes if a future route contains a bug.
API keys are identities with scopes
An API key is not a human role in disguise. It is a credential for an integration, attached to a project and organization, with its own scopes. Dashier’s v1 API accepts a key in either the x-api-key header or Authorization: Bearer header. The server hashes the presented key, rejects unknown or revoked keys, and associates a valid key with its project before resolving the requested source.
For an integration that only reads, issue a key with a read scope rather than an administrative scope. The implementation checks the required scope for authenticated endpoints: GET requires read, while other methods require write. Role-protected endpoints are stricter: a key must include admin, rather than inheriting a human member’s role list.
That separation prevents a common mistake: treating possession of any valid key as permission to do everything. It also supports key rotation and revocation without changing a person’s role. Store keys so the secret is shown only at creation and give each integration a narrowly defined purpose. Never place a raw key in source control or a browser bundle.
Endpoint policy is a second decision layer
Role grants answer whether a subject may perform an operation, but an endpoint also needs an exposure policy. Dashier supports Public, Authenticated, and Role modes, plus an enabled flag, API exposure flag, and per-policy rate limit.
Public means the endpoint requires no credential, so it must be reserved for data that is intentionally public. Authenticated means a session or appropriately scoped API key is required. Role mode requires an allowed member role, with Owner treated specially in the current implementation, or an admin-scoped API key for API callers. A disabled route or a route not exposed through the API must be rejected before credentials turn into access.
This layered approach is useful because “public” is not a role. It is a deliberate endpoint decision. A Viewer role can be appropriate for internal members while a public endpoint remains available to everyone; those are different audiences and should not be conflated.
Start with deny-by-default
Deny-by-default means a missing rule produces no access. It does not mean every product feature must be hard to use; it means the system never interprets an incomplete configuration as permission. If an endpoint has no configured policy, choose a documented safe default and require an explicit grant before broadening access.
A practical decision function should make unknown values fail closed:
function canAccess(
mode: "public" | "authenticated" | "role",
auth: { kind: "none" | "session" | "key"; scopes?: string[]; roleId?: string },
allowedRoles: string[],
need: "read" | "write",
): boolean {
if (mode === "public") return true;
if (mode === "authenticated") {
return auth.kind === "session" ||
(auth.kind === "key" && (auth.scopes ?? []).includes(need));
}
if (auth.kind === "key") return (auth.scopes ?? []).includes("admin");
return auth.kind === "session" && auth.roleId !== undefined &&
allowedRoles.includes(auth.roleId);
}In production code, validate inputs and preserve the Owner exception or other product rules explicitly rather than hiding them in a broad condition. Return generic not-found responses where revealing whether an organization, project, member, or source exists would disclose information across a boundary. Log the internal reason for operators, but avoid returning sensitive policy details to an untrusted caller.
Build an access matrix before building the UI
An access matrix turns ambiguous discussions into testable policy. Start with the built-in roles and the operations that matter. The following is an illustrative starting point for a source called members; teams should adapt it to their own organization and source rules.
| Role | List/get | Create/update | Delete | Manage roles and policies |
|---|---|---|---|---|
| Owner | Yes | Yes | Yes | Yes |
| Admin | Yes | Yes | Yes | Yes, if granted by product policy |
| Editor | Yes | Yes | No by default | No |
| Viewer | Yes | No | No | No |
Then add columns for organization, project, source, endpoint mode, and API channel. A Viewer in Project A is not automatically a Viewer in Project B. An Editor with members.write is not automatically an Editor for a source called payroll. A public GET endpoint may be readable without a role, while its POST endpoint remains authenticated or role-protected.
Treat the matrix as a product artifact and a test fixture. When a custom role is added, document the exact permission keys it receives rather than describing it as “almost an Admin.” This keeps support conversations and security reviews grounded in behavior.
Test both the allow and the boundary
Authorization tests should prove more than a successful happy path. For every permission, test the smallest allowed action and adjacent actions that must fail. For example, a Viewer should be able to list permitted records, receive a 403 for create, and receive a 403 for delete. A read-only API key should pass GET and fail POST, PUT, and DELETE.
Test the scope boundaries explicitly:
- A member assigned to one project cannot use a valid session to read another project’s source.
- A source slug in Project A cannot resolve a same-named source in Project B.
- A key from Organization A cannot select a source in Organization B through query parameters.
- A member with a source allowlist cannot read an unlisted source.
- A revoked key fails even if its old scope would have allowed the request.
- A disabled or API-hidden endpoint fails regardless of the caller’s role.
- An unknown role, method, mode, or missing membership fails closed.
Also test record-level behavior after route authorization. If a member can access a source, that does not imply the member can update a record from another organization or bypass field validation. Validate request bodies against the data source’s field definitions, apply server-side parent filters, and record create, update, delete, and role or permission changes in the audit log with organization, actor, entity, and old/new values where appropriate.
Use audit history as a control, not a diary
Audit logs make authorization explainable after the fact. A useful event answers who changed what, where it happened, which entity was affected, and what changed. For an access-policy edit, store the organization, user, action, entity type, entity ID, and old and new values. For a record mutation, capture the same context without exposing secrets such as raw API keys.
Product teams can use the history to investigate an unexpected denial, confirm that a role change took effect, and spot permission expansion that was not intended. It should be append-oriented and access-controlled itself: people who can manage records should not automatically be able to erase the evidence of their changes.
How Dashier fits the model
Dashier is admin infrastructure for applications whose organizations contain projects, which contain data sources and records. A source defines typed fields and drives generated tables and forms as well as its REST API. That makes source-scoped permissions concrete: a team can reason about members.read or members.write alongside the fields and endpoint policies that expose that source.
Dashier currently combines Owner, Admin, Editor, and Viewer roles with custom roles, API keys with scopes, endpoint modes, audit logs, and organization/project/source boundaries. The right configuration still depends on an application’s data and risk. Dashier should not be treated as a drop-in replacement for another data platform or admin product; its value is a focused model for building and operating these access decisions consistently.