LibrePortal/docs/roadmap/files-tab.md
librelad d522a19cae docs(roadmap): App Files tab proposal + UID-access spike results
Design note for a per-app Files tab scoped to LibrePortal-managed files
(not system files): four file buckets (hidden/view-only/editable/lever),
the advanced/dev mode as the single escalation lever (not per-file flags),
and the hard rule that the flag is UX-only while the locked-down task CLI
stays the security boundary (jail + secret allowlist).

Includes the live UID-access spike: the manager owns and can write the
config tree (/libreportal-system/configs) directly, but the container tree
(/libreportal-containers/<app>) is dockerinstall-owned — readable, not
writable — so config edits need no helper while compose-class edits do.
webui_logins is manager-readable, so secret-hiding must live in the CLI
allowlist, not in perms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-06-18 17:51:27 +01:00

9.3 KiB

LibrePortal — App "Files" tab (Roadmap / Proposal)

Status: Proposal / brainstorm — not built. Design agreed in conversation 2026-06-18; the UID-access spike (§7) is run, results recorded. · Audience: us, future-self · Scope: a per-app file view/edit surface scoped to LibrePortal-managed files, with safe defaults + opt-in escalation · Origin: "file manager for services, like Virtualmin" idea, narrowed to "view/edit LibrePortal-specific files, not system files."


0. The one idea

A per-app Files tab (alongside Services on the app detail page) that lets you view — and, for the safe subset, edit — the files LibrePortal manages for that app, without dropping to SSH. The whole feature hangs off one boundary:

You can only touch files under LibrePortal's own roots for this app. Never arbitrary host/system files.

This is deliberately not Virtualmin/cPanel. Virtualmin is a whole-server control panel; LibrePortal is a curated, privacy-first app manager. A general "browse/edit/delete anywhere" explorer would (a) be the highest-value target on the box, (b) reopen the rootless + de-sudo hardening, and (c) drift the product toward "web root shell." The scoping to LibrePortal-owned files is what keeps it on-brand.

1. Non-goals

  • A general filesystem browser. No /etc, /home, arbitrary paths.
  • A replacement for the config system. Compose/.config are generated; this views them, it does not become the front door for changing them.
  • Editing inside the running container's ephemeral FS (changes lost on recreate — a trap). Scope is the host-side files LibrePortal owns/generates.
  • A new mutating backend API. Writes route through the task system → locked-down CLI, per the project rule.

2. The central constraint — file ownership under rootless

This decides what's even possible, so it leads the design.

App files live under /libreportal-containers/<app>/ and are owned by the dockerinstall user (uid 1002 on the current box), not the manager. The manager/runtime user (libreportal; uid 1003 here — always id it, uids drift per box) is a different user. Observed perms:

  • docker-compose.yml-rw-r--r-- (644), owner 1002 → manager can read, not write.
  • libreportal.config-rwxr-xr-x (755), owner 1002 → manager can read, not write.
  • app dirs → drwxr-xr-x (755), owner 1002 → manager can list/traverse, not create/delete entries.

So: the manager can read LibrePortal's app files but cannot write them. And actual app data written by a container (inside bind mounts) may be owned by rootless-namespaced subuids (100000+), which can be non-readable by the manager at all.

Consequences (refined by the §7 spike):

  • Reads (browse/view) of the management files are free — they're world-readable.
  • Config files live in a different tree (/libreportal-system/configs/) that the manager owns — so config edits are a direct write, no helper needed. This is the most-useful bucket and the cheapest to build.
  • Container-tree writes (compose, .config, app code under /libreportal-containers/<app>/) need a privileged helper running as the owner (dockerinstall) or root, invoked via the locked-down task CLI — exactly the shape the de-sudo work already uses (root-owned helpers in /usr/local/lib/libreportal/).
  • App data files (3rd-party app bind mounts) may not even be readable by the manager; that subset is best-effort and may require a container-side path. (Untested — only the manager's own app is installed; see §7.)

3. File classification (buckets, not per-file flags)

Rather than a config variable per file type (which balloons), classify every candidate file into one of four buckets via a small ruleset:

Bucket Default behaviour Examples
Hidden Never listed, in any mode. Allowlist-in, not blocklist-out. webui_logins, SSH keys, tokens, anything matching a secret pattern
View-only Always viewable; editable only when the lever is on docker-compose.yml, generated config artifacts, .env (view-masked — tends to hold generated secrets)
Editable View + edit by default app config files, plain text/data the app reads
(lever) promotes View-only → Editable advanced/dev mode (see §4)

docker-compose.yml = view is the headline default: it's automated, and raw-editing it desyncs from the config system and has a large blast radius.

4. The escalation lever — reuse what exists, don't build a permission engine

The "let me edit the locked stuff" lever should be the advanced/dev UI mode that already exists (lp.ui.advanced / lp.ui.dev, window.LpUi), not a parallel per-file flag system. At most, add one CFG_ to set the default policy per install (e.g. CFG_FILES_EDIT_POLICY = safe|advanced); the live override is the mode toggle. Layering that falls out cleanly:

dev mode  →  reveals the "allow advanced file edits" policy
          →  promotes the View-only bucket to Editable in the Files tab
          →  edits still flow through the validated task verb (§5)

One mental model, three honest layers, no new permission system.

5. The security boundary — the flag is UX, the CLI is the gate

Non-negotiable: the config/mode flag is presentation only. The locked-down task verb (libreportal appfile …) is the real boundary and must enforce, regardless of any flag:

  • the per-app jail (resolve symlinks; reject any path escaping the app's root — no .., no absolute escapes),
  • the hidden/secret allowlist (refuse to read or write a forbidden path even if the UI thinks it's unlocked),
  • writes execute as the file owner (dockerinstall/root helper), never as an arbitrary target.

A CFG_ variable must never be the only thing between a user and webui_logins. Frame the unlock as "I accept the risk" tiers, not "turn off safety" — and keep the bypass strictly in the UI layer.

6. Surface + phasing

  • API: reads are plain authed GETs (list tree / read file — jailed server-side). Writes/deletes/uploads go through the task system → libreportal appfile write|delete|upload --app <a> --path <p> (mirrors system reclaim).
  • MVP (Phase 1): read-only browse + text viewer for an app's management files (compose, config, generated artifacts). Pure GET, low risk, immediately useful ("what got deployed / what's in this config"). Confirms the jail + secret-hiding before any write path exists.
  • Phase 2: edit/delete for the Editable bucket via the task verb, owner-helper write path.
  • Phase 3: the advanced lever promotes View-only → Editable; .env masking; app-data files (pending §7).

7. UID-access spike — results (2026-06-18, live box)

Goal: empirically confirm what the manager user can read/write under rootless, so we know how much of the "editable" bucket needs an owner-helper. Run as the manager user libreportal (uid 1003). Only the manager's own app was installed, so the 3rd-party-app data case is reasoned, not measured.

Tree Owner Manager read Manager write → Bucket / build cost
/libreportal-system/configs/<app>/ (config files) manager (1003) direct Editable — no helper. Cheapest, most useful.
/libreportal-containers/<app>/ (docker-compose.yml, .config, code) dockerinstall (1002) View-only; edit needs an owner-helper task
app data in bind mounts (3rd-party apps) container subuids (100000+) likely best-effort / out of v1 (untested)
secrets (webui_logins, keys) manager, but world-readable (mode 755) Hidden — perms do NOT protect it; the CLI allowlist must

Probe output (as libreportal): compose/config READ ok, WRITE denied, CREATE denied in the container tree; LIST ok. In the configs tree: CREATE ok. webui_logins: READ ok (hence must be hidden in the UI, not relied on perms).

Conclusions:

  1. Friction and risk align. The most useful case (edit a config) is a direct manager write — no privilege at all. The risky case (compose-class) is both view-only-by-default and the one that needs a helper. So the helper write path is only ever taken for files you've deliberately unlocked.
  2. Phase-1 MVP is even cheaper than feared: read-only browse/view across both trees + direct edit of the config tree needs no new privileged helper — just the jailed read GETs and a manager-side write through the existing task CLI.
  3. The secret-hiding boundary must live in the CLI/allowlist, not in filesystem permswebui_logins is world-readable today (a separate hardening smell: a secret at mode 755).
  4. Container-tree edits (compose/.config) are deferred behind the advanced lever + an owner-helper, which matches them being view-only by default anyway.

8. Open questions

  • App data files owned by namespaced subuids: readable at all by the manager? If not, do we want a container-side read path, or just declare data files out of scope for v1?
  • .env: view-masked, or hidden entirely? It usually holds generated secrets.
  • Per-app Files tab only, or also a small global "LibrePortal files" area? (Lean per-app — the jail is naturally that app's root.)
  • Do we diff/snapshot before an edit so a bad save is revertible (tie into the existing snapshot/rollback primitive)?