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:

How requireNamespace picks the workspace and checks accessWHICH WORKSPACE? (first match wins)X-Namespace-IdheaderX-Namespace-SlugheaderJWT namespaceuuid, else slugSubdomainnot www, api, dashboardeach one only if the previous is absentIdentifier found?400provide a headerWorkspace exists?404Namespace not foundStatus active?403not accessibleMember, or platform admin?403Access deniedContext set on selfnamespace, namespace_permissions,is_namespace_owner, is_platform_adminA member whose statusisn't active also gets 403
Every check that fails ends the request. Platform admins skip the membership check but are still scoped to the workspace they named.
-- 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 subdomain

Once 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:

How requirePermission decidesrequirePermission("users", "update")Platform admin?yespermissions.userscontains update or manage?Handler runs403Permission denied
The whole check: platform admins pass, otherwise the module must list the action or manage.
for _, perm in ipairs(module_perms) do
    if perm == action or perm == "manage" then
        has_permission = true
        break
    end
end

New 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

WhoHow they get accessLimits
Platform admin (role "administrative")Passes every requirePermission check and may open any workspace without being a memberStill scoped to the workspace in the request; a module disabled by PROJECT_CODE still answers 404
Workspace ownerThrough 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 outAn owner given a narrower role is governed by that role; requireOwner routes accept owners and platform admins
MemberOnly the permissions of their rolesGets 403 for anything else
API keyIts scopes, in its one workspaceNever 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:

The three access layers and who each one stops1. PROJECT_CODE: per deployment, hardA disabled module's routes are never loaded and its tables don't exist.404 for everyone, owners and platform admins included.stops: everyone2. Workspace RBAC: per workspace, per rolerequirePermission(module, action) checks the member's role permissions.403 "Permission denied". Platform admins pass every check.stops: members3. Menu configuration: cosmeticnamespace_menu_config hides a sidebar page for a workspace.The API route still answers; hiding a page is not access control.stops: nobody
Only the first layer is a hard boundary for everyone. Per-workspace project_code and menu toggles change what people see, not what the API serves.

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

StatusMeaningTypical cause
401Missing or invalid credentialsNo Bearer header, expired JWT, unknown API key
400No workspace givenRoute needs a workspace and none came from header, JWT or subdomain
403Route exists but access is refusedNot a member, inactive membership, suspended workspace, or missing module.action
404Not hereModule not enabled by PROJECT_CODE, workspace not found, or a plugin turned off for this workspace (members and owners)

Rules for module authors

  1. Scope every query by namespace_id. The middleware sets the workspace; it doesn't rewrite your SQL.
  2. Re-check ownership on read, update and delete. Load the row, compare its namespace_id with self.namespace.id, and refuse if they differ.
  3. Gate writes with requirePermission and register the module's RBAC entry under PROJECT_MODULES in helper/project-config.lua so it appears in the role editor.
  4. Never take namespace_id from the request body. Use the one the middleware resolved.
  5. Index tenant tables on namespace_id first, 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.