Decision records

ADR-0026: Per-tenant Unix users for application processes

  • Status: Accepted (2026-10-06)
  • Refines the “not done” part of ADR-0010.

Context

All managed application processes run as scalwsd’s user (scalws): one tenant’s code can read another tenant’s files, sockets and process state. cgroups (ADR-0010) bound resources but do not separate identities.

Decision

  • tenants[].user: <account> (optional, Linux): the tenant’s managed processes (PHP-FPM, Node, Python) run as that account. The name is resolved at prepare time with getent passwd (NSS-aware: local files, LDAP, …); uid 0 is refused. Container applications are refused for such tenants (rootless podman per tenant is a separate design).
  • Spawn chain (all argv, no shell interpretation of user input): sh -c 'echo $$ > "$0/cgroup.procs" && exec "$@"' <leaf> (only with cgroups; runs as scalws, which owns the delegated hierarchy — the tenant could not move itself) → setpriv --reuid U --regid G --clear-groups --inh-caps -all --ambient-caps -all --no-new-privs --pdeathsig TERM -- → sh -c 'umask 007 && exec "$@"' → application. Without user, setpriv still clears inheritable/ambient capabilities and sets --no-new-privs, so capabilities granted to scalwsd never reach application code. setpriv applies the death signal after the uid change (the kernel resets it on credential changes; verified in testing).
  • Sockets: a tenant with a user gets its own directory <runtime_dir>/t/<hash>, owned by <user>:<scalws group>, mode 2770. Sockets the application creates (umask 007) inherit the scalws group, so scalwsd can connect and other tenants cannot even list the directory. PHP-FPM: listen.mode = 0660, no user/group directives (its master already runs as the tenant); its generated configuration is handed to the tenant user (mode 0600).
  • Privileges scalwsd then needs: CAP_SETUID, CAP_SETGID (setpriv), CAP_CHOWN (socket directory, FPM configuration) and CAP_KILL: stopping processes of another uid needs it, and so does the parent-death signal — the kernel sends it with the dying parent’s credentials and silently drops it without permission (found in testing; the signal was set but never delivered). The packaged unit does not grant them; a drop-in (docs/OPERATIONS.md) does. scalwsd checks them at activation and refuses a configuration with tenant users when one is missing.
  • The tenant must reach its directory: <runtime_dir>/t is mode 0711 and scalwsd adds search permission (o+x, not read) to the runtime directory.
  • A change of a tenant’s user restarts its applications on reload (instances are reused only when the resolved uid/gid are unchanged).

Consequences

  • Holding CAP_SETUID makes scalwsd root-equivalent if compromised — the same trade-off as a root nginx/PHP-FPM master. It is opt-in.
  • Static files and PHP scripts are still read by scalwsd (static handler) or PHP-FPM as the tenant: document roots must be readable by group scalws (static) and by the tenant.
  • Not done: user/mount namespaces, per-application users, rootless containers per tenant.