Skip to content

Latest commit

 

History

History
44 lines (32 loc) · 9.08 KB

File metadata and controls

44 lines (32 loc) · 9.08 KB

service_user — dedicated non-root identity for folderless apps

  • Status: done
  • Date: 2026-06-10
  • Specs touched: docs/specs/BRAIN_HOST_PROTOCOL.md (app-service identity allocation endpoints — written here); APP_ISOLATION.md # Runtime identity & data ownership, APP_MANIFEST.md # B, DECISIONS.md 2026-06-10 (realized, not changed — the spec landed with the decision)

Closes #116. Builds on folderless-app-data-dir-ownership.md, which made the brain pin user: on every instance and chown data/ to match — and left folderless apps running as the brain's euid (root in production). That default breaks images whose processes drop to a non-root service user (nginx+php-fpm, LinuxServer-style): they can't write the root-owned data/, and CAP_CHOWN is stripped so they can't fix it themselves. This slice implements the spec'd answer: service_user: true opts a folderless app into a dedicated, host-allocated, per-instance-stable non-root UID/GID, with the hard rule that a manifest can never name a numeric UID (a host-namespace number could alias a real principal under malmo's no-userns-remap model).

What was done

  • Protocol (internal/protocol/host.go): the reserved app-service band as AppServiceUIDMin/Max = [2100, 2999] — above the fixed well-knowns 2000/2001 (2002–2099 left as headroom for future fixed identities), below the 3000 malmo-user floor — plus Allocate/ReleaseAppServiceIdentityRequest/Response wire types for POST /v1/identity/app-service and …/app-service/release. Documented in BRAIN_HOST_PROTOCOL.md alongside the well-known-identity section.
  • Host-agent (internal/hostagent/agent.go): both handlers, mirror-shaped with the rest of the identity surface — delegate to UserMgr when wired (real binary), else an in-memory svcIdents map (fake). Allocation is idempotent per instance (re-allocating returns the same pair); release is idempotent and band-guarded before any delegation — the endpoint must never be usable to delete an arbitrary account. Missing instance_id and out-of-band UIDs are 400s; a full band is a 500 (app-service-band-exhausted).
  • Real allocator (internal/hostagent/usermgr/linux.go): the /etc/passwd entry is the durable reservationAllocateAppService creates a system account + group malmo-svc-<uid> (--system --no-create-home --shell /usr/sbin/nologin), so band state survives host-agent restarts with no side state. The account is named by UID, not instance ID (Linux's 32-char username cap vs. unbounded instance IDs); the GECOS field carries malmo app-service for <instanceID>, which is the idempotency probe. The free-number scan parses /etc/passwd + /etc/group directly (no nsswitch/nscd dependency, same posture as isInGroup); a useradd failure rolls the groupadd back. ReleaseAppService deletes user then group, tolerating "already gone" at each step (including the orphan-group case a past interrupted release leaves), and refuses any UID outside the band.
  • Brain client (internal/hostclient/hostclient.go): AllocateAppServiceIdentity / ReleaseAppServiceIdentity.
  • Manifest (internal/manifest/manifest.go): service_user boolean, default false. Boolean intent only; Door-2 synthetic manifests never set it.
  • Store (internal/store/store.go): service_uid/service_gid columns on instances (0 = none; allocated values are always ≥ band min) + addColumn migration + SetServiceIdentity.
  • Lifecycle (internal/lifecycle/lifecycle.go): install step 6 grows an else if man.ServiceUser branch — allocate from the host, persist on the row, then feed the pair into the existing isolation struct so the established pin-user:-and-chown path does the rest. Persisted before the chown/override so every later failure path can read it back. Release happens at uninstall (after teardown, mirroring dropServiceGrants placement) and at install rollback (row read-back before Delete erases it); both are best-effort like grant drops — a failed release leaks one band slot for manual cleanup, never blocks. Identity stability across container recreations needs no new machinery: writeOverride only runs at install and Reconcile only does compose up/stop against the on-disk override, so the row + override file carry it.
  • Admission (internal/admission/admission.go): two new rules, both door-symmetric. A numeric user: on any compose service (bare int, "1000", "1000:1000", "0", or a numeric component like "www-data:33") is rejected — malmo owns every runtime UID; named users (www-data) and variables (${APP_UID}) pass, since the brain's override pins user: regardless. New CheckManifest rejects service_user: true combined with a folders grant (a folder app already has a managed non-root identity); it's pure, so lifecycle calls it directly in the shared install transaction rather than widening the Admitter seam (which exists only to skip the docker-CLI syntax pass in tests).
  • Tests: admission table rows for every user: form + CheckManifest combos; manifest parse + default; store roundtrip (Get + List paths, ErrNotFound); host-agent handler tests (fake branch in-band/stable-per-instance/distinct-across-instances/release-returns-to-band, 400s, UserMgr delegation + 500-without-detail-leak); usermgr pure-helper tests (firstFreeAppServiceID incl. out-of-band UIDs ignored, GID-alone reserves, exhaustion; findAppServiceByGecos incl. an imposter non-malmo-svc- account not counting) and the release band guard; hostclient wire roundtrips; lifecycle fake-driver scenarios (override pins 2100:2100 + row persisted, uninstall releases, late compose-up failure releases via row read-back, allocate failure rolls back cleanly, service_user+folders rejected before any state, and the regression guard that a default folderless install makes no identity calls); and a dockerlive end-to-end (TestLiveServiceUserBootAndWrite) proving the container actually runs as 2100 and writes data/ owned 2100:2100 — live-verified.

How it maps to the specs

  • APP_ISOLATION.md # Runtime identity & data ownership: the identity table's service_user row, the band ("below 3000, distinct from the fixed 2000/2001"), per-instance stability, release at uninstall, and the folderless-only rule. Realized.
  • APP_MANIFEST.md # B (service_user): boolean intent, never a number; numeric user: and service_user+folders as admission rejections. Realized.
  • BRAIN_HOST_PROTOCOL.md: the two new endpoints documented in this change — request/response shapes, the band constants and their headroom rationale, malmo-svc-<uid> naming, GECOS idempotency, fake behavior.
  • DECISIONS.md 2026-06-10: the locked decision this implements; nothing flipped.

Decisions surfaced (for review)

  1. Band [2100, 2999], not [2002, 2999]. 2002–2099 stays reserved as headroom for future fixed well-known identities (malmo-app/malmo-shared live at 2000/2001), so a future fixed identity never collides with a dynamic allocation on an upgraded box. 900 dynamic slots ≫ any realistic single-box app count.
  2. Reservation = the passwd entry itself. No allocation table on the host side; malmo-svc-<uid> existing is the claim, durable across host-agent restarts and reconstructible by inspection. The account is UID-named (32-char username cap) with the instance ID in GECOS for the per-instance idempotency probe. The brain's instance row is the authoritative instance↔UID map, per the brain-commits-first model.
  3. The fake's svcIdents map is deliberately not persisted. The brain persists the pair on the instance row and never re-asks; and the unprivileged dev brain can't chown data/ to a foreign UID anyway (documented warn-and-skip), so fake-side durability would buy nothing.

Known gaps & deviations

  • Images that hardcode a different internal UID stay curation-rejects. service_user covers images that adopt the runtime user:; a php-fpm pool pinned to www-data in baked config, or a setuid-dropping entrypoint, still can't be aligned without userns-remap (deferred, NEXT.md; ledger: docs/dev/catalog-import-gaps.md # nonroot-data-ownership).
  • The unprivileged dev brain can't chown data/ to the allocated UID — the established warn-and-skip from folderless-app-data-dir-ownership.md applies; the production brain (euid 0) hard-fails. The dockerlive test reproduces the production chown with a root helper container.
  • The real allocator's shell-outs (groupadd/useradd/userdel/groupdel) aren't exercised on the dev box — pure helpers and handler delegation are unit-tested; the real-command path is the host-agent-real lane (same posture as the rest of usermgr, per user-crud.md).
  • No catalog app uses service_user yet — this is the mechanism slice; the first consumer is whichever curation-ledger app needed it (the nginx+php-fpm class).

What's next

  • Re-attempt the curation-ledger apps that were rejected for non-root data ownership and do adopt the runtime user — they can now ship with service_user: true.
  • Userns-remap remains the unlock for the hardcoded-internal-UID class (NEXT.md).