Merge pull request #101 from dan-claude-bot/fix/grant-incus-admin
fix: box grant provisions incus-admin members instead of refusing them
This commit is contained in:
commit
9649659aff
7 changed files with 583 additions and 23 deletions
76
CHANGELOG.md
76
CHANGELOG.md
|
|
@ -32,6 +32,82 @@ which records not just what changed but what each drill run proved.
|
||||||
verdict, the single `on.push` key, and the same-job tag+publish+re-arm
|
verdict, the single `on.push` key, and the same-job tag+publish+re-arm
|
||||||
in the same daemon-free, fail-closed style.
|
in the same daemon-free, fail-closed style.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`box grant` provisions an `incus-admin` member instead of refusing them**
|
||||||
|
(#99) — the refusal read "they already have the admin tier; there is
|
||||||
|
nothing tighter to grant", which is true about *permission* and silent
|
||||||
|
about *provisioning*: the `incus` group is indeed a strict subset of what
|
||||||
|
`incus-admin` opens **at the daemon API**, but the `user-<uid>` project, the
|
||||||
|
boxnet narrowing, the snapshot and backup allowances, and the `box-net`
|
||||||
|
profile installed into that project are none of them permissions, and an
|
||||||
|
`incus-admin` member had none of them — `box_tier()` resolves them to
|
||||||
|
`admin`, so they worked in the shared default project next to root and every
|
||||||
|
other admin, with no world of their own and no supported way to get one.
|
||||||
|
`box grant` now runs the full convergence for them.
|
||||||
|
|
||||||
|
The group step is part of that convergence, not an exception to it: an
|
||||||
|
`incus-admin` member is added to `incus` like anyone else. The subset
|
||||||
|
argument holds for the API and **fails at the filesystem**, which is where
|
||||||
|
it matters here — the two sockets are two files with two owning groups
|
||||||
|
(Debian 13 / Incus 6.0.4, measured):
|
||||||
|
|
||||||
|
| socket | group | mode |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/var/lib/incus/unix.socket` | `incus-admin` | 0660 |
|
||||||
|
| `/var/lib/incus/unix.socket.user` | `incus` | 0660 |
|
||||||
|
|
||||||
|
`incus-admin` opens the first and not the second, and only the second
|
||||||
|
provisions a `user-<uid>` project. Without the membership the provisioning
|
||||||
|
touch takes `EACCES`, the swallowing `|| true` hides it, no project appears,
|
||||||
|
and the grant dies blaming a perfectly healthy incus-user — the exact
|
||||||
|
incus-admin-only user #99 is about, left no better off. So the membership is
|
||||||
|
granted, and the grant says out loud why: it is the key to a file, not a new
|
||||||
|
privilege (`box_tier()` still reads them as `admin`, both-groups → `admin`).
|
||||||
|
|
||||||
|
The touch itself is **pinned at incus-user's socket**: the incus client picks
|
||||||
|
by writability (`client/connection.go` — the daemon socket when writable,
|
||||||
|
`unix.socket.user` only otherwise), so for an `incus-admin` member an
|
||||||
|
unpinned touch sails past incus-user and provisions nothing. The user-side
|
||||||
|
proof that closes the grant names their project for the same reason, since an
|
||||||
|
unqualified `profile show` would have answered from the shared default
|
||||||
|
project and proved nothing. The socket's existence is probed through `$SUDO`,
|
||||||
|
not a bare `[ -e ]` — `/var/lib/incus` is not traversable by a non-root
|
||||||
|
admin, so an unprivileged stat reports a present socket as absent, and this
|
||||||
|
probe exits on absent (the discipline `box revoke` already documents).
|
||||||
|
|
||||||
|
On success the grant prints the caveat the hard exit was gesturing at, in the
|
||||||
|
two forms it actually takes: the restrictions are a **default placement, not
|
||||||
|
a confinement** (admin membership still wins at the socket — the default
|
||||||
|
project and other users' instances stay one flag away), and until
|
||||||
|
`incus-admin` goes their own `box` commands keep landing in the default
|
||||||
|
project. Dropping `incus-admin` then lands them in their ready project with
|
||||||
|
**no re-grant** — a promise that is only true because they keep `incus`;
|
||||||
|
without it that drop would leave them in neither group, `box_tier()` `none`,
|
||||||
|
and a converged project they could not open. The failure path follows: the
|
||||||
|
membership this run added is rolled back and verified, while the backout
|
||||||
|
refuses to call that a lockout — `incus-admin` is untouched and still opens
|
||||||
|
every project.
|
||||||
|
|
||||||
|
`box revoke` mirrors it. A bare revoke of a granted `incus-admin` member now
|
||||||
|
takes the `incus` membership back and reports **`partial:`** — the socket key
|
||||||
|
`box grant` added is gone, their project is kept, and they are explicitly
|
||||||
|
**not** locked out. An `incus-admin` member who was never granted is still a
|
||||||
|
named **no-op** that makes no privileged call at all. `--purge` unmakes the
|
||||||
|
provisioning while refusing to call them "out". Every path names
|
||||||
|
`gpasswd -d <user> incus-admin` as the only thing that ends their access.
|
||||||
|
|
||||||
|
Unblocks rig's `users apply` (heavy-duty/rig#49), which had to call `box
|
||||||
|
grant` for a user who is both `incus-admin` by hand and role `box` in the
|
||||||
|
fleet file. Driven end to end in `test/cli.sh` under logging incus/sudo shims
|
||||||
|
— every assertion is made against what the run did, not what the source says
|
||||||
|
it would — and, because those shims model neither `INCUS_SOCKET` nor file
|
||||||
|
permissions and so cannot reproduce the `EACCES`, measured on real Incus in
|
||||||
|
CI by a new `drill/multiuser.sh` criterion (o): an `incus-admin`-only member
|
||||||
|
is granted, the membership lands, the project appears, `unix.socket.user`
|
||||||
|
opens as them, and dropping `incus-admin` leaves them in their own project
|
||||||
|
with no re-grant.
|
||||||
|
|
||||||
## 0.7.0 — 2026-07-19
|
## 0.7.0 — 2026-07-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
20
bin/box
20
bin/box
|
|
@ -542,6 +542,17 @@ What it converges, idempotently (safe to re-run, and re-run after upgrades):
|
||||||
rides snapshots, 'box export' rides backups — #70)
|
rides snapshots, 'box export' rides backups — #70)
|
||||||
· installs the box-net profile into their project
|
· installs the box-net profile into their project
|
||||||
|
|
||||||
|
An incus-admin member is provisioned too, not refused (#99): they are added
|
||||||
|
to 'incus' like anyone else — not a new privilege, since incus-admin already
|
||||||
|
opens the daemon, but the key to a FILE, because incus-user's socket is group
|
||||||
|
'incus' mode 0660 and nothing below can provision them without it. Everything
|
||||||
|
else converges, so they finally have a project of their own. What it is not
|
||||||
|
is a confinement:
|
||||||
|
incus-admin wins at the socket, so the restrictions are a default placement
|
||||||
|
they can step outside at will, and their own client keeps resolving to the
|
||||||
|
admin socket (and the default project) until incus-admin is taken away. The
|
||||||
|
grant says all of that out loud when it lands.
|
||||||
|
|
||||||
The user's surface: new/list/info/shell/exec/tmux/snapshot/restore/export/
|
The user's surface: new/list/info/shell/exec/tmux/snapshot/restore/export/
|
||||||
import/rm on their own boxes. Not theirs: expose (edits daemon-global
|
import/rm on their own boxes. Not theirs: expose (edits daemon-global
|
||||||
state), setup-host, grant. Admin boxes and other users' boxes are invisible to them, and the
|
state), setup-host, grant. Admin boxes and other users' boxes are invisible to them, and the
|
||||||
|
|
@ -561,6 +572,15 @@ recreate the project, unhardened, afterwards — measured, not theoretical),
|
||||||
then their boxes, images, project, private bridge and trust-store
|
then their boxes, images, project, private bridge and trust-store
|
||||||
certificate are removed — irreversible, so it asks first.
|
certificate are removed — irreversible, so it asks first.
|
||||||
|
|
||||||
|
On an incus-admin member a bare revoke takes back the 'incus' membership that
|
||||||
|
grant added — reported as 'partial:', because it ends no access: incus-admin
|
||||||
|
still opens every project on this host. (One who was never granted is a named
|
||||||
|
no-op instead.) Mind what that leaves behind: with 'incus' gone, a later
|
||||||
|
'gpasswd -d <user> incus-admin' drops them into NEITHER group and their ready
|
||||||
|
project becomes unreachable — grant's "no re-grant needed" holds only while
|
||||||
|
they still hold 'incus'. --purge unmakes the provisioning the same way. Only
|
||||||
|
'gpasswd -d <user> incus-admin' ends their access, and revoke says so.
|
||||||
|
|
||||||
box revoke dev1 # take the tier; their boxes keep running
|
box revoke dev1 # take the tier; their boxes keep running
|
||||||
box revoke dev1 --purge # ...or end their sessions and delete everything
|
box revoke dev1 --purge # ...or end their sessions and delete everything
|
||||||
EOF
|
EOF
|
||||||
|
|
|
||||||
|
|
@ -127,11 +127,13 @@ It had never worked.
|
||||||
## Rehearsal and CI
|
## Rehearsal and CI
|
||||||
|
|
||||||
`drill/multiuser.sh` (root, opt-in via `BOX_MULTIUSER_REHEARSAL=1`) proves
|
`drill/multiuser.sh` (root, opt-in via `BOX_MULTIUSER_REHEARSAL=1`) proves
|
||||||
criteria (a)–(f) from #74 plus the measured extensions (g)–(n): the in-box
|
criteria (a)–(f) from #74 plus the measured extensions (g)–(o): the in-box
|
||||||
isolation contract (egress, DNS, box→host, RFC1918, cross-user sibling drop,
|
isolation contract (egress, DNS, box→host, RFC1918, cross-user sibling drop,
|
||||||
name enumeration, IPv6-off), the closed escape hatches, re-sync survival, and
|
name enumeration, IPv6-off), the closed escape hatches, re-sync survival, and
|
||||||
scoped revoke, the raw-attach scoped guarantee (m) and the grant-failure
|
scoped revoke, the raw-attach scoped guarantee (m), the grant-failure
|
||||||
injections (n). Real users, real grants, real mints, probes from inside;
|
injections (n) and the `incus-admin`-only grant (o) — the last of these
|
||||||
|
because the shim suite models neither `INCUS_SOCKET` nor socket permissions,
|
||||||
|
so the group gate on `unix.socket.user` can only be measured live (#99, #101). Real users, real grants, real mints, probes from inside;
|
||||||
`--container` for CI, VM mode on real hardware; cleanup deletes everything it
|
`--container` for CI, VM mode on real hardware; cleanup deletes everything it
|
||||||
made.
|
made.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@
|
||||||
# host-owned control — the scoped guarantee, measured (#75 review)
|
# host-owned control — the scoped guarantee, measured (#75 review)
|
||||||
# n. a grant that fails is fail-closed: fresh user backed out (verified),
|
# n. a grant that fails is fail-closed: fresh user backed out (verified),
|
||||||
# pre-existing member warned loudly, re-run converges (#75 review)
|
# pre-existing member warned loudly, re-run converges (#75 review)
|
||||||
|
# o. an incus-admin-ONLY member is provisioned for real: the group step
|
||||||
|
# opens incus-user's socket, the lazy project appears, and dropping
|
||||||
|
# incus-admin lands them in it with no re-grant (#99, #101 review)
|
||||||
#
|
#
|
||||||
# ok/no/note return 0 by design — the 'A && ok || no' idiom below is the
|
# ok/no/note return 0 by design — the 'A && ok || no' idiom below is the
|
||||||
# same one drill.sh is built on (and the reason for the SC2015 disable).
|
# same one drill.sh is built on (and the reason for the SC2015 disable).
|
||||||
|
|
@ -114,7 +117,7 @@ cleanup() {
|
||||||
[ "$KEEP" = 1 ] && { echo "(--keep: users and boxes left for inspection)"; return; }
|
[ "$KEEP" = 1 ] && { echo "(--keep: users and boxes left for inspection)"; return; }
|
||||||
echo
|
echo
|
||||||
echo "── cleanup"
|
echo "── cleanup"
|
||||||
for u in "$U1" "$U2" boxdrill3 boxdrill4; do
|
for u in "$U1" "$U2" boxdrill3 boxdrill4 boxdrill5; do
|
||||||
id "$u" >/dev/null 2>&1 || continue
|
id "$u" >/dev/null 2>&1 || continue
|
||||||
# A half-failed purge followed by userdel leaves a project owned by
|
# A half-failed purge followed by userdel leaves a project owned by
|
||||||
# nobody — and doctor's leftover check keys on the USER existing. Keep
|
# nobody — and doctor's leftover check keys on the USER existing. Keep
|
||||||
|
|
@ -469,6 +472,65 @@ else
|
||||||
fi
|
fi
|
||||||
aud "n. fail-closed injections: fresh-user backout verified; pre-existing member warned, not stripped; re-runs converge"
|
aud "n. fail-closed injections: fresh-user backout verified; pre-existing member warned, not stripped; re-runs converge"
|
||||||
|
|
||||||
|
phase "o. an incus-admin-ONLY member — #99's canonical user, on real Incus"
|
||||||
|
# The case the shim suite structurally cannot reach: the fake 'incus' in
|
||||||
|
# test/cli.sh ignores INCUS_SOCKET and file permissions, so a grant that could
|
||||||
|
# never connect() still logged a clean run there. This is the same path over
|
||||||
|
# the real daemon, where the socket is a real file with a real owning group.
|
||||||
|
#
|
||||||
|
# The blocker it exists to catch (#101 review): incus-user's socket is
|
||||||
|
# /var/lib/incus/unix.socket.user, group 'incus', mode 0660. incus-admin opens
|
||||||
|
# the ADMIN socket and not that one, so an incus-admin-only member without an
|
||||||
|
# 'incus' membership takes EACCES on grant's pinned touch — swallowed by its
|
||||||
|
# '|| true' — no project is created, and the grant dies blaming a perfectly
|
||||||
|
# healthy incus-user. Every assertion below is dead under that implementation.
|
||||||
|
U5=boxdrill5
|
||||||
|
useradd -m -s /bin/bash "$U5" 2>/dev/null
|
||||||
|
usermod -aG incus-admin "$U5"
|
||||||
|
gpasswd -d "$U5" incus >/dev/null 2>&1 || true # stage the ONLY, exactly
|
||||||
|
uid5="$(id -u "$U5")"; p5="user-$uid5"
|
||||||
|
id -nG "$U5" | tr ' ' '\n' | grep -qx incus \
|
||||||
|
&& no "(o) $U5 is already in 'incus' — the admin-ONLY precondition is not staged, so this phase proves nothing" \
|
||||||
|
|| ok "(o) $U5 staged in 'incus-admin' only (the precondition the blocker needed)"
|
||||||
|
|
||||||
|
out="$(box grant "$U5" 2>&1)"; rc=$?
|
||||||
|
if [ "$rc" -eq 0 ]; then
|
||||||
|
ok "(o) box grant converges an incus-admin-only member (rc=0)"
|
||||||
|
else
|
||||||
|
no "(o) box grant FAILED for the admin-only member (rc=$rc) — #99 is still closed:"
|
||||||
|
printf '%s\n' "$out" | tail -4 | sed 's/^/ /'
|
||||||
|
fi
|
||||||
|
id -nG "$U5" | tr ' ' '\n' | grep -qx incus \
|
||||||
|
&& ok "(o) the grant put them in 'incus' — the group that owns unix.socket.user" \
|
||||||
|
|| no "(o) still not in 'incus': the pinned touch cannot connect() to incus-user's socket"
|
||||||
|
incus project show "$p5" >/dev/null 2>&1 \
|
||||||
|
&& ok "(o) $p5 exists — the lazy touch really reached incus-user AS them" \
|
||||||
|
|| no "(o) $p5 was never created — the touch never reached incus-user (the EACCES this phase is for)"
|
||||||
|
|
||||||
|
# The socket, directly: the connect() that used to fail, measured as them.
|
||||||
|
# Resolved by incus's own directory rule, not hardcoded.
|
||||||
|
sockdir=/var/lib/incus; [ -e /run/incus/unix.socket ] && sockdir=/run/incus
|
||||||
|
as_u "$U5" env INCUS_SOCKET="$sockdir/unix.socket.user" incus --project "$p5" profile show box-net >/dev/null 2>&1 \
|
||||||
|
&& ok "(o) they can open unix.socket.user and read $p5's box-net profile" \
|
||||||
|
|| no "(o) EACCES/unreachable on $sockdir/unix.socket.user — the #101 blocker is back"
|
||||||
|
acc5="$(incus project get "$p5" restricted.networks.access 2>/dev/null)"
|
||||||
|
[ "$acc5" = boxnet ] \
|
||||||
|
&& ok "(o) $p5 is narrowed to boxnet like any other granted project" \
|
||||||
|
|| no "(o) $p5 restricted.networks.access = '$acc5' — the admin-only grant converged half a project"
|
||||||
|
|
||||||
|
# Grant's own closing promise, measured: "gpasswd -d <user> incus-admin (no
|
||||||
|
# re-grant needed; the project is ready)". True only because they were left in
|
||||||
|
# 'incus' — under the old no-op this drop left them in NEITHER group, box_tier
|
||||||
|
# 'none', and a ready project they could not open. So drop it and look.
|
||||||
|
gpasswd -d "$U5" incus-admin >/dev/null 2>&1
|
||||||
|
projects5="$(as_u "$U5" incus project list --format csv 2>/dev/null | cut -d, -f1)"
|
||||||
|
if [ "$(printf '%s\n' "$projects5" | grep -c .)" = 1 ] && printf '%s' "$projects5" | grep -q "$p5"; then
|
||||||
|
ok "(o) dropping incus-admin lands them in $p5 with NO re-grant — the promise holds"
|
||||||
|
else
|
||||||
|
no "(o) after dropping incus-admin they see: '$(printf '%s' "$projects5" | tr '\n' ' ')' — grant's no-re-grant promise is false"
|
||||||
|
fi
|
||||||
|
aud "o. incus-admin-only grant: in-'incus'=$(id -nG "$U5" 2>/dev/null | tr ' ' '\n' | grep -cx incus), project '$p5' access='$acc5', post-drop projects='$(printf '%s' "$projects5" | tr '\n' ' ')'"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "════════════════════════════════════════════"
|
echo "════════════════════════════════════════════"
|
||||||
echo " $pass passed, $fail failed"
|
echo " $pass passed, $fail failed"
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,10 @@
|
||||||
# Incus 6.0.4; the full write-up is in docs/plans/2026-07-18-restricted-tier.md.
|
# Incus 6.0.4; the full write-up is in docs/plans/2026-07-18-restricted-tier.md.
|
||||||
#
|
#
|
||||||
# So granting is a per-user CONVERGENCE, and it must be run by an admin:
|
# So granting is a per-user CONVERGENCE, and it must be run by an admin:
|
||||||
# 1. put the user in the 'incus' group (not incus-admin — that is the tier)
|
# 1. put the user in the 'incus' group (not incus-admin — that is the tier).
|
||||||
|
# An incus-admin member goes in too, and NOT for privilege: incus-user's
|
||||||
|
# socket is a FILE, group 'incus', mode 0660, so the membership is the
|
||||||
|
# only thing that lets step 2 connect() at all (#99, #101 review)
|
||||||
# 2. touch incus-user AS the user, so the lazy project exists to converge
|
# 2. touch incus-user AS the user, so the lazy project exists to converge
|
||||||
# 3. unpin the private bridge (drop eth0 from the project's default profile)
|
# 3. unpin the private bridge (drop eth0 from the project's default profile)
|
||||||
# 4. restrict the project's network access to boxnet and ONLY boxnet —
|
# 4. restrict the project's network access to boxnet and ONLY boxnet —
|
||||||
|
|
@ -63,12 +66,34 @@ getent passwd "$user" >/dev/null || { echo "box grant: no such user: $user" >&2;
|
||||||
uid="$(id -u "$user")"
|
uid="$(id -u "$user")"
|
||||||
[ "$uid" -eq 0 ] && { echo "box grant: root does not need a tier — UID 0 owns the daemon socket outright." >&2; exit 1; }
|
[ "$uid" -eq 0 ] && { echo "box grant: root does not need a tier — UID 0 owns the daemon socket outright." >&2; exit 1; }
|
||||||
|
|
||||||
# An incus-admin member already holds the full socket; "granting" them the
|
# An incus-admin member gets the full convergence anyway (#99). This used to
|
||||||
# restricted tier would not restrict anything (admin membership wins at the
|
# be a hard refusal, on the reasoning that admin membership wins at the socket
|
||||||
# socket), it would only mislead whoever reads the group list later.
|
# so nothing here could restrict them. True — and beside the point, because it
|
||||||
|
# conflates the two separate things a grant hands over:
|
||||||
|
# · PERMISSION — the 'incus' group. At the DAEMON API they already hold
|
||||||
|
# strictly more through incus-admin, so this group adds no privilege. It
|
||||||
|
# is still required, because the two sockets are two FILES with two
|
||||||
|
# different owning groups (Debian 13 / Incus 6.0.4, measured):
|
||||||
|
# /var/lib/incus/unix.socket group incus-admin 0660
|
||||||
|
# /var/lib/incus/unix.socket.user group incus 0660
|
||||||
|
# incus-admin opens the first and not the second, and the second is the
|
||||||
|
# only one that provisions a user-<uid> project. An earlier revision of
|
||||||
|
# this script skipped the usermod for an admin member, reasoning that
|
||||||
|
# 'incus' is a subset of incus-admin — true of the API, false of the
|
||||||
|
# filesystem: the pinned touch below took EACCES, the '|| true' swallowed
|
||||||
|
# it, no project appeared, and the grant died blaming a healthy
|
||||||
|
# incus-user. So the group step is a real convergence for everyone.
|
||||||
|
# · PROVISIONING — the user-<uid> project, the boxnet narrowing, the
|
||||||
|
# snapshot and backup allowances, the box-net profile installed INTO that
|
||||||
|
# project. An incus-admin member has none of it: box_tier() resolves them
|
||||||
|
# to 'admin' (bin/box), so they work in the SHARED default project next to
|
||||||
|
# root and every other admin, with no world of their own. This script is
|
||||||
|
# the only thing that provisions one, and refusing left them unable to get
|
||||||
|
# it without first being taken out of incus-admin.
|
||||||
|
# So provision, and say plainly at the end what the provisioning does not do.
|
||||||
|
admin_member=0
|
||||||
if id -nG "$user" | tr ' ' '\n' | grep -qx incus-admin; then
|
if id -nG "$user" | tr ' ' '\n' | grep -qx incus-admin; then
|
||||||
echo "box grant: $user is in incus-admin — they already have the admin tier; there is nothing tighter to grant." >&2
|
admin_member=1
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The stack the tier converges ONTO must exist first. Checked via the daemon,
|
# The stack the tier converges ONTO must exist first. Checked via the daemon,
|
||||||
|
|
@ -93,7 +118,11 @@ fi
|
||||||
# admin re-runs. Backing out the group closes that window completely for a
|
# admin re-runs. Backing out the group closes that window completely for a
|
||||||
# fresh grant (their existing sessions predate the membership, so no process
|
# fresh grant (their existing sessions predate the membership, so no process
|
||||||
# holds it yet). A user who was already in the group keeps it: not ours to
|
# holds it yet). A user who was already in the group keeps it: not ours to
|
||||||
# take on a re-run's failure.
|
# take on a re-run's failure. An incus-admin member now takes those same two
|
||||||
|
# paths (#101): the membership IS added for them and so IS backed out, with
|
||||||
|
# one thing extra to say either way — the rollback closes incus-user's socket
|
||||||
|
# and never their daemon access, which outlives this failure by a route the
|
||||||
|
# script never granted and must not pretend to control.
|
||||||
added_group=0; was_member=0
|
added_group=0; was_member=0
|
||||||
backout() {
|
backout() {
|
||||||
if [ "$added_group" -eq 1 ]; then
|
if [ "$added_group" -eq 1 ]; then
|
||||||
|
|
@ -106,6 +135,16 @@ backout() {
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "box grant: FAILED — removed $user from 'incus' again (verified against the group database); fix the cause and re-run" >&2
|
echo "box grant: FAILED — removed $user from 'incus' again (verified against the group database); fix the cause and re-run" >&2
|
||||||
|
if [ "$admin_member" -eq 1 ]; then
|
||||||
|
# The rollback is real and verified, but for an admin member it is not a
|
||||||
|
# lockout and must not read as one: what came back was incus-user's
|
||||||
|
# socket key, not the daemon. Say what survives, and what would end it.
|
||||||
|
echo "box grant: NOTE — that rollback closed incus-user's socket, NOT $user's access." >&2
|
||||||
|
echo " They keep full admin socket access throughout via 'incus-admin', which this run" >&2
|
||||||
|
echo " neither granted nor removed, so every project on this host stays open to them." >&2
|
||||||
|
echo " Their project may be part-converged; a re-run converges the rest. To close their" >&2
|
||||||
|
echo " access you must take the admin group itself: gpasswd -d $user incus-admin" >&2
|
||||||
|
fi
|
||||||
# The one window the database cannot close: a login STARTED between our
|
# The one window the database cannot close: a login STARTED between our
|
||||||
# usermod and this backout keeps the group in its session credentials.
|
# usermod and this backout keeps the group in its session credentials.
|
||||||
# For a fresh grant that is a rare race, but rare is not never — name it
|
# For a fresh grant that is a rare race, but rare is not never — name it
|
||||||
|
|
@ -122,6 +161,14 @@ backout() {
|
||||||
echo "box grant: FAILED with $user still holding socket access (their membership predates this run)." >&2
|
echo "box grant: FAILED with $user still holding socket access (their membership predates this run)." >&2
|
||||||
echo " their project may be part-converged — harmless in itself, and a re-run converges the rest." >&2
|
echo " their project may be part-converged — harmless in itself, and a re-run converges the rest." >&2
|
||||||
echo " if their access is not acceptable while you fix the cause: box revoke $user" >&2
|
echo " if their access is not acceptable while you fix the cause: box revoke $user" >&2
|
||||||
|
if [ "$admin_member" -eq 1 ]; then
|
||||||
|
# Same correction as above, for the member who was in BOTH groups before
|
||||||
|
# this run: 'box revoke' takes the 'incus' key back, and still leaves
|
||||||
|
# them the whole daemon.
|
||||||
|
echo "box grant: NOTE — $user is also in 'incus-admin', which this run neither granted nor removed:" >&2
|
||||||
|
echo " 'box revoke' takes back incus-user's socket key and nothing more. To close their" >&2
|
||||||
|
echo " access: gpasswd -d $user incus-admin" >&2
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
trap backout EXIT
|
trap backout EXIT
|
||||||
|
|
@ -132,17 +179,63 @@ if id -nG "$user" | tr ' ' '\n' | grep -qx incus; then
|
||||||
else
|
else
|
||||||
$SUDO usermod -aG incus "$user"
|
$SUDO usermod -aG incus "$user"
|
||||||
added_group=1
|
added_group=1
|
||||||
|
if [ "$admin_member" -eq 1 ]; then
|
||||||
|
# Say why, because the group list alone would imply a restriction that is
|
||||||
|
# not in force — the concern the old no-op was built around. It was a
|
||||||
|
# cosmetic concern and this is where it gets carried: in output, not in a
|
||||||
|
# skipped mutation that broke the mechanism.
|
||||||
|
echo "group: added $user to 'incus' — NOT a new privilege ('incus-admin' already opens the daemon,"
|
||||||
|
echo " and box_tier still reads them as 'admin'), but the key to a FILE: incus-user's socket is"
|
||||||
|
echo " group 'incus' mode 0660, and nothing below can provision $user without it"
|
||||||
|
else
|
||||||
echo "group: added $user to 'incus' (their next login picks it up; the grant does not wait)"
|
echo "group: added $user to 'incus' (their next login picks it up; the grant does not wait)"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
project="user-$uid"
|
project="user-$uid"
|
||||||
|
|
||||||
|
# The incus CLI picks its socket by WRITABILITY, not by intent: with no
|
||||||
|
# INCUS_SOCKET set it takes $INCUS_DIR/unix.socket when that is writable and
|
||||||
|
# only falls back to unix.socket.user when it is not (client/connection.go,
|
||||||
|
# stable-6.0 — the same branch that then defaults the project to user-<uid>).
|
||||||
|
# For a plain 'incus' member the fallback fires and every command below lands
|
||||||
|
# where we want it. For an incus-admin member the daemon socket IS writable,
|
||||||
|
# so an unpinned client sails straight past incus-user — the touch would not
|
||||||
|
# provision anything and the grant would die claiming incus-user was
|
||||||
|
# unhealthy. Pin the socket for them, by incus's own directory rule.
|
||||||
|
# INCUS_DIR first, then /run/incus if the daemon socket lives there, else
|
||||||
|
# /var/lib/incus: incus's resolution order, not an approximation of it — the
|
||||||
|
# pinned path has to name the same directory the client would have chosen.
|
||||||
|
user_socket=""
|
||||||
|
if [ "$admin_member" -eq 1 ]; then
|
||||||
|
incus_dir="${INCUS_DIR:-}"
|
||||||
|
if [ -z "$incus_dir" ]; then
|
||||||
|
incus_dir="/var/lib/incus"; [ -e /run/incus/unix.socket ] && incus_dir="/run/incus"
|
||||||
|
fi
|
||||||
|
user_socket="$incus_dir/unix.socket.user"
|
||||||
|
# $SUDO test, not a bare [ -e ]: revoke-user.sh documents from measurement
|
||||||
|
# that /var/lib/incus is not traversable by a non-root admin, so an
|
||||||
|
# unprivileged stat answers "absent" for a socket that is very much there —
|
||||||
|
# and this check EXITS on absent. Same discipline, same reason (#101 review).
|
||||||
|
$SUDO test -e "$user_socket" \
|
||||||
|
|| { echo "box grant: incus-user is active but $user_socket is not there — nothing can provision $project (journalctl -u incus-user)" >&2; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run the incus CLI as the granted user, on the socket that will actually
|
||||||
|
# serve them: pinned to incus-user for an admin member, left to the CLI's own
|
||||||
|
# resolution for everyone else (whose fallback already gets it right).
|
||||||
|
run_as_incus() {
|
||||||
|
if [ -n "$user_socket" ]; then run_as "$user" env INCUS_SOCKET="$user_socket" "$@"
|
||||||
|
else run_as "$user" "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# 2. The project is created LAZILY, on the user's first contact with
|
# 2. The project is created LAZILY, on the user's first contact with
|
||||||
# incus-user — an admin cannot pre-create it (incus-user would fight over
|
# incus-user — an admin cannot pre-create it (incus-user would fight over
|
||||||
# it), so make that first contact happen now, as the user.
|
# it), so make that first contact happen now, as the user.
|
||||||
if ! incus project show "$project" >/dev/null 2>&1 </dev/null; then
|
if ! incus project show "$project" >/dev/null 2>&1 </dev/null; then
|
||||||
echo "project: touching incus-user as $user to create $project..."
|
echo "project: touching incus-user as $user to create $project..."
|
||||||
run_as "$user" timeout 60 incus project list >/dev/null 2>&1 || true
|
run_as_incus timeout 60 incus project list >/dev/null 2>&1 || true
|
||||||
incus project show "$project" >/dev/null 2>&1 </dev/null \
|
incus project show "$project" >/dev/null 2>&1 </dev/null \
|
||||||
|| { echo "box grant: incus-user did not create $project — is incus-user.socket healthy? (journalctl -u incus-user)" >&2; exit 1; }
|
|| { echo "box grant: incus-user did not create $project — is incus-user.socket healthy? (journalctl -u incus-user)" >&2; exit 1; }
|
||||||
echo "project: $project created"
|
echo "project: $project created"
|
||||||
|
|
@ -205,10 +298,53 @@ echo "profile: box-net installed in $project"
|
||||||
|
|
||||||
# Prove the grant from the USER's side of the socket — the only side that
|
# Prove the grant from the USER's side of the socket — the only side that
|
||||||
# matters. This catches the failure the steps above cannot see one at a time:
|
# matters. This catches the failure the steps above cannot see one at a time:
|
||||||
# a converged project the user still cannot reach.
|
# a converged project the user still cannot reach. For an admin member the
|
||||||
run_as "$user" timeout 30 incus profile show box-net >/dev/null 2>&1 \
|
# project is named explicitly: an unqualified profile show over the pinned
|
||||||
|
# incus-user socket asks about 'default', and over their own admin socket it
|
||||||
|
# would answer from the shared default project — a green that proves the
|
||||||
|
# convergence nothing at all.
|
||||||
|
if [ -n "$user_socket" ]; then
|
||||||
|
run_as_incus timeout 30 incus --project "$project" profile show box-net >/dev/null 2>&1 \
|
||||||
|
|| { echo "box grant: converged, but $user cannot reach $project's box-net profile through incus-user — check journalctl -u incus-user" >&2; exit 1; }
|
||||||
|
else
|
||||||
|
run_as "$user" timeout 30 incus profile show box-net >/dev/null 2>&1 \
|
||||||
|| { echo "box grant: converged, but $user cannot see the box-net profile through incus-user — check journalctl -u incus-user" >&2; exit 1; }
|
|| { echo "box grant: converged, but $user cannot see the box-net profile through incus-user — check journalctl -u incus-user" >&2; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
trap - EXIT # converged and verified: the grant stands
|
trap - EXIT # converged and verified: the grant stands
|
||||||
echo "granted: $user has the restricted tier — their 'box new' lands on the hardened boxnet."
|
if [ "$admin_member" -eq 1 ]; then
|
||||||
echo " (their boxes are theirs alone; 'box revoke $user' takes the tier back)"
|
# The honest version of what the old refusal was gesturing at. The project
|
||||||
|
# is real, converged and theirs — that is what they were missing and what
|
||||||
|
# this run supplied. What it is NOT is confinement, in two distinct ways
|
||||||
|
# that both come from incus-admin winning at the socket, and both belong in
|
||||||
|
# the output rather than in a hard exit:
|
||||||
|
# · nothing here binds them. Every restriction converged above describes
|
||||||
|
# project $project; the default project and every other user's instances
|
||||||
|
# stay one flag away, and no setting inside $project can say otherwise
|
||||||
|
# while they hold that group.
|
||||||
|
# · nothing here even routes them, yet. Their unpinned CLI resolves to the
|
||||||
|
# writable daemon socket and so to the DEFAULT project (the socket rule
|
||||||
|
# cited at step 2), so their 'box new' still lands beside the other
|
||||||
|
# admins' until they either drop incus-admin — at which point this
|
||||||
|
# project becomes their automatic home, no re-run needed — or pin
|
||||||
|
# INCUS_SOCKET at incus-user by hand.
|
||||||
|
# That "no re-run needed" is a real promise only because the group step
|
||||||
|
# above put them in 'incus' (#101): dropping incus-admin leaves them a
|
||||||
|
# plain 'incus' member, which is exactly the tier whose client falls back
|
||||||
|
# to unix.socket.user and lands in $project. Under the old no-op they would
|
||||||
|
# have been left in NEITHER group — box_tier 'none', no socket at all, and
|
||||||
|
# a converged project they could not open.
|
||||||
|
echo "granted: $user has their own converged project $project (boxnet-only, snapshots, backups, box-net)."
|
||||||
|
echo " CAVEAT — $user is in 'incus-admin', which wins at the socket: this is a"
|
||||||
|
echo " DEFAULT PLACEMENT, not a confinement. They can reach the default project and"
|
||||||
|
echo " every other user's instances whenever they choose to."
|
||||||
|
echo " And until incus-admin goes, their own 'box' commands keep landing in the DEFAULT"
|
||||||
|
echo " project — the admin socket is the one their client picks. To make $project theirs"
|
||||||
|
echo " for real: gpasswd -d $user incus-admin (no re-grant needed: they keep 'incus', so"
|
||||||
|
echo " their client falls straight back to incus-user and $project is already ready)."
|
||||||
|
echo " 'box revoke $user' unwinds this provisioning and takes the 'incus' membership back;"
|
||||||
|
echo " it cannot touch their admin access."
|
||||||
|
else
|
||||||
|
echo "granted: $user has the restricted tier — their 'box new' lands on the hardened boxnet."
|
||||||
|
echo " (their boxes are theirs alone; 'box revoke $user' takes the tier back)"
|
||||||
|
fi
|
||||||
|
|
|
||||||
|
|
@ -56,14 +56,42 @@ if [ "$purge" -eq 1 ]; then
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# An incus-admin member is the mirror of grant's #99 case. 'box grant' DOES
|
||||||
|
# put them in 'incus' — not for privilege, but because incus-user's socket is
|
||||||
|
# a file owned by that group (#101) — so revoke has a real membership to take
|
||||||
|
# back here. What it does not have is a lockout: 'incus-admin' opens the
|
||||||
|
# daemon and is not this script's to remove, so "revoked" would still be the
|
||||||
|
# script claiming something it did not perform. Recorded here, spoken below.
|
||||||
|
admin_member=0
|
||||||
|
if id -nG "$user" | tr ' ' '\n' | grep -qx incus-admin; then
|
||||||
|
admin_member=1
|
||||||
|
fi
|
||||||
|
|
||||||
# The group, first — access ends even if a purge step below trips.
|
# The group, first — access ends even if a purge step below trips.
|
||||||
|
dropped_group=0
|
||||||
if id -nG "$user" | tr ' ' '\n' | grep -qx incus; then
|
if id -nG "$user" | tr ' ' '\n' | grep -qx incus; then
|
||||||
$SUDO gpasswd -d "$user" incus >/dev/null
|
$SUDO gpasswd -d "$user" incus >/dev/null
|
||||||
|
dropped_group=1
|
||||||
|
if [ "$admin_member" -eq 1 ]; then
|
||||||
|
echo "group: removed $user from 'incus' — that membership was incus-user's socket key, which"
|
||||||
|
echo " 'box grant' added. It is NOT their daemon access: 'incus-admin' is untouched here"
|
||||||
|
else
|
||||||
echo "group: removed $user from 'incus'"
|
echo "group: removed $user from 'incus'"
|
||||||
|
fi
|
||||||
|
elif [ "$admin_member" -eq 1 ]; then
|
||||||
|
echo "group: $user is not in 'incus' — nothing here to take; their socket access is 'incus-admin', which this does not touch"
|
||||||
else
|
else
|
||||||
echo "group: $user was not in 'incus'"
|
echo "group: $user was not in 'incus'"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Say it once, plainly, before either path's summary: for an admin member the
|
||||||
|
# group step above closed nothing, so nothing this script does — including
|
||||||
|
# --purge — ends their access to the daemon. Only the admin group itself does.
|
||||||
|
if [ "$admin_member" -eq 1 ]; then
|
||||||
|
echo "NOTE: $user is in 'incus-admin' and keeps FULL access to every project on this host"
|
||||||
|
echo " regardless of what follows. To actually lock them out: gpasswd -d $user incus-admin"
|
||||||
|
fi
|
||||||
|
|
||||||
# Supplementary groups are fixed AT LOGIN: the database change above does
|
# Supplementary groups are fixed AT LOGIN: the database change above does
|
||||||
# nothing to a session the user already holds — a leftover tmux keeps the
|
# nothing to a session the user already holds — a leftover tmux keeps the
|
||||||
# socket until it dies. For a bare revoke that is an honest warning. For
|
# socket until it dies. For a bare revoke that is an honest warning. For
|
||||||
|
|
@ -95,8 +123,28 @@ fi
|
||||||
if [ "$purge" -eq 0 ]; then
|
if [ "$purge" -eq 0 ]; then
|
||||||
if incus project show "$project" >/dev/null 2>&1 </dev/null; then
|
if incus project show "$project" >/dev/null 2>&1 </dev/null; then
|
||||||
echo "kept: project $project and its boxes (still running — revoking a person does not kill their workloads)"
|
echo "kept: project $project and its boxes (still running — revoking a person does not kill their workloads)"
|
||||||
|
if [ "$admin_member" -eq 1 ]; then
|
||||||
|
# "restores access" would be the wrong promise here: no access was lost.
|
||||||
|
echo " 'box revoke $user --purge' deletes them; 'box grant $user' re-converges the project and the membership"
|
||||||
|
else
|
||||||
echo " 'box revoke $user --purge' deletes them; 'box grant $user' restores access"
|
echo " 'box revoke $user --purge' deletes them; 'box grant $user' restores access"
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
if [ "$admin_member" -eq 1 ]; then
|
||||||
|
# Not "revoked", still: what came back is the 'incus' membership grant
|
||||||
|
# added for incus-user's socket, and their access to this host was never
|
||||||
|
# riding on it. The project is kept. So say exactly what was taken and
|
||||||
|
# name the two real options, instead of a summary that reads as a lockout.
|
||||||
|
if [ "$dropped_group" -eq 1 ]; then
|
||||||
|
echo "partial: took $user out of 'incus' — incus-user's socket key, which 'box grant' added."
|
||||||
|
echo " $user is NOT locked out: 'incus-admin' still opens every project on this host."
|
||||||
|
else
|
||||||
|
echo "no-op: $user was not in 'incus' and holds the admin tier via 'incus-admin' — nothing was taken."
|
||||||
|
fi
|
||||||
|
echo " to remove their access: gpasswd -d $user incus-admin"
|
||||||
|
echo " to remove the project 'box grant' provisioned for them: box revoke $user --purge"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
echo "revoked: $user no longer has the restricted tier."
|
echo "revoked: $user no longer has the restricted tier."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
@ -160,4 +208,13 @@ if [ -n "$leftover" ]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "revoked: $user is out, and everything the tier created is gone."
|
if [ "$admin_member" -eq 1 ]; then
|
||||||
|
# "is out" would be false: the purge unmade the provisioning, and their
|
||||||
|
# incus-admin membership — untouched by design, it is not this script's to
|
||||||
|
# take — still opens every project on the host, including a fresh one they
|
||||||
|
# can build by hand a second from now.
|
||||||
|
echo "purged: everything 'box grant' created for $user is gone — but $user is NOT out."
|
||||||
|
echo " 'incus-admin' still gives them the whole daemon: gpasswd -d $user incus-admin"
|
||||||
|
else
|
||||||
|
echo "revoked: $user is out, and everything the tier created is gone."
|
||||||
|
fi
|
||||||
|
|
|
||||||
211
test/cli.sh
211
test/cli.sh
|
|
@ -479,6 +479,22 @@ check "box revoke with no user exits 2 (via the CLI table)" 2 "usage: box revoke
|
||||||
check "help grant names the hardened network" 0 "boxnet" "$BOX" help grant
|
check "help grant names the hardened network" 0 "boxnet" "$BOX" help grant
|
||||||
check "help revoke names --purge" 0 "purge" "$BOX" help revoke
|
check "help revoke names --purge" 0 "purge" "$BOX" help revoke
|
||||||
|
|
||||||
|
# The help is the PRE-RUN CONTRACT: an operator reads it to decide whether to
|
||||||
|
# run the command at all, so it must not promise a mutation that will not
|
||||||
|
# happen (or deny one that will). Round 1 of #101 changed what grant/revoke
|
||||||
|
# mutate for an incus-admin member and left this prose describing the
|
||||||
|
# superseded design — these pins are why that cannot happen silently again.
|
||||||
|
# Both directions: the current sentence must be present, and the superseded
|
||||||
|
# one must be gone.
|
||||||
|
check "help grant: the admin member's group step is a real add, not a no-op" \
|
||||||
|
0 "like anyone else" "$BOX" help grant
|
||||||
|
check "help revoke: a bare revoke of a granted admin member is 'partial:'" \
|
||||||
|
0 "partial:" "$BOX" help revoke
|
||||||
|
check "help grant no longer calls the admin group step a no-op" 0 "" \
|
||||||
|
bash -c '! "'"$BOX"'" help grant | grep -q "reported no-op"'
|
||||||
|
check "help revoke no longer claims there is no membership to drop" 0 "" \
|
||||||
|
bash -c '! "'"$BOX"'" help revoke | grep -q "no membership to drop"'
|
||||||
|
|
||||||
# Load-bearing lines a daemon-free run cannot exercise — grepped so a deleted
|
# Load-bearing lines a daemon-free run cannot exercise — grepped so a deleted
|
||||||
# guard cannot ship green (the house test discipline).
|
# guard cannot ship green (the house test discipline).
|
||||||
# The expose guard must fire before ANY incus call in cmd_expose: line order.
|
# The expose guard must fire before ANY incus call in cmd_expose: line order.
|
||||||
|
|
@ -512,8 +528,8 @@ check "grant: installs the SHIPPED profile into the project" 0 "" \
|
||||||
grep -qF 'profile edit box-net < "$here/profiles/box-net.yaml"' "$ROOT/host/grant-user.sh"
|
grep -qF 'profile edit box-net < "$here/profiles/box-net.yaml"' "$ROOT/host/grant-user.sh"
|
||||||
check "grant: unpins the private-bridge eth0 from the default profile" 0 "" \
|
check "grant: unpins the private-bridge eth0 from the default profile" 0 "" \
|
||||||
grep -qF 'profile device remove default eth0' "$ROOT/host/grant-user.sh"
|
grep -qF 'profile device remove default eth0' "$ROOT/host/grant-user.sh"
|
||||||
check "grant: refuses an incus-admin member (nothing tighter to grant)" 0 "" \
|
check "grant: an incus-admin member is provisioned, not refused (#99)" 1 "" \
|
||||||
grep -qF 'incus-admin' "$ROOT/host/grant-user.sh"
|
grep -qF 'there is nothing tighter to grant' "$ROOT/host/grant-user.sh"
|
||||||
check "revoke: group removal is the lockout" 0 "" \
|
check "revoke: group removal is the lockout" 0 "" \
|
||||||
grep -qF 'gpasswd -d' "$ROOT/host/revoke-user.sh"
|
grep -qF 'gpasswd -d' "$ROOT/host/revoke-user.sh"
|
||||||
# Group membership is read at login: purge must terminate live sessions (a
|
# Group membership is read at login: purge must terminate live sessions (a
|
||||||
|
|
@ -549,11 +565,202 @@ check "rehearsal: measures the raw boxnet attach (criterion m)" 0 "" \
|
||||||
# shellcheck disable=SC2016 # the $-string is a literal in the target file
|
# shellcheck disable=SC2016 # the $-string is a literal in the target file
|
||||||
check "rehearsal: injects grant failures (criterion n)" 0 "" \
|
check "rehearsal: injects grant failures (criterion n)" 0 "" \
|
||||||
grep -qF 'grant-user.sh" "$U3"' "$ROOT/drill/multiuser.sh"
|
grep -qF 'grant-user.sh" "$U3"' "$ROOT/drill/multiuser.sh"
|
||||||
|
# Criterion o is the real-Incus half of #101: the shim cannot model an EACCES
|
||||||
|
# on the user socket, so the admin-only grant is measured where the socket has
|
||||||
|
# a real owning group. Pinned so it cannot quietly leave the rehearsal.
|
||||||
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
||||||
|
check "rehearsal: grants an incus-admin-ONLY member on real Incus (criterion o)" 0 "" \
|
||||||
|
grep -qF 'usermod -aG incus-admin "$U5"' "$ROOT/drill/multiuser.sh"
|
||||||
|
# shellcheck disable=SC2016 # ditto
|
||||||
|
check "rehearsal: ...and opens the user socket as them, not just the daemon" 0 "" \
|
||||||
|
grep -qF 'INCUS_SOCKET="$sockdir/unix.socket.user"' "$ROOT/drill/multiuser.sh"
|
||||||
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
||||||
check "revoke: purge deletes instances one at a time" 0 "" \
|
check "revoke: purge deletes instances one at a time" 0 "" \
|
||||||
grep -qF 'delete -f "$inst"' "$ROOT/host/revoke-user.sh"
|
grep -qF 'delete -f "$inst"' "$ROOT/host/revoke-user.sh"
|
||||||
check "revoke: purge removes the trust-store certificate" 0 "" \
|
check "revoke: purge removes the trust-store certificate" 0 "" \
|
||||||
grep -qF 'config trust remove' "$ROOT/host/revoke-user.sh"
|
grep -qF 'config trust remove' "$ROOT/host/revoke-user.sh"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# #99: an incus-admin member is PROVISIONED, not refused. The distinction the
|
||||||
|
# old refusal missed is permission (the 'incus' group — theirs already, and
|
||||||
|
# stronger) versus provisioning (the user-<uid> project, the boxnet narrowing,
|
||||||
|
# snapshots, backups, the box-net profile — theirs not at all). Grepping the
|
||||||
|
# new prose would prove only that the prose exists, so both tier scripts are
|
||||||
|
# DRIVEN end to end under shims, the same seam setup-host is driven through:
|
||||||
|
# every incus and sudo call is logged, and the assertions are made against
|
||||||
|
# those logs — what the run did, not what the source says it would do.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
GSHIM="$(mktemp -d)"; W99="$(mktemp -d)"
|
||||||
|
cat > "$GSHIM/incus" <<'SHIM'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Fake incus for the driven grant/revoke: logs every call, answers the
|
||||||
|
# existence probes from FAKE_*, and models the two state changes the scripts
|
||||||
|
# depend on — the project appearing after the incus-user touch, and
|
||||||
|
# disappearing after a purge deletes it.
|
||||||
|
[ -n "${FAKE_INCUS_LOG:-}" ] && printf 'incus %s\n' "$*" >> "$FAKE_INCUS_LOG"
|
||||||
|
case "$*" in *"profile edit"*) cat >/dev/null ;; esac
|
||||||
|
case "$*" in
|
||||||
|
"network show boxnet") [ -n "${FAKE_HAVE_BOXNET:-}" ] || exit 1 ;;
|
||||||
|
"project show "*)
|
||||||
|
[ -e "$FAKE_STATE/deleted" ] && exit 1
|
||||||
|
if [ -n "${FAKE_PROJECT_LAZY:-}" ]; then
|
||||||
|
# Lazy creation: absent on the first look, present afterwards — i.e.
|
||||||
|
# the touch worked. n counts the looks this run has taken.
|
||||||
|
n=0; [ -e "$FAKE_STATE/looks" ] && n="$(cat "$FAKE_STATE/looks")"
|
||||||
|
printf '%s\n' "$((n + 1))" > "$FAKE_STATE/looks"
|
||||||
|
[ "$n" -ge 1 ] || exit 1
|
||||||
|
else
|
||||||
|
[ -n "${FAKE_HAVE_PROJECT:-}" ] || exit 1
|
||||||
|
fi ;;
|
||||||
|
"project delete "*) : > "$FAKE_STATE/deleted" ;;
|
||||||
|
*"restricted.networks.access"*)
|
||||||
|
[ -z "${FAKE_FAIL_NARROW:-}" ] || { echo 'Instance "old" is on incusbr-1000' >&2; exit 1; } ;;
|
||||||
|
*"network show "*) exit 1 ;; # the private bridge: never there in these runs
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
SHIM
|
||||||
|
cat > "$GSHIM/sudo" <<'SHIM'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Fake sudo: logs and swallows — EXCEPT 'sudo test', which is run for real.
|
||||||
|
# Both scripts route filesystem probes through it on purpose (/var/lib/incus
|
||||||
|
# is not traversable by a non-root admin, so an unprivileged stat lies), and
|
||||||
|
# both directions matter here: revoke's absence assert must see incus-user's
|
||||||
|
# state directory as genuinely absent on a clean machine, and grant's socket
|
||||||
|
# check must see the shimmed unix.socket.user as genuinely present.
|
||||||
|
[ -n "${FAKE_SUDO_LOG:-}" ] && printf 'sudo %s\n' "$*" >> "$FAKE_SUDO_LOG"
|
||||||
|
case "${1:-}" in test) shift; test "$@"; exit $? ;; esac
|
||||||
|
exit 0
|
||||||
|
SHIM
|
||||||
|
printf '#!/usr/bin/env bash\nexit 0\n' > "$GSHIM/getent"
|
||||||
|
printf '#!/usr/bin/env bash\nexit 0\n' > "$GSHIM/systemctl"
|
||||||
|
printf '#!/usr/bin/env bash\nexit 1\n' > "$GSHIM/pgrep"
|
||||||
|
chmod +x "$GSHIM/incus" "$GSHIM/sudo" "$GSHIM/getent" "$GSHIM/systemctl" "$GSHIM/pgrep"
|
||||||
|
|
||||||
|
# The pinned incus-user socket. box grant resolves it through INCUS_DIR (the
|
||||||
|
# client's own first choice), so a directory here is the whole seam.
|
||||||
|
mkdir -p "$W99/incusdir"; : > "$W99/incusdir/unix.socket.user"
|
||||||
|
|
||||||
|
rungrant() { # rungrant <groups> <state-dir> [VAR=val ...] — the real grant, shimmed
|
||||||
|
local groups="$1" state="$2"; shift 2
|
||||||
|
mkdir -p "$state"
|
||||||
|
env FAKE_UID=1000 FAKE_GROUPS="$groups" FAKE_STATE="$state" \
|
||||||
|
FAKE_HAVE_BOXNET=1 FAKE_PROJECT_LAZY=1 INCUS_DIR="$W99/incusdir" \
|
||||||
|
FAKE_INCUS_LOG="$state/incus.log" FAKE_SUDO_LOG="$state/sudo.log" \
|
||||||
|
PATH="$GSHIM:$SHIMDIR:$PATH" "$@" bash "$ROOT/host/grant-user.sh" dev1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- the admin member: full convergence, no group change, honest caveat -----
|
||||||
|
A="$W99/admin"
|
||||||
|
check "grant: an incus-admin member CONVERGES (exit 0, no refusal)" 0 "granted:" \
|
||||||
|
rungrant "users incus-admin" "$A"
|
||||||
|
check "grant: ...and the group step is a real convergence, named as one" 0 "added dev1 to 'incus'" \
|
||||||
|
rungrant "users incus-admin" "$W99/a2"
|
||||||
|
check "grant: ...saying WHY (the socket is a file, group 'incus', not a privilege)" 0 "mode 0660" \
|
||||||
|
rungrant "users incus-admin" "$W99/a2b"
|
||||||
|
check "grant: ...the caveat calls it a default placement, not a confinement" 0 "DEFAULT PLACEMENT" \
|
||||||
|
rungrant "users incus-admin" "$W99/a3"
|
||||||
|
check "grant: ...and names the group that has to go for it to bind" 0 "gpasswd -d dev1 incus-admin" \
|
||||||
|
rungrant "users incus-admin" "$W99/a4"
|
||||||
|
# The logs: what the run actually did to the machine.
|
||||||
|
# #101's decision, pinned at the seam that broke: an incus-admin member IS
|
||||||
|
# usermod'ed into 'incus'. It buys them no API privilege they lack — but
|
||||||
|
# incus-user's socket is a FILE, group 'incus' mode 0660, and without the
|
||||||
|
# membership the pinned touch below takes EACCES, the '|| true' eats it, and
|
||||||
|
# the grant dies blaming a healthy incus-user. The shim cannot model that
|
||||||
|
# EACCES (it ignores INCUS_SOCKET and permissions entirely), so the decision
|
||||||
|
# is pinned here and MEASURED on real Incus in drill/multiuser.sh criterion o.
|
||||||
|
check "grant: the admin member IS added to 'incus' — the user socket's group (#101)" 0 "" \
|
||||||
|
grep -qF 'usermod -aG incus dev1' "$A/sudo.log"
|
||||||
|
check "grant: their project is still narrowed to boxnet" 0 "" \
|
||||||
|
grep -qF 'project set user-1000 restricted.networks.access boxnet' "$A/incus.log"
|
||||||
|
check "grant: their project still gets snapshots" 0 "" \
|
||||||
|
grep -qF 'project set user-1000 restricted.snapshots allow' "$A/incus.log"
|
||||||
|
check "grant: their project still gets backups" 0 "" \
|
||||||
|
grep -qF 'project set user-1000 restricted.backups allow' "$A/incus.log"
|
||||||
|
check "grant: box-net is still installed INTO their project" 0 "" \
|
||||||
|
grep -qF -- '--project user-1000 profile edit box-net' "$A/incus.log"
|
||||||
|
# The socket pin (#99's teeth): incus's client takes the DAEMON socket when it
|
||||||
|
# is writable, and only falls back to unix.socket.user when it is not — so for
|
||||||
|
# an incus-admin member an unpinned touch never reaches incus-user at all, and
|
||||||
|
# the project it was supposed to create never appears.
|
||||||
|
check "grant: the touch is pinned at incus-user's socket (the admin socket would win)" 0 "" \
|
||||||
|
grep -qF "INCUS_SOCKET=$W99/incusdir/unix.socket.user" "$A/sudo.log"
|
||||||
|
check "grant: the user-side proof names their project (an unqualified show proves nothing)" 0 "" \
|
||||||
|
grep -qF -- '--project user-1000 profile show box-net' "$A/sudo.log"
|
||||||
|
# The socket existence probe rides $SUDO, like revoke's: /var/lib/incus is not
|
||||||
|
# traversable by a non-root admin, and a bare [ -e ] there false-fails into an
|
||||||
|
# exit that blames incus-user for a socket that is present (#101 review).
|
||||||
|
check "grant: the socket probe goes through sudo, not a bare [ -e ]" 0 "" \
|
||||||
|
grep -qF "test -e $W99/incusdir/unix.socket.user" "$A/sudo.log"
|
||||||
|
|
||||||
|
# --- the restricted user: unchanged, and unpinned ---------------------------
|
||||||
|
R="$W99/restricted"
|
||||||
|
check "grant: a plain user is still added to 'incus'" 0 "added dev1 to 'incus'" \
|
||||||
|
rungrant "users" "$R"
|
||||||
|
check "grant: ...via usermod (the log, not the prose)" 0 "" \
|
||||||
|
grep -qF 'usermod -aG incus dev1' "$R/sudo.log"
|
||||||
|
check "grant: ...and their client is left to its own socket fallback" 1 "" \
|
||||||
|
grep -qF 'INCUS_SOCKET' "$R/sudo.log"
|
||||||
|
|
||||||
|
# --- the failure path: what this run added comes back, and says what didn't --
|
||||||
|
F="$W99/failed"
|
||||||
|
check "grant: a failed grant for an admin member exits 1" 1 "FAILED" \
|
||||||
|
rungrant "users incus-admin" "$F" FAKE_FAIL_NARROW=1
|
||||||
|
check "grant: ...says their admin socket was neither granted nor removed here" 1 "neither granted nor removed" \
|
||||||
|
rungrant "users incus-admin" "$W99/f2" FAKE_FAIL_NARROW=1
|
||||||
|
# The membership IS this run's now, so the backout IS its business (#101).
|
||||||
|
check "grant: ...and DOES roll the 'incus' membership back (this run added it)" 0 "" \
|
||||||
|
grep -qF 'gpasswd -d dev1 incus' "$F/sudo.log"
|
||||||
|
check "grant: ...while refusing to call that rollback a lockout" 1 "closed incus-user's socket, NOT" \
|
||||||
|
rungrant "users incus-admin" "$W99/f3" FAKE_FAIL_NARROW=1
|
||||||
|
|
||||||
|
# --- revoke, the mirror: it cannot take what it never gave ------------------
|
||||||
|
# BOX_YES=1 throughout: --purge is destructive and refuses without a terminal
|
||||||
|
# to confirm on, and this suite has none. It changes nothing for a bare revoke.
|
||||||
|
runrevoke() { # runrevoke <groups> <state-dir> [script args...]
|
||||||
|
local groups="$1" state="$2"; shift 2
|
||||||
|
mkdir -p "$state"
|
||||||
|
env FAKE_UID=1000 FAKE_GROUPS="$groups" FAKE_STATE="$state" BOX_YES=1 \
|
||||||
|
FAKE_HAVE_PROJECT=1 FAKE_INCUS_LOG="$state/incus.log" FAKE_SUDO_LOG="$state/sudo.log" \
|
||||||
|
PATH="$GSHIM:$SHIMDIR:$PATH" bash "$ROOT/host/revoke-user.sh" dev1 "$@"
|
||||||
|
}
|
||||||
|
# The granted admin member is in BOTH groups — that is what 'box grant' leaves
|
||||||
|
# behind now (#101) — so revoke has a real membership to take back. It takes
|
||||||
|
# it, and still refuses to call the result a lockout: 'incus-admin' holds the
|
||||||
|
# daemon and is not this script's to remove.
|
||||||
|
GRANTED="users incus incus-admin"
|
||||||
|
V="$W99/revoke"
|
||||||
|
check "revoke: a bare revoke of a granted admin member is 'partial', not 'revoked'" 0 "partial:" \
|
||||||
|
runrevoke "$GRANTED" "$V"
|
||||||
|
check "revoke: ...and refuses to call it a lockout" 0 "is NOT locked out" \
|
||||||
|
runrevoke "$GRANTED" "$W99/v2"
|
||||||
|
check "revoke: ...naming the group that would actually lock them out" 0 "gpasswd -d dev1 incus-admin" \
|
||||||
|
runrevoke "$GRANTED" "$W99/v3"
|
||||||
|
# The mirror of grant's flip: there IS a privileged call now, and it is the
|
||||||
|
# membership grant added — asserted against the log, not the prose.
|
||||||
|
check "revoke: ...having actually dropped the 'incus' membership (the log)" 0 "" \
|
||||||
|
grep -qF 'gpasswd -d dev1 incus' "$V/sudo.log"
|
||||||
|
check "revoke: ...calling that key incus-user's, not their daemon access" 0 "NOT their daemon access" \
|
||||||
|
runrevoke "$GRANTED" "$W99/v4"
|
||||||
|
# An admin member who was never granted: nothing to take, and it still says so
|
||||||
|
# rather than reporting a revocation it did not perform.
|
||||||
|
N="$W99/revoke-ungranted"
|
||||||
|
check "revoke: an UNgranted admin member is still a named no-op" 0 "no-op:" \
|
||||||
|
runrevoke "users incus-admin" "$N"
|
||||||
|
check "revoke: ...saying their access is incus-admin's, untouched here" 0 "which this does not touch" \
|
||||||
|
runrevoke "users incus-admin" "$W99/n2"
|
||||||
|
# Absence of the LOG, not of a line in it: an ungranted admin member's bare
|
||||||
|
# revoke makes no privileged call whatsoever, so the file is never created.
|
||||||
|
check "revoke: ...having made NO privileged call at all (no membership to drop)" 1 "" \
|
||||||
|
test -e "$N/sudo.log"
|
||||||
|
P="$W99/purge"
|
||||||
|
check "revoke --purge: still unmakes the provisioning" 0 "purged:" \
|
||||||
|
runrevoke "$GRANTED" "$P" --purge
|
||||||
|
check "revoke --purge: ...and refuses to call an admin member 'out'" 0 "is NOT out" \
|
||||||
|
runrevoke "$GRANTED" "$W99/p2" --purge
|
||||||
|
check "revoke --purge: ...the project really was deleted (the log, not the summary)" 0 "" \
|
||||||
|
grep -qF 'project delete user-1000' "$P/incus.log"
|
||||||
|
rm -rf "$GSHIM" "$W99"
|
||||||
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
||||||
check "setup-host: the restricted gate precedes the sudo resolution" 0 "" bash -c '
|
check "setup-host: the restricted gate precedes the sudo resolution" 0 "" bash -c '
|
||||||
gate="$(grep -n "restricted tier" "'"$ROOT"'/host/setup-host.sh" | head -1 | cut -d: -f1)"
|
gate="$(grep -n "restricted tier" "'"$ROOT"'/host/setup-host.sh" | head -1 | cut -d: -f1)"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue