> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mangrove.dino.icu/llms.txt
> Use this file to discover all available pages before exploring further.

# Multi-user and roles

> Global and workspace roles, how they're enforced server-side, and the audit log.

Two independent axes, not one:

* **Global role** (`users.role`: `owner` or `member`) -- host-level. Governs
  things with no per-project meaning at all: managing org accounts, the
  `/admin` panel (sessions, ports, pruning, backups), the storage/NAS
  feature. An `owner` is 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`, or
  `viewer`) -- per workspace, governs everything workspace-scoped: a
  project's deployments, services, secrets, custom domains, exec/shell
  access. A `member` with no `workspace_members` row in a given workspace
  has **no access to it at all** -- not read-only, none.

Before this existed, `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-token` and 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.sh` reads 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 via
  `POST /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** via `auth.RequireOwner` (see `internal/api/roles_test.go`'s `TestMemberForbiddenFromOwnerOnlyRoutes`):

1. **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.
2. **Listing or revoking sessions** -- `GET /api/admin/sessions` and `DELETE /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.
3. **Port registry management, container pruning, and backups** -- `GET`/`POST /api/admin/ports`, `DELETE /api/admin/ports/{port}`, `POST /api/admin/prune`, and `GET /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](https://docs.mangrove.dino.icu/internals/backup.md)).

## Workspace roles: admin / editor / viewer

Scoped per workspace, checked via `auth.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 `TestWorkspaceAdminScopedNotGlobal` in `internal/api/workspace_roles_test.go`.

A caller with **no membership** in a resource's workspace gets the same `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 still `DELETE` 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).

There's no "last admin" guardrail on a workspace the way there is for the last org owner (`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 matches `deploy_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.

Each entry records `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 no `workspace_id` at all.

GitHub PATs aren't included (see the "Known limitation" above -- the same reason they aren't workspace-scoped for authorization means there's no single workspace to attribute a PAT change to either).

## Deleting a user

Two guardrails on `DELETE /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.

Together these mean the box can never end up with zero owners and nobody able to grant owner access to anyone else.

## 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's `workspace_members` row 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`'s `WorkspaceIDForDeployment`, one hop; `{serviceID}` -> two hops through `deployments` -> `projects`). See `internal/api/workspace_resolvers.go` for every resolver and `internal/api/router.go` for exactly which routes use which minimum role.
* A handful of routes have no single resource ID to hang middleware off (`listProjects` spans every workspace; `createProject`'s target workspace is in the JSON body; `setProjectWorkspace` involves *two* workspaces, source and destination) and call `HasWorkspaceRole` inline instead -- same check, same effect.

**Upgrading an existing install**: `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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.