Beast Serve Permission Architecture

Per-User Pub Groups — Final Design (supersedes prior tier-based proposals)

Date
2026-07-17
Status
Final
Consultants
codex (gpt-5.5, high reasoning)
Supersedes
Tier-based / ACL designs

Decision

Per-user publish groups (<host-user>-pub). No tiers, no AccessTier enum, no PermissionManager interface, no POSIX ACLs, no new directories. Keep Site.Public bool. Delete makeWorldReadable(). Caddy joins each user's pub group to read published files.

Architecture Summary

Public access is controlled by one Unix group per host user.

private site: user:user-private-group dirs 0700 files 0600 → Caddy CANNOT read public site: user:user-pub dirs 0750 files 0640 → Caddy CAN read (member of user-pub) Examples: beast-serve-pub → Caddy reads beast-serve's published sites agent-kai-pub → Caddy reads agent-kai's published sites agent-ren-pub → Caddy reads agent-ren's published sites

Why Per-User Pub Groups

Exact Code Changes

internal/serve/service.go

Delete makeWorldReadable()

Remove entirely. No replacement should use chmod o+r or world-readable modes.

Add Permission helpers

applySitePermissions(siteDir string, public bool) error
currentHostUser() (string, error)
pubGroupForUser(user string) string    // returns "<user>-pub"
privateGroupForUser(user string) string
runPermissionCommand(name string, args ...string) error

Shell out to standard Unix tools (chgrp, chmod, find). No interfaces.

Modify Service.Deploy

After staging + saving, call applySitePermissions(siteDir, site.Public). Apply regardless of Config.Public.Enabled — permissions are always correct, even before public Caddy starts.

Modify Service.SetVisibility

Both publish and unpublish call applySitePermissions(). Unpublish revokes group access.

internal/serve/site.go

No change — keep Public bool, keep PublicSites()

internal/serve/caddy.go

No change — keep existing RenderCaddyfile filter for !s.Public

cmd/serve/publish.go / cmd/serve/deploy.go

No change — behavior changes through Service layer only

internal/host/host.go

Modify Manager.Create

  1. groupadd <name>-pub (treat "already exists" as OK)
  2. usermod -aG <name>-pub caddy
  3. systemctl restart caddy (pick up new supplementary group)

Modify Manager.Delete

groupdel <name>-pub — ignore "does not exist" errors

Beast Host Integration

beast host create <name> provisions the user's public namespace automatically:

  1. Resolve host user name: <name>
  2. Create publish group: groupadd <name>-pub
  3. Create user as today: useradd ...
  4. Create home as today: install -d ...
  5. Add Caddy to pub group: usermod -aG <name>-pub caddy
  6. Restart Caddy: systemctl restart caddy

The pub group is not stored — always derived as <name>-pub.

Publish / Unpublish Flow

Publish

beast serve publish report (as user agent-kai)

  1. Find site in manifest
  2. Set site.Public = true, save manifest + site.json
  3. chgrp -R agent-kai-pub <site-dir>
  4. find <site-dir> -type d -exec chmod g+rx {} +
  5. find <site-dir> -type f -exec chmod g+r {} +
  6. chmod -R o-rwx <site-dir>
  7. Regenerate Caddyfile, reload Caddy

Unpublish

beast serve unpublish report

  1. Find site in manifest
  2. Set site.Public = false, save manifest + site.json
  3. chgrp -R agents <site-dir>
  4. chmod -R g-rwx,o-rwx <site-dir>
  5. find <site-dir> -type d -exec chmod u+rwx {} +
  6. find <site-dir> -type f -exec chmod u+rw {} +
  7. Regenerate Caddyfile, reload Caddy

Deploy Flow

Private deploy

beast serve deploy ./dist --slug report
→ stage files, Public=false, apply private perms (owner-only), regen Caddy

Public deploy

beast serve deploy ./dist --slug report --public
→ stage files, Public=true, apply pub-group perms, regen Caddy

Re-deploying a public site re-applies pub-group permissions after staging.

Doctor Checks

  1. public_groups_exist — for each public site, verify <owner>-pub group exists
  2. public_site_group — verify site dir group is <owner>-pub
  3. public_site_modes — dirs have group execute, files have group read, no world bits
  4. private_site_not_world_readable — no world permissions on private sites
  5. caddy_group_membershipcaddy user is in each needed <owner>-pub
  6. running_caddy_effective_groups — Caddy process has the groups (not just /etc/group)
  7. no_world_readable_public_sites — detect old makeWorldReadable remnants

Migration

Convert existing world-readable public sites to pub-group permissions:

  1. Create pub group: groupadd <owner>-pub
  2. Add Caddy: usermod -aG <owner>-pub caddy
  3. For each public site: chgrp -R <owner>-pub, set group-readable, remove world bits
  4. For each private site: chgrp -R <private-group>, remove group+world bits
  5. Restart Caddy, run beast serve doctor

No manifest schema migration needed.

Implementation Order

  1. Replace makeWorldReadable with pub-group permission helpers in service.go. Wire into Deploy and SetVisibility. Add unit tests.
  2. Update beast host create to provision <name>-pub group and add Caddy. Update Delete to remove group.
  3. Add doctor checks for group existence, Caddy membership, mode correctness.
  4. Run migration on current serve data. Restart Caddy. Validate end-to-end.

What was rejected