feat: users apply grants the box tier, not just the socket #53
Labels
No labels
attention
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-ruling
needs-triage
offsite
post-merge
ready
release
scope:bootstrap
scope:coolify
scope:db
scope:docs
scope:drill
scope:installer
scope:labels
scope:platform
scope:runner
scope:users
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/rig#53
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/users-apply-grants-tier"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The gap: the group is the socket, the tier is
box grantRole
boxin the users file resolved to exactly one action(
commands/users-apply.sh:213):usermod -aG incus <user>— step 1 of the 5box grantperforms. Theother four are the tier: the
user-<uid>project, its narrowing toboxnetand only
boxnet, the snapshot and backup allowancesbox cloneandbox exportride, and the shippedbox-netprofile installed into thatproject. Without them the user's first
box newfails closed atbox/bin/box:749("your project has no box-net profile"), so apply's promise— the users file is the fleet's source of truth, apply converges to it — was
not kept for this role.
And the half-grant was worse than absent: until an admin ran
box grantbyhand, the user held an
incusmembership with no converged project, soincus-user would lazily hand them a stock unhardened
incusbr-<uid>NATbridge — a door box's own contract forbids.
The fix
On
host=yes, apply callsbox grant <user>for each user carrying rolebox. rig calls box's grant rather than reimplementing four fifths of it:the "rig never installs Incus" boundary is about installation, not
invocation, and grant is already script-callable — idempotent, root-or-sudo
(which apply already is), stdin-pinned so no incus client can wedge on a
prompt, and it does its own run-as-the-user touch, so the granted user never
has to log in first.
Ordering. The call sits after
useradd(grant opens with agetent passwdatgrant-user.sh:62and refuses an unknown account) andafter the other group work, so a user whose grant fails still lands with
everything rig owns outright. Pinned by a line-number ordering assert, the
same shape as bootstrap's marker-then-box check.
Failure granularity, split the way the
host=guard beside it alreadysplits:
host=yes, box-role users, noboxCLI on PATHincus-group die at:180; a broken VM host, not a per-user accidentbox grant <user>exits non-zerohost=no/ marker with nohost=traitThe decision the issue left open: does rig still manage the
incusmembership?Deliberately half:
incusstays in the wanted set, but the add isdeferred to
box granton hosts where we call it.want, or the exact-convergence loop's other arm wouldstrip a box-role user's socket on the very run that granted it. (That arm
is #50's; this PR does not touch it.)
a membership that run added — a user it finds already in
incuskeepsit when a later step trips, by design. If rig opens the socket first, grant
can no longer close it, and a grant that fails midway leaves the user with
live access to an un-narrowed project. Deferring makes the socket and the
tier land together or fail together.
incus-adminmember it deliberately does not addincus. rig adding itanyway would not oscillate, but it would be rig overruling box on box's own
tier — the exact boundary this change exists to respect.
Both halves are asserted, since either alone is a bug, plus an ordering check
that the deferral sits on the add arm and never the removal arm.
Blocked-on note:
incus-adminmembers (heavy-duty/box#99)box grantrefusesincus-adminmembers outright today(
grant-user.sh:69). That refusal is what heavy-duty/box#99 fixes, and ithas not merged. This PR does not block on it and does not special-case
box's refusal into a skip either — it calls grant unconditionally and, when
the call fails and the user is in
incus-admin, warns with the specificstory: they keep the full socket (
incus-adminis strictly stronger than thetier), but they get no
user-<uid>project of their own and land in theshared default project alongside every other admin; blocked on
heavy-duty/box#99; a later apply picks the tier up once box stops refusing.
When #99 lands, grant returns 0 and that branch simply stops being reached —
no rig change needed.
Verification
shellcheck -xover the full CI sweep (bin/*+**/*.sh, 22 files) —clean.
bash test/cli.sh— 336 passed, 0 failed (325 onmain; 11 new).bash test/release.sh— 41 passed, 0 failed.New coverage in
test/cli.sh: the call exists and isbox grant, not a bareusermod; it runs afteruseradd; the missing-CLI die and the per-user warnboth present; the grant is gated on
host=yes(asserted against the guard'sown block, so a
host=yesmatch borrowed from the die cannot pass it forfree); the
incus-adminbranch exists and cites the box-side blocker; thedeferral exists,
incusstill enters the wanted set, and the deferral sitsabove the removal arm; and rig still never apt-installs incus.
Executed end-to-end, since the greps alone would not settle behaviour: a
throwaway mount namespace with a tmpfs
/etc, realuseradd, and a stubboxon PATH that succeeds for one user and exits 1 for another.alice(admin,box) granted, group transition reported,CHANGEDset;
bob(box) grant fails → warning,bobkeeps account + keys and isnot left holding a bare
incusmembership;carol(rig) untouched;apply exit 0.
"already granted?" probe), and apply still reports "already converged; no
changes" — the grant does not manufacture a phantom change every run.
host=no: zeroboxinvocations, prior behaviour intact.host=yeswith noboxon PATH: dies with the new message.Closes #49
🤖 Generated with Claude Code
Verdict: Approve
Reviewed at head 25b63e3. Series context checked: #53, #54, and #55 are each based on
main(not stacked); this PR closes #49, with the revoke-side mirror explicitly deferred to #50/#55 and bootstrap-time convergence to #51/#54. The diff matches that scoping — nothing here touches the removal arm or the ledger revocation path.What I verified
Suites re-run locally on the PR head (worktree of
refs/pull/53/head):bash test/cli.sh— 336 passed, 0 failed (matches the PR body's claim; 11 new checks present)bash test/release.sh— 41 passed, 0 failedshellcheck -xoverbin/*+commands/*.sh+commands/lib/*.sh+test/*.sh— cleanIndependent end-to-end execution, not just the greps: a root mount namespace with tmpfs over
/etc,/home,/var/log, realuseradd, and a stubboxon PATH that grantsaliceand exits 1 forbob:alice(admin,box) granted, group transition logged, apply reportsconverged;bob's failed grant warns and continues, andbobis not left holding a bareincusmembership — the deferral atcommands/users-apply.sh:278really does make socket and tier fail together;carol(rig) untouched; exit 0.already converged; no changes— no phantomCHANGED(thehad_socketprobe atcommands/users-apply.sh:308-324works as described).boxinvocations.boxon PATH: dies with the new message atcommands/users-apply.sh:221. The message's claim that "rig bootstrap does it on host=yes" checks out againstcommands/bootstrap.sh:437-446.Review-focus answers
incusadd tobox grant(commands/users-apply.sh:278) is the sharpest part of the change — rig never opens the socket ahead of the narrowing, so a mid-grant failure cannot strand a user with live access to an un-narrowed project. This closes the "worse than absent" window #49 named rather than widening it.gpasswd -datcommands/users-apply.sh:286) and the ledger revocation (:396-398) still bypassbox revoke; that is #50, addressed by #55. The test attest/cli.sh:816-820correctly pins that the deferral sits on the add arm only, so a de-roled user's socket is still stripped.useraddinside the per-user loop, pinned by the fail-closed ordering assert (test/cli.sh:762-768, defaults999999/0so a failed grep fails the test). Usernames reachingbox grant "$u"are already constrained to^[a-z_][a-z0-9_-]{0,31}$bycommands/lib/users-config.sh:71-74, so no injection surface.commands/users-apply.sh:130-133) is untouched, and rig now defers to box's own authority on theincusgroup (the incus-admin non-add), rather than overruling it.--helptext (commands/users-apply.sh:49-55) was updated in step.test/cli.sh:786-788is a nice touch — it cannot be satisfied by the die's ownhost=yes), both failure registers, both halves of the defer/wanted-set pair, and the no-apt-install invariant.Non-blocking observations
incusgroup is absent. On ahost=no(or marker-less) box where the group exists — a marker/reality mismatch, e.g.box setup-hostran but bootstrap said--host no— apply still bare-adds box-role users toincuswith no tier (commands/users-apply.sh:251gated only onINCUS_OK), which is exactly the half-grant state this PR calls worse than absent. I reproduced it in the namespace run (flipping the marker to host=no handedbobthe bare socket his failed grant had correctly withheld). This is unchanged frommainand out of #49's scope, but it deserves an issue: either a warn on that arm or a decision that the marker wins over the group's presence.commands/users-apply.sh:325): the branch keys on membership, not on grant's actual refusal, so an incus-admin user whose grant fails for an unrelated per-user reason (e.g. incus-user socket down) gets the "blocked on heavy-duty/box#99, everything else converged" story instead of the real cause. Low stakes — box's own stderr still prints above — but matching on grant's refusal message or a distinct exit code (once box#99 settles the interface) would make the warning honest in that corner.commands/users-apply.sh:314-320shows this trade-off was chosen knowingly; fine as is.None of these block: 1 is pre-existing, 2 and 3 are cosmetic-to-minor and both sit on paths that box#99 will reshape anyway.
— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: Approve — I agree with this as-is.
Why
boxnow invokesbox grantonhost=yesafter account creation — the tier, not justusermod -aG incus.incus-adminbranch is an honest warn until box#99/101 lands; no rig reimplementation of grant. Grep pins + CI green.No blockers. Land with box#101 for the admin-member story end-to-end.
Verdict: I agree with everything and have no additional feedback. On
host=yes, the implementation delegates the full tier tobox grantafter account creation, defers theincusadd so grant can roll it back on failure, and retainsincusin the desired set so subsequent convergence does not remove it. Host-level prerequisites fail loudly, per-user failures remain isolated, and the current CI checks are green.@danmt — handoff summary.
What this fixes
rig users applygave role-boxusers the Incus socket and never the tier. One line —usermod -aG incus— wherebox grantdoes five things (project, bridge unpin,restricted.networks.access boxnet, snapshot/backup allowances,box-netprofile). Their firstbox newfailed atbox/bin/box:749telling them to find an admin. Worse, the group without the project left a window where incus-user would lazily create an unhardened project (incusbr-<uid>: NAT on v4 and v6, no ACL, nodns.mode=none, no port isolation) — a state box's own design doc measures as worse than not granting at all.Now apply calls
box grantper box-role user onhost=yes, afteruseradd. Closes #49.Review history
One round, no fixes needed. 3/3 approved at head
25b63e3— claude-bot, codex-bot, grok-bot. claude-bot re-ran the suites independently and executed the change end-to-end in its own mount namespace rather than trusting the diff.Verification
test/cli.sh336 passed / 0 failed (baseline 325 — 11 new checks)test/release.sh41 passed / 0 failedshellcheck -xover CI's exact sweep, 22 files: cleancheck,scope,db-integration,reconcileall greenuseraddin a tmpfs/etc, with a stubboxrigged to grant one user and exit 1 for another — confirming warn-and-continue, idempotent re-run, zeroboxcalls onhost=no, and the new die when box is absent onhost=yesThe design decision worth your eye
The sharpest call was deferring the
incusgroup add tobox grantrather than doing it in rig.incusstays in the wanted set (so the exact-convergence else-arm doesn't strip a box-role user's socket), but the add is deferred via acontinueon the add arm only.Reasoning:
box grant's fail-closed backout only reverses a membership that run added — a pre-existing one is deliberately kept. So if rig opened the socket first, a mid-way grant failure could not close it, stranding the user with live access to an un-narrowed project. That is exactly the hazard this PR exists to remove. claude-bot verified this by execution: the failed-grant user is not left holding a bareincusmembership.Flagged for your judgment
A pre-existing hole this PR does not close, found by claude-bot. On a
host=noor marker-less box where theincusgroup nonetheless exists — a marker/reality mismatch, e.g.box setup-hostran but bootstrap said--host no— apply still bare-adds box-role users toincuswith no tier. That is the half-grant state this PR calls worse than absent, reproduced in claude-bot's namespace run. Unchanged frommainand outside #49's scope, but it wants a decision: warn on that arm, or rule that the marker wins over the group's presence. Happy to file it.Two minor notes, both on paths box#99 reshapes anyway: the incus-admin warning keys on group membership rather than on grant's actual refusal, so an unrelated per-user failure gets the wrong story; and post-#99 a first-time grant for an incus-admin member can succeed with no group transition, so the run that materially creates their project still reports "already converged". Both were chosen knowingly and are documented in-code.
Merge order matters
#55 touches this same file (
users-apply.sh) — this PR owns the add arm, #55 owns the removal arms, deliberately non-overlapping. Whichever lands second needs a rebase; both also touch CHANGELOG's## Unreleased.The incus-admin story only completes when heavy-duty/box#101 lands (currently in round 2 — reviewers found a real blocker in it). This PR handles both worlds: if
box grantstill refuses admin members, it warns and continues rather than failing apply, and needs no change once box#101 merges.🤖 Generated with Claude Code
Rebased onto
mainafter #60 merged — and the changelog needed hand-placing@danmt — sweep after #60. This branch is rebased onto
main;mergeable: true.The changelog needed more than conflict resolution, and the reason is worth knowing before you merge the rest of this batch.
maincut 0.1.0 at 18:03:45Z. #60 merged at 19:02:12Z — an hour later. Its entry had been written under## Unreleased, but by merge time that heading had been stamped to## 0.1.0, so git merged the entry cleanly, with no conflict, into the notes of an already-published release. Same thing happened to this branch on rebase.Every PR in this batch predates the release, so every one of them has this. It does not announce itself — #54's rebase produced zero conflicts and still put a BREAKING entry inside 0.1.0's notes.
Resolved here by rebuilding the section structure: a fresh
## Unreleasedholding this PR's entry and #60's (moved back out of 0.1.0, where it does not belong — it shipped after), with## 0.1.0left holding only what it actually released.Scope of the damage, checked rather than assumed: the published GitHub release body for 0.1.0 is clean — it was generated at release time, before #60 merged, so it never contained the stray entry. Only the
CHANGELOG.mdfile onmainwas wrong, and merging any PR from this batch corrects it.Filed as heavy-duty/rig#66 — the release flow has no guard against this, and it will recur on every release.
Verification at the rebased head
shellcheck -xover the CI sweep — cleantest/release.sh— 61 / 0Content is otherwise unchanged from what the bots approved; the delta is the rebase plus changelog placement.
Rebased again after #55 merged
@danmt — second sweep, at
a950569.#55merging conflicted this branch in four files; all resolved, all suites green.Three of the four were complementary content, not competing — #55 and this PR describe opposite directions of the same role, so both sides were kept:
commands/users-apply.sh(--help) — theboxrole blurb now carries both: apply callsbox granton the way in, hands the group back throughbox revokeon the way out. It ends "defers BOTH directions to box", which is the actual composed behavior.README.md— both paragraphs kept, grant first (the acquiring direction), then #55's revoke paragraph.test/cli.sh— both test blocks, purely additive.CHANGELOG.md— this PR's entry under### Added, #55's and #58's under### Fixed, all inside## Unreleased. See #66 for why that placement needs care on this branch.One assertion needed a real update, not a merge. The previous rebase (onto #60) composed the wanted-set arm into:
…which is exactly what both PRs' authors predicted would happen. But this PR pinned the old literal line, so the test failed on rebase — correctly. Updated to the composed form, with a comment recording that the two operands answer different questions:
BOX_ROLE_OKis "does the box role apply on this box at all" (the marker's call, #58),INCUS_OKis "is the group there to converge". Both must hold, andincusmust still enter the wanted set when they do — otherwise the exact-convergence else-arm strips a box-role user's socket on the very run that granted it, which is the hazard this PR exists to remove.That failing test was the rebase working. A silent pass there would have meant the pin had stopped pinning anything.
Verification at
a950569test/cli.sh— 364 passed, 0 failedtest/release.sh— 61 / 0shellcheck -xover the CI sweep — clean