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 withgetent 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. Withoutuser, 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>, mode2770. 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, nouser/groupdirectives (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) andCAP_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>/tis 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_SETUIDmakes 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.