Platform

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_id and ctx.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_role from the role column.

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:

  1. An AUTHZ dict mapping request keys to chains of authz function paths.
  2. 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