Every business table in OpsAPI carries a namespace_id. A namespace is what the dashboard calls a workspace: one tenant, with its own members, roles, settings and data. Many workspaces share one deployment and one database. This note explains how a request is bound to exactly one of them, how roles decide what a member may do, and which controls are real boundaries.
Resolving the workspace
NamespaceMiddleware.requireNamespace in middleware/namespace.lua runs after authentication. It looks for the workspace in a fixed order and then checks that this user may use it:
-- middleware/namespace.lua: priority 1 is the header
local header_id = self.req.headers["x-namespace-id"]
if header_id and header_id ~= "" then return header_id end
local header_slug = self.req.headers["x-namespace-slug"]
if header_slug and header_slug ~= "" then return header_slug end
-- priority 2: the JWT's namespace (uuid, else slug); priority 3: the subdomainOnce it passes, the handler can rely on self.namespace, self.namespace_permissions, self.is_namespace_owner and self.is_platform_admin. It also stores ngx.ctx.namespace_id so the activity log attributes the request to the workspace.
API keys
An API key (opsk_…) is bound to exactly one workspace and has no membership row. Its workspace comes from the key itself and its scopes act as its permissions. Sending a namespace header that doesn't match the key's own workspace is refused with 403, so a key can never reach another tenant. Keys are never owners or platform admins.
Roles and permissions
Permissions are a map of module to a list of actions: read, create, update, delete, or manage, which means all of them. A member's effective permissions are the union of their roles in that workspace. A route asks for one pair:
for _, perm in ipairs(module_perms) do
if perm == action or perm == "manage" then
has_permission = true
break
end
endNew workspaces are seeded with default roles: owner with manage on every module, admin with manage on everything except namespace, which is read, and member with nothing until someone grants it. Owners and admins can add their own roles in the dashboard's role editor, for example a read-only viewer with read on every module.
Owners and platform admins
| Who | How they get access | Limits |
|---|---|---|
| Platform admin (role "administrative") | Passes every requirePermission check and may open any workspace without being a member | Still scoped to the workspace in the request; a module disabled by PROJECT_CODE still answers 404 |
| Workspace owner | Through the owner role, which grants manage on everything. An owner left with no roles falls back to full owner permissions, so they can never be locked out | An owner given a narrower role is governed by that role; requireOwner routes accept owners and platform admins |
| Member | Only the permissions of their roles | Gets 403 for anything else |
| API key | Its scopes, in its one workspace | Never owner or admin |
The three access layers
Teams often ask for "this tenant should only have modules X and Y". OpsAPI has three mechanisms that look similar from the dashboard but behave very differently at the API:
PROJECT_CODE is an environment variable of the whole process, so two workspaces in the same deployment can't have different API-reachable module sets. To give a tenant a hard limit that holds for its owner too, run it in its own deployment and database with a narrower PROJECT_CODE. The per-workspace namespaces.project_code column only filters the menu and seeds the default roles' modules; it is never checked at request time. Billing entitlements are metadata and are not a request-time gate either.
Status codes you'll see
| Status | Meaning | Typical cause |
|---|---|---|
| 401 | Missing or invalid credentials | No Bearer header, expired JWT, unknown API key |
| 400 | No workspace given | Route needs a workspace and none came from header, JWT or subdomain |
| 403 | Route exists but access is refused | Not a member, inactive membership, suspended workspace, or missing module.action |
| 404 | Not here | Module not enabled by PROJECT_CODE, workspace not found, or a plugin turned off for this workspace (members and owners) |
Rules for module authors
- Scope every query by
namespace_id. The middleware sets the workspace; it doesn't rewrite your SQL. - Re-check ownership on read, update and delete. Load the row, compare its
namespace_idwithself.namespace.id, and refuse if they differ. - Gate writes with
requirePermissionand register the module's RBAC entry underPROJECT_MODULESinhelper/project-config.luaso it appears in the role editor. - Never take
namespace_idfrom the request body. Use the one the middleware resolved. - Index tenant tables on
namespace_idfirst, because every query filters on it.
The live demo shows this model in action: its public demo account has a read-only role in a showcase workspace, so every page opens and every change answers 403, while real sign-ups live in a different workspace it can't see.