Security & Permissions
Saaslivery has five layers of access control. A request must pass all of them before app code runs.
Every layer answers a different question, and every layer is enforced by platform middleware — apps never implement their own auth checks. This page explains what each layer does, why it exists, and how they fit together.
The five layers at a glance
┌──────────────────────────────────────────────────────┐
│ 1. Authentication — who is this person? │
├──────────────────────────────────────────────────────┤
│ 2. Workspace Membership — are they in this tenant? │
├──────────────────────────────────────────────────────┤
│ 3. Platform Role — owner / admin / member │
├──────────────────────────────────────────────────────┤
│ 4. App Access — is this app installed & allowed? │
├──────────────────────────────────────────────────────┤
│ 5. App Permissions — what can they do in this app? │
└──────────────────────────────────────────────────────┘
Each layer rejects the request if its check fails. Apps trust that by the time their handler runs, all five have passed.
Layer 1 — Authentication
Question: Who is this person?
The platform's authentication middleware reads the session cookie and resolves the member. The cookie is set on .saaslivery.com and shared across every subdomain — one login covers every app and every workspace the member belongs to.
- Unauthenticated UI requests are redirected to
/login. - Unauthenticated API requests receive
401 Unauthorized. - On success, the platform sets
ctx.member_idandctx.workspace_id. Both are read-only to apps.
If this layer fails, the member never sees a workspace-specific page — they land on the Saaslivery login.
Layer 2 — Workspace Membership
Question: Are they actually in this tenant?
Having a Saaslivery account doesn't mean you belong to a given workspace. acme.saaslivery.com is a different tenant from globex.saaslivery.com, and a member of one is not implicitly a member of the other.
core.tenant.enforce_workspace (UI) and enforce_api_workspace (API) look up workspace_members for (workspace_id, member_id):
- No row → request is blocked.
- Row exists → the platform publishes
ctx.platform_rolefrom therolecolumn.
This is where the workspace subdomain is validated against the session's workspace_slug. Without it, anyone with a valid session could probe other workspaces by changing the subdomain.
The most critical layer
A data leak across workspaces is the most serious bug possible in a multi-tenant system. Every database query in the codebase must include workspace_id. Use ctx.db (ScopedDB), which auto-injects it.
Layer 3 — Platform Role
Question: What's their position in the workspace?
Three fixed, structural roles govern workspace-wide operations. These are not customizable.
| Role | Capabilities |
|---|---|
| owner | Full control. Transfer ownership, delete workspace, manage billing, promote/demote admins. One owner per workspace (transferable). |
| admin | Manage members (invite, remove, change roles). Install/uninstall apps. Manage custom roles. Configure workspace settings. Cannot delete the workspace or transfer ownership. |
| member | Use installed apps according to their assigned app permissions. Cannot manage workspace settings, members, or apps. |
Owner/admin bypass
Owners and admins short-circuit Layers 4 and 5. The platform grants them the universal wildcard:
if ctx.platform_role in ("owner", "admin"):
ctx.permissions = {"*"}
ctx.has_permission(...) returns True whenever "*" is in the set. This means you don't special-case admins in your authz functions — the standard helpers already let them through.
Layer 4 — App Access
Question: Is this app installed for the workspace, and is this member allowed into it?
Not every member gets every app. Admins install apps per workspace and decide which roles can enter each one. This is stored in the applications table:
access_policy |
Behavior |
|---|---|
everyone |
Any workspace member can enter the app. |
roles |
Only members assigned one of the role IDs in allowed_roles can enter. |
approval |
Per-member approval (reserved for future — currently treated like roles with an empty list). |
Restrictive default
New app installs land as access_policy='roles' with allowed_roles='[]' — meaning only owners and admins can reach the app until the admin grants a role access. This is deliberate: admins opt members in, not out.
Existing rows aren't bulk-migrated — admins tighten them through the Account UI when they're ready.
What this looks like in practice
- Install CRM, grant access to the "Sales" role → members of Sales see CRM in the launcher; Engineering doesn't.
- Install Wiki with
access_policy='everyone'→ every member can open the Wiki subdomain. - Disable the app for a role → members of that role get
403 "You don't have access to this app"if they URL-guess into it.
Layer 5 — App Permissions
Question: What can they do inside this app?
Once a member is inside an app, Layer 5 decides which actions are available. This is the fine-grained, customizable layer — and the one app developers interact with most.
How apps declare their permissions
Every app lists the actions it supports in manifest.py:
# apps/tasks/manifest.py
"permissions": [
{"key": "tasks.boards.manage", "label": "Manage boards", "category": "management"},
{"key": "tasks.tasks.create", "label": "Create tasks", "category": "tasks"},
{"key": "tasks.tasks.edit", "label": "Edit any task", "category": "tasks"},
{"key": "tasks.tasks.delete", "label": "Delete any task", "category": "tasks"},
{"key": "tasks.tasks.assign", "label": "Assign to others", "category": "tasks"},
]
Permission key format
{app_slug}.{resource}.{action}
| Segment | Description |
|---|---|
app_slug |
Must match the app's manifest slug. |
resource |
Plural noun (boards, tasks, pages). |
action |
Verb — create, read, edit, delete, manage, assign, approve, export. |
- Core apps use their slug directly:
tasks.tasks.create. - Third-party apps must prefix with
ext.{publisher}.{slug}.*to prevent namespace collision. - The platform rejects invalid prefixes at boot.
How permissions are granted
Admins create custom roles ("Engineering", "Finance", "Support") and tick permission boxes. The granted set is stored on roles.permissions as a JSONB object keyed by app slug:
{
"tasks": ["tasks.tasks.create", "tasks.tasks.edit"],
"wiki": ["wiki.*"],
"files": ["files.files.upload"]
}
The {slug}.* wildcard expands at check time, so a role granted wiki.* automatically picks up any new wiki permissions added in future versions.
App-scoped resolution
When a request hits a Tasks route (/app/tasks/... on the workspace subdomain, or /tasks/... on the API), the platform's load_permissions middleware only loads the member's tasks permissions into ctx.permissions. An app never sees permissions for other apps — this isolation is non-negotiable and prevents third-party apps from probing cross-app grants.
The authz.py pattern
Every app has an apps/{slug}/authz.py module. This is the single audit surface for that app's authorization. It contains two things:
- An
AUTHZdict mapping request keys to chains of authz function paths. - The authz functions themselves.
# apps/tasks/authz.py
from heaven import Request, Response, Context
from core.security import require
AUTHZ = {
# Key format: "{subdomain_key} {METHOD} {route_pattern}"
"api POST /tasks": ["apps.tasks.authz.can_create_task"],
"api DELETE /tasks/:id": ["apps.tasks.authz.can_delete_task"],
"* GET /app/tasks/": ["apps.tasks.authz.can_view_tasks"],
}
# Simple permission gates
can_create_task = require("tasks.tasks.create")
can_view_tasks = require("tasks.tasks.read")
# Ownership-aware authz — loads the resource and deposits it on ctx.authz
async def can_delete_task(req: Request, res: Response, ctx: Context):
task = await ctx.db.one("SELECT * FROM tasks WHERE id = $1", req.params["id"])
ctx.authz.task = task # handler reads this, no re-query
if task.member_id == ctx.member_id:
return # own task — allowed
if not ctx.has_permission("tasks.tasks.delete"):
return res.out(HTTPStatus.FORBIDDEN, {"error": "Cannot delete this task"})
A single platform BEFORE hook (authz_gate) looks up (subdomain, method, route) in the merged registry and runs the chain. Handlers never call ctx.has_permission() directly — they read pre-loaded resources from ctx.authz.
Routes with no AUTHZ entry are explicitly public
If a route isn't in any app's AUTHZ dict, authz_gate lets it through. This makes public routes (login, health checks, marketing pages) visible at a glance and avoids the "forgotten middleware" bug.
Template helpers for UX
Jinja2 globals let templates render conditionally based on permissions:
{% if has_permission("tasks.tasks.create") %}
<button hx-post="/tasks" hx-target="#task-list">New Task</button>
{% endif %}
{% if platform_role in ("owner", "admin") %}
<a href="/settings">Workspace Settings</a>
{% endif %}
Template checks are UX only
authz_gate is the real enforcement. Hiding a button is not a security measure — never rely on it to block an action.
The full flow
Request arrives
↓
Layer 1: authenticated? → no → 401 / redirect to login
↓
Layer 2: member of this workspace? → no → 403
↓ (sets ctx.platform_role)
Layer 3: platform role check → enforced on workspace-management routes
↓ (owner/admin → ctx.permissions = {"*"})
Layer 4: app installed + role allowed? → no → 403 "no access to this app"
↓
Layer 5: load_permissions → ctx.permissions (app-scoped set)
authz_gate → runs AUTHZ chain for this route
↓ → deposits resources on ctx.authz
App handler runs
Why five layers and not fewer
Each layer answers a distinct question. Collapsing them hides attack surface.
| If you skip... | You get... |
|---|---|
| Layer 2 | Cross-workspace data leaks (the most critical bug in a multi-tenant system). |
| Layer 4 | Members can URL-guess into apps their admin disabled for them. |
| Layer 5 | No way to express "Sales can read CRM but not delete deals". |
Owner/admin bypass at Layer 3 is the only shortcut — it skips 4 and 5 for admins so new app installs don't require re-granting access to workspace operators.
Summary
| Concern | Where it lives | Who enforces it |
|---|---|---|
| Authentication | Session cookie + authenticate hook |
Platform |
| Workspace membership | workspace_members + enforce_workspace hook |
Platform |
| Platform role | workspace_members.role column |
Platform |
| App access | applications.access_policy + check_app_access hook |
Platform |
| App permissions (resolution) | roles.permissions JSONB + load_permissions hook |
Platform |
| App permissions (enforcement) | apps/{slug}/authz.py + authz_gate hook |
Platform runs, apps declare |
| Resource ownership | Authz function loads resource, deposits on ctx.authz |
Apps (via authz.py) |
| Audit logging | system.security.* events |
Platform |