# AppArmor profile for the SHCP web daemon (shcpd). SC-305.
#
# Shipped inside the .deb at /etc/apparmor.d/shcpd and loaded enforce-mode by
# the package postinst via `apparmor_parser -r -W`; unloaded in postrm. Deb
# counterpart to the dovecot/valkey profiles (SC-135). The installer NEVER
# disables AppArmor.
#
# *** NAMED PROFILE, NOT PATH-ATTACHED — this is load-bearing, not stylistic.
# ***  /usr/sbin/shcpd is a SHARED binary: it backs three distinct execs —
#   1. shcpd.service        (User=shcp, the FrankenPHP/Caddy web daemon)  <-- CONFINED
#   2. shcp-worker.service  (User=root, `shcpd php-cli bin/console messenger:consume`)
#   3. /usr/sbin/shcp CLI   (`exec /usr/sbin/shcpd php-cli ...`, root admin)
# dovecot/valkey own dedicated binaries, so `profile /usr/sbin/dovecot` safely
# attaches by path. Doing the same here (`profile /usr/sbin/shcpd`) would drag
# the root worker + CLI into this confinement and break every privileged
# operation (Apache config writes, certbot, useradd, PowerDNS). So this is a
# NAMED profile `shcpd` with NO attachment path; systemd applies it to the web
# daemon ALONE via `AppArmorProfile=-shcpd` on shcpd.service. The worker and the
# CLI stay deliberately unconfined in v1 (SC-069 worker sandbox unchanged).
#
# *** FIRST-DRAFT PROFILE — iterate under enforce on trixie + noble + arm64
# per the SC-135-style per-cell loop before the checkpoint closes. *** The
# FrankenPHP/Caddy runtime footprint (Caddy XDG data dir, /dev nodes, proc/sys
# reads, systemd notify) is very likely incomplete and will surface DENIED
# lines on the first enforce run — add the MINIMAL specific rule and rebuild,
# exactly like dovecot's SC-135 enforce-iters #2..#5. Cannot be enforce-tested
# at authoring time (no deb VM); see tests/e2e/60-shcpd-apparmor.sh.
#
# The `abi` line is intentionally omitted so apparmor_parser uses the running
# kernel's feature set — one profile works across trixie (AppArmor 4.x) and
# noble.

include <tunables/global>

profile shcpd flags=(attach_disconnected) {
  include <abstractions/base>
  include <abstractions/nameservice>
  include <abstractions/openssl>
  # System CA trust store (read-only). abstractions/openssl grants openssl.cnf
  # and the provider modules but NOT the trust anchors, so shcpd's outbound TLS
  # to license.senternal.com (LicenseApiClient) could present its own cert yet
  # not verify the server chain — panel licensing failed closed under enforce.
  # ssl_certs is the minimal upstream grant for /etc/ssl/certs, the
  # ca-certificates share and /etc/pki; it is a distinct abstraction from
  # openssl (the dovecot quota-warning helper includes both for the same
  # reason). rpm parity: shcpd_t already reaches the CA bundle via
  # miscfiles_read_generic_certs (cert_t) in selinux/shcpd.te.
  include <abstractions/ssl_certs>

  # Capability — exactly the set shcpd.service grants
  # (CapabilityBoundingSet=CAP_NET_BIND_SERVICE). The daemon binds its TLS
  # listeners; no other capability is reachable through this profile, so a
  # FrankenPHP/PHP RCE gains nothing beyond binding a port.
  capability net_bind_service,

  # The shared binary (read + mmap for execution). `mr` only — NOT ix: the web
  # daemon does not re-exec itself into another profile, and it must never gain
  # x on a sibling that would let it escape confinement.
  /usr/sbin/shcpd mr,

  # *** THE ONLY EXECUTABLE THIS PROFILE GRANTS (SC-305, SC-356) ***
  # The panel's server pages read systemd state synchronously on the request
  # path: unit active/enabled state, MainPID/MemoryCurrent/ActiveEnterTimestamp,
  # and the SRV-5 automation timer table. With no `x` at all those reads did not
  # degrade — they raised ProcessStartFailedException and returned 500 on the
  # panel landing page, /server/automation/api/list and /server/reboot. SC-356
  # made the code fail closed instead of fataling; this rule is what actually
  # returns the data.
  #
  # Scope was measured, not guessed: the whole /server surface was exercised
  # against this profile in complain mode, and `systemctl` is the ONLY binary
  # the daemon ever execs.
  #
  # *** NO SHELL IS GRANTED, AND NONE IS NEEDED. *** Early enforce runs also
  # logged a denial for /usr/bin/dash, which reads like the panel wanting a
  # shell. It does not: Symfony passes an ARGV ARRAY to proc_open, so there is
  # no shell in the happy path — PHP only falls back to /bin/sh AFTER posix_spawn
  # is refused. Allow systemctl and the fallback never triggers; the dash denial
  # disappears on its own. So W^X on the confined daemon still holds, and a
  # FrankenPHP/PHP RCE gains no interpreter here.
  #
  # `ix` — inherit, deliberately NOT Px/Ux: systemctl runs under THIS profile
  # rather than transitioning to an unconfined or freer one, so it can reach
  # nothing the daemon could not already reach.
  #
  # This grants VISIBILITY, NOT CONTROL. shcpd runs as uid `shcp`, and unit
  # control is mediated by polkit, not by file permissions: `systemctl restart
  # apache2` as `shcp` returns "Interactive authentication required", and the
  # installer ships no polkit rule granting that uid. Verified on a stock box.
  # Panel-initiated start/stop/restart continues to go through the privileged
  # worker over the message bus, which is unconfined by design.
  /usr/bin/systemctl ix,

  # systemctl's own runtime, needed once it is exec'd under this profile.
  # @{multiarch} so one profile serves x86_64 and arm64; the soname is globbed
  # so a systemd major bump does not require a profile edit.
  /usr/lib/@{multiarch}/systemd/libsystemd-shared-*.so mr,

  # Unit definitions and drop-ins systemctl resolves to answer those queries.
  # Read-only, and unit files carry no secrets — the panel's own environment
  # lives in /etc/shcp/panel.env, which is NOT reachable from here.
  /etc/systemd/system/ r,
  /etc/systemd/system/** r,
  /{usr/,}lib/systemd/system/ r,
  /{usr/,}lib/systemd/system/** r,
  /run/systemd/system/ r,
  /run/systemd/system/** r,

  # systemctl's D-Bus read path — needed on UBUNTU, inert on Debian. Ubuntu
  # builds dbus-daemon with AppArmor mediation and Debian does not, so the
  # exact systemctl that works confined on trixie gets its
  # org.freedesktop.systemd1 Properties.Get REFUSED on noble, falls back to
  # the /run/systemd/private control socket (denied below, deliberately),
  # then a ptrace read of PID1 (also not granted), and reports "Failed to
  # retrieve unit state: Access denied" (noble e2e run 31861005395, leg 60
  # forensics). Grant ONLY the read-only query surface: Properties and the
  # Manager lookup/list members. Mutating members (StartUnit, StopUnit,
  # RestartUnit, …) stay unmatched — panel-initiated control keeps going
  # through the unconfined privileged worker, same as ever.
  dbus (send)
      bus=system
      path=/org/freedesktop/systemd1{,/**}
      interface=org.freedesktop.DBus.Properties
      member={Get,GetAll}
      peer=(name=org.freedesktop.systemd1),
  dbus (send)
      bus=system
      path=/org/freedesktop/systemd1
      interface=org.freedesktop.systemd1.Manager
      member={GetUnit,GetUnitByPID,GetUnitFileState,ListUnits,ListUnitsByNames,LoadUnit}
      peer=(name=org.freedesktop.systemd1),
  # Replies come back over the bus connection; the Hello handshake is part of
  # opening it.
  dbus (send)
      bus=system
      path=/org/freedesktop/DBus
      interface=org.freedesktop.DBus
      member={Hello,AddMatch,RemoveMatch,GetNameOwner,StartServiceByName}
      peer=(name=org.freedesktop.DBus),
  dbus (receive)
      bus=system
      peer=(name=org.freedesktop.systemd1),

  # *** DELIBERATELY NOT GRANTED, noble fallback chain ***
  #   /run/systemd/private — PID1's root-equivalent control channel: connect
  #     access IS manage-units. The panel only ever needs reads; keep denied.
  #   ptrace (read) peer=unconfined — would let a confined RCE read
  #     /proc/<pid>/environ of arbitrary root processes (service credentials).
  #     systemctl only tries it after the D-Bus path fails; with D-Bus granted
  #     the fallback is never reached.

  # Installed-package versions for the /server/services version column (SC-249).
  # Read-only query against the dpkg database, and it genuinely works as uid
  # `shcp` — verified on a stock box. The dpkg database lists what is installed;
  # it holds no credentials.
  /usr/bin/dpkg-query ix,
  /var/lib/dpkg/ r,
  /var/lib/dpkg/** r,

  # *** THIRD GRANTED EXECUTABLE (SC-305, SC-356; SC-528) ***
  # Postfix pcre-map preview (base#668/#1002). The helo/sender/body/command_filter
  # editors run the operator's UNSAVED buffer through Postfix's OWN matcher —
  # `postmap -q <sample> pcre:<tempfile>` — so an over-broad regex is visible
  # BEFORE it is saved; PHP's PCRE dialect differs, so only postmap answers
  # truthfully. ROLE_ADMIN, header-CSRF, rate-limited, and wall-time-bounded
  # (base#1004). It is the FIRST web-tier shellout to postmap — the write path
  # only ever dispatches the privileged worker.
  #
  # `ix` — inherit, exactly like systemctl/dpkg-query above: postmap runs under
  # THIS profile, reaches nothing shcpd could not already reach, and can never
  # transition to a freer domain. This does NOT re-open the W^X/RCE hole the
  # /usr/bin/php denial guards, because postmap is a BOUNDED map utility, NOT an
  # interpreter — the SC-356 distinction that separates it from the php/dash execs
  # deliberately refused. And unlike postqueue/postdrop (set-gid, still denied
  # below) postmap carries NO set-id bit, so `ix` confers no credential the daemon
  # does not already hold.
  /usr/sbin/postmap ix,
  # postmap reads main.cf for config parameters and resolves its dynamic map
  # plugins via /etc/postfix/dynamicmaps.cf, then dlopen()s dict_pcre from the
  # postfix lib dir. `mr` there is executable-mmap of fixed root-owned .so files
  # (same shape as the libsystemd-shared grant above) — they are NOT writable
  # here, so W^X on the confined daemon still holds.
  /etc/postfix/ r,
  /etc/postfix/** r,
  /usr/lib/postfix/ r,
  /usr/lib/postfix/** mr,
  # The operator's unsaved buffer is staged by shcpd via
  # tempnam(sys_get_temp_dir(), 'shcp_pcre_preview_') and read by postmap. NO new
  # rule is needed: PHP's sys_temp_dir is /opt/shcp/var/tmp (installer php.ini),
  # which the /opt/shcp/var/** and /opt/shcp-releases/*/var/** grants above
  # already cover rwk — shcpd creates+writes+unlinks it, and postmap (running
  # under THIS profile via ix) reads it through those existing rules.

  # *** journalctl — the /server per-service "View Logs" pane (SC-305 amendment,
  # shcp-build#165). ***
  # SystemServiceStatusService::journalFor() reads one systemd unit's journal on
  # the request path — `journalctl -q -u <unit> -n <n> --no-pager
  # --output=short-iso` — to render the log viewer. $unit is always resolved from
  # a SystemService/PhpVersion enum, NEVER a request string (SC-269), so that
  # enum allow-list is the confinement, not the (broad) journal grant.
  #
  # With no `x` the exec raised ProcessStartFailedException (SC-356 fail-closed),
  # and the pane showed the MISLEADING "service account may lack permission to
  # read the system journal" for EVERY service — on a host where the account has
  # the permission fine.
  #
  # Unlike fail2ban-client below, this genuinely WORKS as uid `shcp`: the
  # installer puts `shcp` in the systemd-journal group (system_accounts.sh,
  # asserted by functions/sanity.sh), so the DAC layer already lets the account
  # read the journal. Once AppArmor permits the exec plus the journal-store
  # reads, the pane returns real log lines. No shell is involved — Symfony passes
  # an argv ARRAY to proc_open, so posix_spawn runs journalctl directly and the
  # /bin/sh fallback (and its /usr/bin/dash denial) never triggers, exactly as
  # granting systemctl above made the earlier dash denial vanish.
  #
  # `ix` — inherit, matching /usr/bin/systemctl and /usr/bin/dpkg-query above:
  # journalctl runs under THIS profile and can reach nothing the daemon could not
  # already reach. NOT Px — that needs a shipped child profile or the exec is
  # denied outright. journalctl links libsystemd-shared-*.so, already granted
  # above for systemctl. The code invokes /bin/journalctl; every supported distro
  # is usr-merged (/bin -> /usr/bin) and AppArmor mediates the RESOLVED path, so
  # the /usr/bin form below is the one that matches — same as the systemctl rule,
  # whose callers likewise reach the binary through the usr-merge symlink.
  /usr/bin/journalctl ix,

  # The journal store journalctl reads: the persistent tree and the
  # pre-persistence runtime tree. Read-only — journal files carry system logs,
  # no panel secrets, and SC-269's unit allow-list bounds which unit's lines are
  # ever requested. Scoped to the journal dirs alone, not a wider /var/log grant.
  /var/log/journal/ r,
  /var/log/journal/** r,
  /run/log/journal/ r,
  /run/log/journal/** r,

  # *** hostname(1) — the server FQDN for the /server/network page + the
  # dashboard host tiles (SC-305, SC-356). ***
  # NetworkConfigService::getHostname() runs `hostname -f` via Symfony Process
  # (an argv ARRAY to proc_open) to resolve the box's FQDN on the request path.
  # With no `x` the exec raised ProcessStartFailedException — Process::run() there
  # has no try/catch, so unlike the @fopen probes it did NOT degrade to an empty
  # field, it 500'd /server/network. The code invokes /bin/hostname; every
  # supported distro is usr-merged (/bin -> /usr/bin) and AppArmor mediates the
  # RESOLVED path, so the /usr/bin form matches — exactly as for systemctl and
  # journalctl above.
  #
  # `ix` — inherit, matching systemctl/dpkg-query/postmap/journalctl: hostname
  # runs under THIS profile and reaches nothing the daemon could not already
  # reach. NOT Px/Ux. hostname is a bounded identity utility, NOT an interpreter,
  # and carries no set-id bit, so ix confers no new credential and no W^X hole —
  # the SC-356 distinction that keeps the php/dash execs denied.
  #
  # This grant is ALSO what makes the secondary /usr/bin/dash denial vanish: with
  # the real target granted, posix_spawn succeeds and PHP never falls back to
  # /bin/sh — precisely the argument the systemctl comment makes above. So NO
  # dash rule is added here.
  /usr/bin/hostname ix,

  # ModSecurity audit log — the /admin/waf/hits viewer (WafAuditLogReader,
  # SEC-6/SC-478; installer#698). root:www-data 0640 (configure_apache_modsecurity
  # chowns it), so DAC already lets `shcp` read it — this profile was the
  # actual blocker: no rule named this path, so every open() was AppArmor-denied
  # and the viewer silently rendered zero hits with no error (leg 73-modsecurity,
  # debian-trixie e2e). isAvailable() checks is_file()+is_readable(), which pass
  # under DAC regardless of AppArmor — the denial only surfaces at the later
  # fopen(), past the point where the reader could report "unavailable" instead
  # of "no hits" (SC-575). One exact path, read-only: the
  # reader tails the live file only, never a rotated one.
  /var/log/apache2/modsec_audit.log r,

  # *** DELIBERATELY NOT GRANTED — these four stay DENIED on purpose. ***
  # The panel makes other synchronous shell-outs on request paths. Since SC-356
  # they degrade to an unknown/empty field instead of 500ing, so each of these
  # is a blank widget, never a broken page. The exec surface is NOT widened to
  # fill them in; where a field is genuinely wanted the fix is to collect it in
  # the UNCONFINED WORKER and read it back, per SC-356.
  #
  # journalctl was RECONSIDERED alongside these in shcp-build#165 and GRANTED
  # above — but only because it clears both bars the two below fail: it works as
  # uid `shcp` (systemd-journal group) AND it is not an interpreter. fail2ban and
  # the php-version probe fail one bar each, so they stay denied here and are
  # being re-homed to the unconfined privileged worker on the shcp-base side
  # (SC-356 route), NOT solved by a profile rule. Do not "fix" this file by
  # adding either — the reasons below are why that would be wrong:
  #
  #   /usr/bin/fail2ban-client — SSH-jail indicator (isFail2banSshEnabled, which
  #     backs a health badge that today FALSE-POSITIVES "SSH jail is not
  #     enabled"). It is a thin client for /var/run/fail2ban/fail2ban.sock,
  #     `srwx------ root:root`; as `shcp` it returns "Permission denied to socket
  #     ... (you must be root)". Granting exec would widen the surface and STILL
  #     not make the probe work — the fix is a root-worker probe (shcp-base#165
  #     follow-up), not this file.
  #   /usr/bin/php8.5 — per-version PHP-FPM version string (blank version column).
  #     This is an INTERPRETER. Handing the confined web daemon the ability to
  #     exec one re-opens, by another door, exactly what the W^X reasoning on the
  #     `/ rw` rule below is protecting: arbitrary code of the attacker's
  #     choosing. It, too, moves to the unconfined worker rather than here.
  #   /usr/sbin/postqueue — mail-queue depth. postqueue is SETGID postdrop, and
  #     `ix` does not strip setgid, so this would hand a confined RCE a
  #     credential the daemon does not otherwise hold.
  #   /usr/bin/grep — a leftover shell pipeline; the panel should not be
  #     shelling out to grep at all.
  #
  # A denied exec also logs a SECOND audit line for /usr/bin/dash, because PHP
  # retries through a shell once posix_spawn is refused. That pair is expected
  # audit noise from the deliberate denials above, NOT a missing rule — and it
  # is precisely why granting systemctl above made the earlier dash denial
  # vanish rather than requiring a shell rule.

  # *** BLUE/GREEN LAYOUT (UPD-3, plan §4.12) — WHY THESE PATHS ARE DOUBLED ***
  # /opt/shcp is a SYMLINK into /opt/shcp-releases/<version>/. AppArmor mediates
  # the RESOLVED path, not the one the process opened, so a rule written against
  # /opt/shcp/** matches NOTHING once the symlink exists — the confined daemon
  # would be denied its own code tree and the panel would not start. Every
  # /opt/shcp rule below therefore has an /opt/shcp-releases/*/ twin.
  # The /opt/shcp forms are kept, not replaced: a host the update engine has not
  # yet converted still has a real flat directory there, and one profile has to
  # serve both. `*` is a single path component (the version), so a release dir
  # can never widen the grant beyond one level.

  # Symfony application tree (read): code, templates, config, public/ webroot,
  # dav/ WebDAV endpoint, the compiled container, importmap assets.
  /opt/shcp/ r,
  /opt/shcp/** r,
  /opt/shcp-releases/ r,
  /opt/shcp-releases/*/ r,
  /opt/shcp-releases/*/** r,

  # Symfony writable state under %kernel.project_dir%/var. Since UPD-3 this is
  # the PER-RELEASE half only — cache and tmp — because the compiled container
  # embeds the release's absolute paths and cannot be shared. rwk: Symfony and
  # Caddy take flock on cache files.
  /opt/shcp/var/ rw,
  /opt/shcp/var/** rwk,
  /opt/shcp-releases/*/var/ rw,
  /opt/shcp-releases/*/var/** rwk,

  # The SHARED half of var/ (§4.12): sessions, logs, locks, the PSL cache and the
  # node control-plane mTLS material, all relocated out of the release dir so a
  # flip does not discard them. <release>/var/<name> is a symlink here, and the
  # rule above does not cover it — AppArmor resolves the link and lands on this
  # path instead. Without this the panel comes up unable to write a session.
  # NOT granted: /var/lib/shcp/kv, the installer's root-owned secret store, which
  # is deliberately outside panel-state so this grant cannot reach it.
  /var/lib/shcp/panel-state/ rw,
  /var/lib/shcp/panel-state/** rwk,
  # Root-owned compatibility alias for retained pre-rename releases. The
  # updater refuses any legacy symlink that does not resolve exactly above.
  /var/lib/shcp/panel-var/ rw,
  /var/lib/shcp/panel-var/** rwk,

  # Caddy's XDG dirs. shcpd runs as `shcp` with HOME=/opt/shcp; Caddy resolves
  # its data dir to $XDG_DATA_HOME (~/.local/share/caddy) and config to
  # $XDG_CONFIG_HOME (~/.config/caddy). `persist_config off`/`auto_https off`
  # keep these near-empty, but Caddy still creates + locks them at startup.
  # HOME resolves through the symlink, so these land inside the release dir.
  owner /opt/shcp/.local/ rwk,
  owner /opt/shcp/.local/** rwk,
  owner /opt/shcp/.config/ rwk,
  owner /opt/shcp/.config/** rwk,
  owner /opt/shcp-releases/*/.local/ rwk,
  owner /opt/shcp-releases/*/.local/** rwk,
  owner /opt/shcp-releases/*/.config/ rwk,
  owner /opt/shcp-releases/*/.config/** rwk,

  # WebDAV (CalDAV/CardDAV) document root — served via the /dav/* handler in
  # shcpd.conf, backed by server.php under /opt/shcp/dav. Read the app files,
  # write the per-principal calendar/addressbook data.
  /opt/shcp/dav/ r,
  /opt/shcp/dav/** rw,
  /opt/shcp-releases/*/dav/ r,
  /opt/shcp-releases/*/dav/** rw,

  # Panel persistence: the single SQLite DB (DATABASE_URL=sqlite:////var/lib/
  # shcp/shcp.db) plus its WAL/SHM sidecars. rwk — SQLite POSIX-locks the DB
  # and its journal. The dir itself is rw so SQLite can create the -wal/-shm
  # files on first write.
  /var/lib/shcp/ rw,
  /var/lib/shcp/shcp.db rwk,
  /var/lib/shcp/shcp.db-wal rwk,
  /var/lib/shcp/shcp.db-shm rwk,
  /var/lib/shcp/shcp.db-journal rwk,

  # The update engine's state dir (UPD-8, SC-416).
  # TWO files, read-only, named exactly. Not `/var/lib/shcp/update/** r` — that
  # glob would also hand a network-reachable process the run journals, the
  # request file. The pre-update DATABASE SNAPSHOTS are NOT in this tree —
  # they live at /var/lib/shcp/db-backups/pre-update, which no rule in this
  # profile covers at all, and SC-272 makes them app-owned 0640 so AppArmor
  # is the only thing keeping them away from the web tier. A recursive glob
  # one level up (/var/lib/shcp/** r) would reach both, which is why the
  # scope test asserts against the whole ruleset and not just rules that
  # happen to mention `update`.
  #
  # maintenance.json: for the BANNER, not for the gate. Measured on a real
  # enforce-mode box (demo.shcp.dev, 2026-08-01), because an earlier version of
  # this comment claimed the opposite and was wrong:
  #
  #   stat  (is_file/filesize)  ALLOWED without any rule
  #   open  (file_get_contents) DENIED  without a rule
  #
  # AppArmor mediates the open, not the stat. So UPD-7's gate — which is
  # `is_file()` in MaintenanceFlag::isActive() — closes correctly on a confined
  # box with or without this line. SC-318 is NOT inverted, and this rule does
  # not fix a security defect.
  #
  # What it does fix is MaintenanceFlag::read(), which opens the file for the
  # run id and the recorded timestamp. Without the grant the banner cannot name
  # the run holding the gate and staleness falls back to the file mtime —
  # degraded, but never fail-open. The file stays 0600.
  #
  # status.json: the §4.9 progress doc, 0644, scheduling scalars only.
  /var/lib/shcp/update/runs/*/status.json r,
  /var/lib/shcp/update/maintenance.json r,

  # apt pin files for the read-only pins view (UPD-8). World-readable already;
  # this is the MAC half. Read-only and scoped to the pins dir — the panel has
  # no business anywhere else under /etc/apt.
  /etc/apt/preferences.d/ r,
  /etc/apt/preferences.d/* r,

  # Canonical PHP/Caddy configuration. Keep exact grants: /etc/shcp also holds
  # root-only panel state that the confined web daemon must not read.
  /etc/shcp/ r,
  /etc/shcp/shcpd.conf r,
  /etc/shcp/php.ini r,
  /etc/shcp/preload.php r,
  /etc/shcp/conf.d/ r,
  /etc/shcp/conf.d/* r,
  /etc/shcp/backup-stream.key r,
  /etc/shcp/file-broker.key r,

  # Physical rollback snapshot retained only on upgraded hosts.
  /etc/shcpd/ r,
  /etc/shcpd/shcpd.conf r,
  /etc/shcpd/php.ini r,
  /etc/shcpd/preload.php r,
  /etc/shcpd/conf.d/ r,
  /etc/shcpd/conf.d/* r,
  /etc/shcpd/backup-stream.key r,
  /etc/shcpd/file-broker.key r,

  # The canonical panel env (UPD-3 / AD-4). Symfony's Dotenv reads .env.local on
  # every boot, and since UPD-3 that is a symlink to /etc/shcp/panel.env — a
  # It shares /etc/shcp with other root-managed state, so it remains an exact
  # grant rather than widening the daemon-config allowlist. Without this rule the daemon cannot read its own
  # DATABASE_URL or APP_SECRET and fails at boot. Read-only: only root writes it.
  /etc/shcp/panel.env r,

  # Daemon logs: SHCP_SERVE_LOG_DIR=/var/log/shcpd (access.log,
  # control_access.log, roundcube/phpmyadmin access logs).
  /var/log/shcpd/ rw,
  /var/log/shcpd/** rw,

  # Panel login-failure log, read by the shcp-panel fail2ban jail (SC-442,
  # SC-473). The panel's Monolog auth handler appends here; the installer
  # pre-creates it, so normally this is append-only, but `rwk` also covers a
  # recreate if it is ever removed. Scoped to exactly this file, not /var/log/**.
  /var/log/shcp/ r,
  /var/log/shcp/auth.log rwk,

  # TLS material. The panel + RCM + PMA listeners terminate TLS with the SHCP
  # host cert/key (0640 root:ssl-cert; shcp is in ssl-cert). The node-mtls certs
  # for the control listener moved to /var/lib/shcp/panel-state/node-mtls in UPD-3
  # (they are the node's enrolment identity and must outlive a release flip) —
  # covered by the panel-state rule above, no longer by the /opt/shcp/var one.
  /etc/ssl/certs/shcp.crt r,
  /etc/ssl/private/shcp.key r,

  # RCM (Roundcube) + PMA (phpMyAdmin) document roots — shcpd serves these as
  # static roots and proxies .php to system PHP-FPM (below). Read-only: shcpd
  # only needs to stat/serve static assets; PHP execution happens in FPM. The
  # /var/www/{webmail,phpmyadmin} entry points are SYMLINKS to versioned real
  # dirs (roundcubemail-1.6.15, phpMyAdmin-5.2.3-all-languages) that AppArmor
  # resolves to; allow all of /var/www read-only so a webmail/PMA version bump
  # doesn't require a profile edit. /var/www holds only these system webapps
  # (tenant sites live under /home), so read here discloses nothing tenant.
  /var/www/ r,
  /var/www/** r,

  # SHMP P1b transfer data plane (SC-461 transfer data-path isolation; SC-152
  # control/peer plane on the hardened listener). The peer route
  # /peer/v1/transfers/{grantId}/artifact streams a staged export artifact via
  # StreamedResponse readfile (same pattern as FileManagerController::download).
  # Artifacts stage in a DEDICATED transfers/ subdir of the backup scratch,
  # written by the shcp worker (owner of the shcp:shcp 0700 scratch) as
  # shcp:shcp 0600 — the uid is fine (shcpd is shcp), but without this grant
  # shcpd's open() is AppArmor-DENIED and /peer/v1 serves zero bytes. Read-only
  # (shcpd never writes here — the worker stages under its own confinement) and
  # scoped to transfers/ ALONE, deliberately NOT /var/backups or the whole
  # scratch: other tenants' backup archives staged in the same scratch stay
  # unreadable to the confined web daemon.
  /var/backups/shcp-backup-scratch/transfers/** r,

  # PHP-FPM FastCGI socket for the RCM/PMA vhosts (php_fastcgi unix/...). The
  # concrete path is /run/php/php<ver>-fpm.sock; glob the version so a PHP
  # minor bump doesn't require a profile edit.
  /run/php/ r,
  /run/php/php*-fpm.sock rw,
  # TENANT per-pool FPM status probe (base#1113, SC-356). The RCM/PMA glob above
  # is the SYSTEM-PHP socket only; each tenant site runs in its OWN pool whose
  # listen socket is /run/php/php<ver>-fpm-<user>.sock (PhpVersion::fpmSocketPath,
  # e.g. /run/php/php8.4-fpm-alice.sock) — the trailing `-<user>` segment means
  # `php*-fpm.sock` above does NOT match it. base#1113 added pm.status_path=/status
  # to those pools and a FastCGI status client so /server shows per-pool worker
  # counts; the panel (PHP under this profile) connect()s each pool socket on the
  # request path. Without this rule Noble's AppArmor 4 mediates the socket PATH as
  # class="file" and EACCES-denies the connect, so every per-pool probe silently
  # degrades to "unavailable" (the SC-135/SC-356 gap, same shape as the valkey/
  # file-broker sockets below). The sockets are 0660 apache:apache and shcp is a
  # member of the apache group (installer system_accounts.sh), so DAC already
  # permits the connect — this rule is the MAC half only. rw matches the
  # requested_mask, as with the RCM/PMA socket.
  /run/php/php*-fpm-*.sock rw,

  # Path-bound unix sockets the panel (PHP under this profile) connects to.
  # Debian/EL sufficed with the `network unix stream` rule below, but Ubuntu
  # Noble's AppArmor 4 ALSO mediates the socket PATH (a connect audits as
  # class="file"), so each needs an explicit file rule or the connect is
  # EACCES-denied — the panel then reports "temporarily unavailable" for file
  # ops, backups, and tenant-DB admin (SC-135 gap surfaced by the Noble e2e).
  # AlmaLinux is unaffected (SELinux, not AppArmor); Debian's older AppArmor
  # didn't path-mediate unix sockets. rw matches the observed requested_mask.
  /run/valkey/ r,
  /run/valkey/valkey-server.sock rw,     # cache + rate-limiter + locks
  /run/shcp/ r,
  /run/shcp/file-broker.sock rw,         # tenant file-manager ops (SC-217)
  # Mail queue listing published by the root collector (shcp:mail:health-sample).
  # A plain READ of a root:shcp 0640 file on tmpfs -- no exec, no socket, no new
  # capability. It lives here rather than in the panel database because the entries
  # carry sender/recipient addresses for every tenant and the database is inside the
  # server backup, so a column would inherit 30/90-day offsite retention
  # (SC-411).
  /run/shcp/mail-queue-entries.json r,
  # Top-senders + per-domain delivery-rate aggregates published by the root
  # collector (shcp:mail:sender-stats, shcp-base#567). Same shape as the queue
  # listing above: a plain READ of a root:shcp 0640 tmpfs file, off the DB/backup
  # because it is queue-entry metadata (SC-411). Path-specific, not a glob — the
  # profile grants /run/shcp/ directory read but each file is named explicitly.
  /run/shcp/mail-sender-stats.json r,
  /run/shcp/backup-stream.sock rw,       # tenant backup stream (SC-212)
  /{,var/}run/mysqld/ r,
  /{,var/}run/mysqld/mysqld.sock rw,     # tenant DB admin (Doctrine mariadb conn)

  # Caddy's admin unix socket — unlike every /run/shcp socket above (which the
  # daemon CONNECTS to, created by a root helper), the daemon CREATES + binds +
  # listens on this one itself. It is the ONLY reload path (Caddy v2 has no
  # signal reload), so `shcpd reload` can hot-load config instead of a full
  # restart (installer#334). The unit's RuntimeDirectory=shcpd owns /run/shcpd
  # (shcp, 0700) and the socket is bound 0600, so no tenant can reach the
  # unauthenticated admin API. `rw` covers create+bind+connect (SC-491).
  /run/shcpd/ r,
  /run/shcpd/admin.sock rw,

  # Networking: the TLS listeners (inet + inet6 stream) and the unix stream
  # socket to PHP-FPM.
  network inet stream,
  network inet6 stream,
  network unix stream,

  # proc/self bits the Go (Caddy) + PHP runtimes read for process introspection
  # and the memory-overcommit / boot-id / listen-backlog probes.
  @{PROC}/ r,
  @{PROC}/@{pid}/ r,
  @{PROC}/@{pid}/** r,
  @{PROC}/sys/kernel/random/boot_id r,
  @{PROC}/sys/vm/overcommit_memory r,
  @{PROC}/sys/net/core/somaxconn r,

  # Host load average + uptime for the dashboard's server-health tiles. These
  # are @{PROC} ROOT files, not @{PROC}/@{pid}/ ones, so the self-introspection
  # rules above do not cover them and both were DENIED on a stock install.
  # World-readable kernel counters carrying no tenant data. SC-356.
  @{PROC}/loadavg r,
  @{PROC}/uptime r,

  # Machine identity: the Go/PHP runtime reads /etc/machine-id (stable node id).
  /etc/machine-id r,

  # GeoIP database, READ ONLY.
  #
  # shcp-geoip-update.service (root, UNCONFINED) downloads the DB-IP country
  # mmdb here; the panel only ever reads it, so no write grant is warranted —
  # and withholding one means a PHP RCE cannot poison the geolocation data that
  # firewall and audit surfaces make decisions from.
  #
  # Without this rule the confined daemon was denied its own database:
  #   apparmor="DENIED" operation="open" profile="shcpd"
  #     name="/usr/share/GeoIP/dbip-country-lite.mmdb" requested_mask="r"
  # and every lookup logged "Failed to open GeoIP database" while silently
  # returning nothing. It stayed hidden because the producer was ALSO broken —
  # the timer died at 226/NAMESPACE on a missing directory (installer#111), so
  # the file never existed and the error read as the missing-file case. Fixing
  # the producer is what exposed the consumer.
  #
  # The directory itself is readable so the reader can stat/open by path.
  /usr/share/GeoIP/ r,
  /usr/share/GeoIP/** r,

  # cgroup v2: the Go (Caddy) runtime reads memory limits to right-size
  # GOMEMLIMIT. It parses /proc/self/cgroup (allowed above), then walks UP the
  # cgroup hierarchy reading memory.* at each level — the unit's own cgroup
  # (system.slice/shcpd.service/), its parent slice (system.slice/), and the
  # root (/sys/fs/cgroup/) — to find the effective enclosing limit. Allow the
  # whole tree read-only; cgroup accounting files carry no secrets and a
  # confined RCE cannot act on another unit's counters.
  /sys/fs/cgroup/ r,
  /sys/fs/cgroup/** r,

  # PHP opcache shared-memory segment. FrankenPHP's ZTS PHP allocates its
  # opcache SHM via mmap(MAP_SHARED|MAP_ANONYMOUS); an anonymous shared mapping
  # has no backing path, so attach_disconnected reports it as the mount root
  # "/". Grant rw ONLY (satisfies the PROT_READ|PROT_WRITE|MAP_SHARED check) —
  # deliberately NOT m/exec, so a FrankenPHP/PHP RCE cannot map+execute
  # anonymous shellcode (W^X on the confined web daemon holds).
  / rw,

  # systemd Type=simple; if a future unit switches to Type=notify this is the
  # readiness socket. Harmless to allow now.
  /run/systemd/notify w,

  # ---------------------------------------------------------------------------
  # DELIBERATELY NOT GRANTED
  # ---------------------------------------------------------------------------
  # This block is referenced by shcp-installer/tests/e2e/60-shcpd-apparmor.sh,
  # which asserts these binaries stay unexecutable. Written here for the first
  # time so that assertion points at a stated rationale instead of nothing.
  #
  #   /usr/sbin/postqueue   -- NOT granted (x of any kind)
  #   /usr/sbin/postsuper   -- NOT granted (x of any kind)
  #
  # The reason is NOT that a grant would hand the daemon a privilege it lacks.
  # That claim was made when this was first decided and it is false on the facts:
  # `postqueue` is set-gid `postdrop`, and the installer ALREADY puts the `shcp`
  # user in that group on purpose -- see shcp-installer/functions/system_accounts.sh
  # ("postdrop for postqueue -p"). POSIX consults supplementary groups, so the
  # set-gid bit confers nothing the web user does not already hold, and
  # `postqueue -p` runs fine as `shcp` on a box with no MAC policy.
  #
  # The real reasons, in order:
  #
  #   1. SC-356 -- a synchronous shell-out on an HTTP request path is the defect,
  #      independent of privilege. The queue figures are collected by the
  #      unconfined root timer and read back from a snapshot, so the web daemon
  #      never needs the exec at all.
  #   2. `/api/health/` is PUBLIC_ACCESS and carries no rate limit. Granting the
  #      exec would let an unauthenticated caller drive a `postqueue` fork per
  #      request -- an amplification primitive that the exec denial is currently
  #      the only thing preventing (SC-412).
  #   3. `postqueue` reaching the `showq` socket is a read primitive over EVERY
  #      tenant's envelope metadata, handed to the most exposed process on the box.
  #
  # `postsuper` is additionally root-only, so it could not work here regardless;
  # queue mutations ride the `privileged` messenger transport instead.
  #
  # If a future change appears to need one of these, the answer is a collector
  # tick, not a rule.
}
