- Global role (
users.role:ownerormember) — host-level. Governs things with no per-project meaning at all: managing org accounts, the/adminpanel (sessions, ports, pruning, backups), the storage/NAS feature. Anowneris also an implicit admin of every workspace, bypassing the second axis entirely — see “How it’s enforced” below. - Workspace role (
workspace_members.role:admin,editor, orviewer) — per workspace, governs everything workspace-scoped: a project’s deployments, services, secrets, custom domains, exec/shell access. Amemberwith noworkspace_membersrow in a given workspace has no access to it at all — not read-only, none.
workspace_members was a schema stub nobody read
(every member could see and act on every project in every workspace,
including running an arbitrary command in any container via
POST /services/{id}/exec — the single biggest gap this closed). Finishing
it is what makes owner/member actually mean “host administrator” vs.
“everyone else,” instead of the only permission distinction in the app.
How an account is created
- The very first account, created via the one-time setup flow when no users exist yet, is automatically
owner. That flow (POST /api/auth/setup) requires a single-use setup token (internal/auth/setuptoken.go): generated on a start with zero users, written 0600 to<data dir>/setup-tokenand logged, reused across restarts until consumed, deleted when the first account is created (and on any start where accounts already exist). Without it, whoever reached a fresh install first became owner. The handler also holds a mutex across its user count and create, so two concurrent setup calls can’t both succeed.setup.shreads the file as root. - Every account after that is created by an existing owner, from the
dashboard’s Admin page (technical mode only) or
POST /api/admin/users— there’s no self-service signup and no outbound invite email. “Invite” means an owner sets an initial email/password directly and shares it out of band; the new user can change their password afterward viaPOST /api/auth/change-password. - Every new account auto-joins the default workspace (id 1) as editor — not a mirror of their global role — so the common single-workspace install keeps working immediately without a separate membership step. Access to any other workspace, or a different role in the default one, is granted explicitly afterward (see “Managing workspace membership”).
What a member can’t do (global, host-level)
Everything not listed here works the same for both global roles — the real per-project distinctions now live in workspace roles, not here. A member is blocked from exactly three things, each enforced server-side viaauth.RequireOwner (see internal/api/roles_test.go’s TestMemberForbiddenFromOwnerOnlyRoutes):
- Managing other org accounts — listing, creating, or removing (everything under
/api/admin/users). An obvious privilege-escalation vector if members could grant themselves or others owner access. - Listing or revoking sessions —
GET /api/admin/sessionsandDELETE /api/admin/sessions/{id}operate on every session on the box, not just the caller’s own; letting a member call these would let them revoke the owner’s session (a lockout vector) or harvest every user’s session metadata. - Port registry management, container pruning, and backups —
GET/POST /api/admin/ports,DELETE /api/admin/ports/{port},POST /api/admin/prune, andGET /api/admin/backup. All are system-wide, unscoped-to-any-workspace operations — a backup in particular is equivalent to every secret on the box (see backup.md).
Workspace roles: admin / editor / viewer
Scoped per workspace, checked viaauth.RequireWorkspaceRole (or, for the handful of routes with no single resource ID to resolve a workspace from, auth.HasWorkspaceRole called inline — see “How it’s enforced”). A global owner always passes regardless of workspace role.
- viewer: read-only. View projects, deployments, services, logs, history, non-secret env vars, domains.
- editor: viewer, plus deploying/redeploying/scaling/stopping/ restarting/rolling back, editing non-secret env vars, installing templates, connecting a repo, creating staging/PR-preview deployments, verifying a pending custom domain, and running a command in a live container (
POST /services/{id}/exec) — the route that used to be open to any authenticated user regardless of workspace. - admin: editor, plus the things that used to be global-owner-only and are now devolved to whoever administers that workspace: deleting a project or deployment, setting a secret env var, changing a deployment’s access control (public/private/password), adding or removing a custom domain, deleting the workspace itself, and managing its membership (see below). A workspace-admin has no special power outside their own workspace — see
TestWorkspaceAdminScopedNotGlobalininternal/api/workspace_roles_test.go.
404 {"error":"not found"} as for an ID that doesn’t exist, so IDs in other workspaces can’t be probed (auth.WorkspaceAccess reports membership separately from role; TestMemberWithNoWorkspaceMembershipBlocked checks the bodies match). A member whose role is too low gets 403 ("<role> role (in this workspace) required") — they can already see the resource, so there’s nothing to hide. The inline checks follow the same rule: createProject and listProjects?workspace_id= answer “workspace not found”, and setProjectWorkspace answers “project not found” outside the source workspace and “workspace not found” outside the destination.
Known limitation: editor can reach secrets
Setting a secret env var is admin+, but an editor can stillDELETE any env var (deleteEnvVar has no secret check) or overwrite a secret with a plain value of the same key (CreatePlainEnvVar upserts and clears is_secret), and can exec into the container, where the secret is in the process environment. The admin-only rule on secrets guards against mistakes, not against an editor; the user docs say so.
Known limitation: GitHub PATs aren’t workspace-scoped
github_pats is an org-level resource (org_id, not workspace_id), and a single PAT can back repos across multiple projects in multiple workspaces at once via project_repos.github_pat_id. There’s no single workspace to check a PAT’s routes against without a schema change, so /api/github/pats stays open to any authenticated user, unchanged. Real gap, left as-is rather than guessed at.
Managing workspace membership
Admin-only (/api/workspaces/{id}/members, GET/POST/PUT/DELETE), reachable from the dashboard’s Workspaces page:
- Add grants an existing org account a role, looked up by exact email match — deliberately not a picker over the full user roster, which stays reserved for global owners (see above). This is how a workspace-admin who isn’t a global owner adds a known colleague without that broader visibility.
- Change role / remove are exactly what they say; removing a member revokes their access to that workspace only, it does not touch their org account (
DELETE /api/admin/users/{id}is the only thing that does, and stays global-owner-only).
CountOwners, below) — a global owner can always still manage any workspace regardless of its workspace_members rows, so a workspace can never actually get stranded.
Audit log
Matters more now that workspace-admins, not just global owners, can delete/set-secrets/change-access — see “Workspace roles” above.audit_log (internal/store/audit.go) records who deployed, deleted, or changed access, and when, for:
- Deploy: every trigger — manual, redeploy, rollback, scale, promote, and an automated GitHub push/PR-preview sync (recorded under the actor
github-webhook, since there’s no user session behind a webhook delivery) — action name matchesdeploy_history.triggered_by’s own vocabulary (manual,redeploy,rollback,scale,promote,push). - Delete: project, deployment, workspace, custom domain, org user.
- Changed access: deployment access control (public/private/password — never the password itself), a secret env var (never the value, just which key changed), custom domain add, workspace membership add/role change/remove, moving a project between workspaces, org user creation, and session revocation.
actor_email (denormalized, so it stays readable if the account is later deleted), the action, the resource type/ID, the workspace (nil for an org-level action like user management), and a short detail string. Never pruned — unlike health_checks or resource_usage_snapshots, an audit trail that quietly expires isn’t one.
Two read endpoints, matched to the two-axis model above:
GET /api/workspaces/{id}/audit-log— viewer+ in that workspace (any member, not just its admins: accountability for what happened in your own workspace is exactly the kind of thing a plain member should be able to see). Scoped to only that workspace’s events.GET /api/admin/audit-log— owner-only, every event across every workspace, including org-level ones with noworkspace_idat all.
Deleting a user
Two guardrails onDELETE /api/admin/users/{id} (owner-only to call at all, via RequireOwner):
- You can’t delete your own account (
400). - You can’t delete the last remaining owner (
400) — checked by counting owners only when the target being deleted is themselves an owner, so this never blocks deleting a member.
How it’s enforced under the hood
Session validation (internal/auth) loads the caller’s global role in the same query as the session lookup itself (GetSessionByTokenHash joins sessions to users), so role-gating costs no extra round trip. RequireAuth middleware stashes the role in request context; RequireOwner checks it for host-level routes exactly as before.
Workspace roles add a second layer, internal/auth/workspace.go:
auth.HasWorkspaceRole(ctx, store, minRole, workspaceID)is the core check — a global owner always passes; otherwise it looks up the caller’sworkspace_membersrow and compares rank (viewer, then editor, then admin).auth.RequireWorkspaceRole(store, minRole, resolve)wraps it as middleware for routes with a single resource ID to resolve a workspace from (e.g.{deploymentID}->internal/store’sWorkspaceIDForDeployment, one hop;{serviceID}-> two hops throughdeployments->projects). Seeinternal/api/workspace_resolvers.gofor every resolver andinternal/api/router.gofor exactly which routes use which minimum role.- A handful of routes have no single resource ID to hang middleware off (
listProjectsspans every workspace;createProject’s target workspace is in the JSON body;setProjectWorkspaceinvolves two workspaces, source and destination) and callHasWorkspaceRoleinline instead — same check, same effect.
internal/db/migrations/0012_workspace_role_backfill.sql backfills a workspace_members row for every (workspace, user) pair that doesn’t already have one — existing owners become admin, existing members become editor — so upgrading never silently drops anyone from access they already had. New workspaces/users after the upgrade get their membership from CreateWorkspace (creator becomes admin) and CreateUser (auto-joins the default workspace as editor) instead of the migration.
On the frontend, web/src/lib/stores/user.svelte.ts’s isOwner() (global) and web/src/lib/stores/workspaces.svelte.ts’s workspaceRole(workspaceId) (per-workspace, derived from the already-loaded workspace list, no extra fetch; returns "" when unknown so === "admin" checks fail closed) both exist side by side — purely cosmetic either way, since the API enforces the real boundary regardless of what the UI shows or hides.