Repository navigation
Operations Deploy Sudoers
This document is the repo-tracked source of truth for the sudoers capability model the deploy user needs, the reasoning behind each grant, and how Ops verifies a sudoers change. It intentionally does not describe any specific host's live
/etc/sudoers.d/*state — that is an Ops-owned, host-local concern. What ships here is the model every deployment of this project should converge to, plus the verification procedure that proves it.
A bare NOPASSWD: /usr/bin/dnf (or apt-get) grant for a deploy user is
root-equivalent code execution: dnf install from an arbitrary repo,
dnf remove of anything, dnf scriptlets, etc. An earlier version of the
bootstrap path auto-installed ffmpeg through such a grant; GH-133 removed
that call entirely (see _check_ffmpeg in scripts/deploy_server.sh) and
this document records the capability model that replaced it — without
breaking CD, ffmpeg availability, or systemd unit management.
| Capability | Does repo CD need it? | Root required? | Target rule |
|---|---|---|---|
App service restart (systemctl restart wecom-archive-365.service) |
Yes — step 7 of deploy_server.sh
|
Yes | Exact grant: deploy-user ALL=(root) NOPASSWD: /usr/bin/systemctl restart wecom-archive-365.service
|
nginx reload (systemctl reload nginx) |
Yes — ssl-renew/renew-wildcard.sh (not deploy_server.sh) |
Yes | Exact grant (owned by wildcard-ssl-renewal.md) |
systemd unit copy/install (cp .../deploy/systemd/*.service|*.timer|*.path /etc/systemd/system/) |
Yes — step 10, _sync_managed_systemd_units
|
Yes | Exact two-glob cp grants (see deploy_server.sh header) |
systemctl daemon-reload |
Yes — step 10, only when a unit file actually changed | Yes | Exact grant |
Enable timers/paths (systemctl enable --now wecom-*.timer / wecom-*.path) |
Yes — step 10 | Yes |
wecom-* wildcard grant (see "systemd wildcard decision" below) |
Webroot copy / mkdir (cp/mkdir -p /var/www/*) |
Yes — step 9, static homepage publish, only on the root-required fallback tier | Yes (only when the webroot isn't already writable by the deploy user) | Exact two-glob cp + mkdir -p /var/www/*
|
qiniu-ssl-renew-wildcard.timer enable |
Yes — one-time enable for wildcard TLS renewal | Yes | Exact, single-unit grant (#129 pattern) |
Bare /usr/bin/dnf
|
No — never called by any repo script | N/A | Never grant. |
Bare /usr/bin/apt-get
|
No — GH-133 removed the only caller | N/A | Never grant. |
| ffmpeg installation via sudo package manager | No — ffmpeg is a host prerequisite (see below), never installed by the deploy user | N/A | Never add this. If ffmpeg needs installing, an operator/root runs the package-manager command directly — outside the deploy user's sudo scope entirely. |
Two models were considered:
-
Model A — host prerequisite (chosen).
deploy_server.shonly checkscommand -v ffmpeg; if missing, it fails the deploy immediately with an actionable message, restores the working tree (same pattern every other pre-restart failure already uses), and does not touch the running service. Installing ffmpeg becomes a one-time root/operator action (e.g.sudo dnf install -y ffmpeg), independent of the deploy user's own privileges. -
Model B — controlled bootstrap. A separate, narrowly-scoped
mechanism installs ffmpeg automatically. Rejected: the only way to grant
it without falling back to bare package-manager sudo is an exact,
single-purpose sudo rule for something like
/usr/bin/dnf install -y ffmpeg— butdnf installaccepts multiple packages and options in ways sudoers command matching does not meaningfully constrain (unlike the two-argumentcp/systemctlgrants elsewhere in this file, aninstall -y <pkg>line still lets the caller substitute a different package name at that exact position). That reintroduces close to the same blast radius GH-133 exists to remove, for a dependency that is not in the request path of a single running request.
Deciding factors for Model A:
-
ffmpeg already degrades gracefully at runtime.
backend/app/voice_transcode.py::_run_ffmpegcatchesFileNotFoundError/OSError/SubprocessErrorand returnsNone;voice_playback_pipeline.pyandmedia_worker.pyexplicitly document that "original download success never depends on ffmpeg" and "no ffmpeg outcome can change download success." ffmpeg is not required for the service to start, pass/health/ready, or archive messages — only for one optional playback conversion. There is no reason to grant root package-manager access to guarantee an already-optional capability. -
The old auto-install path was environment-fragile. It shelled out
to
apt-get, which does not exist ondnf-based distributions — the call could only ever fail there. Model A is a net improvement with no behavior regression in the failure case. -
Fail-fast beats silent skip. A missing dependency should not be
discovered only when a user first uploads a voice message. A
deploy-time
command -v ffmpegcheck surfaces it immediately, in CI/CD logs, with the fix already known.
wecom-*.timer / wecom-*.path (the enable --now grant in step 10)
stays a wildcard rather than being narrowed to an explicit per-unit list.
Why this is still minimum practical privilege, not just minimum character count:
-
deploy/systemd/MANAGED_UNITSalready lists ~15 timer/path units and is explicitly designed to grow (GH-104): each new scheduled workload PR adds a new.timeror.paththere. An exact, per-unit sudoers line would mean every futureMANAGED_UNITSaddition also blocks on a separate, manual root-only sudoers edit before it can self-heal via CD — reintroducing precisely the "declared in the repo but never actually installed" drift step 10 exists to close. - The wildcard's blast radius is bounded on two independent axes:
-
Name.
wecom-*.timer/wecom-*.pathcannot matchnginx.service,sshd.service, anyqiniu-*unit (which needed, and got, its own exact grant under #129 for exactly this reason), or any other unit outside the literalwecom-prefix and.timer/.pathsuffix. -
Provenance. The only sudo-granted way to place a new file
under
/etc/systemd/system/is the accompanyingcpgrant, which is itself restricted to copying from$DEPLOY_DIR/deploy/systemd/*.timer/*.path— i.e. files that are already tracked in this repository'sdeploy/systemd/directory and reachedmainthrough the normal PR/review/CI process.enable --now wecom-*.timercannot be used to activate an arbitrary attacker-supplied unit unless that unit both (a) is already present under/etc/systemd/system/by some other means entirely outside this sudoers grant, and (b) happens to match thewecom-*naming convention.
-
Name.
- #129's own precedent (an exact, non-glob grant for
qiniu-ssl-renew-wildcard.timer) is the right call there specifically because it was a single, one-off timer being onboarded — the cost of an exact line was low. Generalizing that same reasoning towecom-*, which already covers over a dozen units and gains more on essentially every scheduled-workload PR, would generalize the wrong lesson: it would trade a bounded, well-understood wildcard for a recurring, unbounded operational cost.
If a future unit ever needs to live outside the wecom-* namespace (as
qiniu-ssl-renew-wildcard.timer did), follow #129's precedent: add an
exact, single-unit grant for it rather than broadening this wildcard.
This is the step-by-step procedure for converging a host's sudoers state to the model above, and for verifying it. Dev does not execute any of this — sudoers edits, production deploys, and host access are Ops actions under their own authorization.
# Confirm what this deploy is currently running
cd /srv/apps/wecom-archive-365/current && git rev-parse HEAD
# Current effective sudo grants for the deploy user
sudo -l -U <deploy-user>
# Back up the current sudoers drop-ins before touching anything
for f in /etc/sudoers.d/<deploy-grant-files>; do
sudo cp "$f" "$f.bak.$(date +%Y%m%d%H%M%S)"
done
# ffmpeg presence (host prerequisite — never installed by the deploy user)
command -v ffmpeg && ffmpeg -version | head -1
# Relevant service/timer state
systemctl is-active wecom-archive-365.service
systemctl list-timers 'wecom-*' --no-pager
systemctl list-timers 'qiniu-ssl-renew-wildcard.timer' --no-pagerIn the deploy-user grant file, keep the capability-matrix rows marked KEEP and remove any bare package-manager lines:
| Line | Action |
|---|---|
/usr/bin/systemctl restart wecom-archive-365.service |
KEEP — unchanged |
/usr/bin/mkdir -p /var/www/* |
KEEP — unchanged |
/usr/bin/cp <two-glob shape> (webroot publish) |
KEEP — unchanged |
/usr/bin/dnf |
REMOVE if present |
/usr/bin/apt-get |
REMOVE if present |
In the systemd-management grant file:
| Line | Action |
|---|---|
cp grants for *.service/*.timer/*.path under deploy/systemd/
|
KEEP — unchanged |
/usr/bin/systemctl daemon-reload |
KEEP — unchanged |
/usr/bin/systemctl enable --now wecom-*.timer |
KEEP — unchanged (see "systemd wildcard decision" above) |
/usr/bin/systemctl enable --now wecom-*.path |
KEEP — unchanged |
/usr/bin/systemctl enable --now qiniu-ssl-renew-wildcard.timer (#129) |
DO NOT TOUCH |
This is a pure removal of dead/dangerous lines — nothing is ADDed or
CHANGEd. If a removed line is the entire content of a sudoers block,
delete the block rather than leaving an empty NOPASSWD: clause.
sudo visudo -cf /etc/sudoers.d/<each-edited-file>
sudo visudo -cDo not proceed past a non-zero exit from any of the above.
sudo -n -u <deploy-user> sudo -n /usr/bin/systemctl restart wecom-archive-365.service
sudo -n -u <deploy-user> sudo -n /usr/bin/systemctl status wecom-archive-365.service
sudo -n -u <deploy-user> sudo -n /usr/bin/systemctl daemon-reload
sudo -n -u <deploy-user> sudo -n /usr/bin/systemctl enable --now wecom-archive-worker.timer
sudo -n -u <deploy-user> sudo -n /usr/bin/mkdir -p /var/www/deploy-sudoers-probe
sudo -n -u <deploy-user> sudo -n /usr/bin/systemctl reload nginxEach must exit 0 with no password prompt. systemctl status is the
positive sudo verification because it is the authorized service-inspection
capability. deploy_server.sh deliberately runs systemctl is-active
without sudo: ordinary users can query active state, so it must not be used
to imply a broader sudo grant. Clean up any probe artifacts (e.g.
/var/www/deploy-sudoers-probe) afterward.
sudo -n -u <deploy-user> sudo -n /usr/bin/dnf --version; echo "exit=$?" # expect nonzero, "a password is required" or "not allowed"
sudo -n -u <deploy-user> sudo -n /usr/bin/apt-get --version; echo "exit=$?" # expect nonzero, same reason
# Wildcard boundary: none of these unit names may be enabled via the
# wecom-*/qiniu-ssl-renew-wildcard grants.
sudo -n -u <deploy-user> sudo -n /usr/bin/systemctl enable --now nginx.service; echo "exit=$?"
sudo -n -u <deploy-user> sudo -n /usr/bin/systemctl enable --now sshd.service; echo "exit=$?"
sudo -n -u <deploy-user> sudo -n /usr/bin/systemctl enable --now arbitrary.service; echo "exit=$?"All of these must be refused (nonzero exit, sudo's "not allowed" message) before this migration is considered verified.
Trigger a normal, low-risk deploy (a docs-only or otherwise inert commit
to main is safest) through the existing GitHub Actions workflow rather
than invoking deploy_server.sh by hand, and confirm:
- The workflow's SSH step and
deploy_server.shboth complete with exit0. - The log shows
[2/10] Checking ffmpeg host prerequisite …with no install attempt (noapt-get/dnfin the log at all). -
/health/readyand the public health check both pass as usual. - Step 10's systemd sync still reports success (or its usual non-fatal WARNs, unchanged from before this migration) for the units it manages.
If anything above fails or the host shows unexpected behavior after the sudoers change:
sudo cp /etc/sudoers.d/<file>.bak.<timestamp> /etc/sudoers.d/<file>
sudo visudo -cThis restores the previous grants exactly as they were. Rolling back the
sudoers file does not require reverting any application code —
deploy_server.sh's ffmpeg check has no dependency on those grants
existing, so the application deploy path keeps working (and keeps failing
fast on missing ffmpeg) with either sudoers state in place. No code
rollback is needed purely to reverse this migration.
- #129's exact
qiniu-ssl-renew-wildcard.timergrant — untouched. - TLS renewal cadence or
ssl-renew/renew-wildcard.shlogic — untouched. - nginx topology or its own reload grant — untouched.
- Payment/WeCom/application business logic — untouched.
- No sudoers file, service, or deploy is modified by documentation alone; every verification above is Ops's to run under its own authorization.