agent v0.24.0: role-gate the eject path (system/backup mounts unmount-protected at the agent)
handleDiskEject now resolves the authoritative role of the storage at `where` and refuses 403 (no Unmount) unless it is user-data. Fails safe to protected on ambiguity. Adds roleForMountPath + an injectable HostReader seam for testability. TestEject_RoleGated asserts protected mounts are refused with no Unmount. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -100,7 +100,7 @@ func (s *Server) handleDisks(w http.ResponseWriter, r *http.Request, vmid int) {
|
||||
}
|
||||
// Resolve the OS/system disks ONCE for this request — role classification is agent-authoritative
|
||||
// (the agent's own mount/topology read, never the caller's claim).
|
||||
sysDisks, sysKnown := storage.SystemDisks(storage.NewProcHostReader())
|
||||
sysDisks, sysKnown := storage.SystemDisks(s.hostReader())
|
||||
out := make([]DiskInfo, 0, len(targets))
|
||||
for _, t := range targets {
|
||||
di := DiskInfo{
|
||||
@@ -183,6 +183,17 @@ func (s *Server) handleDiskEject(w http.ResponseWriter, r *http.Request, vmid in
|
||||
writeErr(w, http.StatusBadRequest, "where (mountpoint) is required")
|
||||
return
|
||||
}
|
||||
// ROLE GATE (defense in depth): eject is permitted ONLY for a user-data mount. The agent
|
||||
// classifies the role of the storage at `where` from its OWN view — never the caller's claim —
|
||||
// and refuses system/backup. The UI hiding the button is NOT the control: a direct API call (or a
|
||||
// compromised controller) trying to unmount /var/lib/vz or the PBS mount is refused here. Fails
|
||||
// SAFE: an unresolvable mount → protected → refused (most-protected-on-ambiguity, like the wipe).
|
||||
if role := s.roleForMountPath(r.Context(), req.Where); role != storage.RoleUserData {
|
||||
s.logger.Warn("local-api: protected — eject refused by role",
|
||||
"vmid", vmid, "where", req.Where, "role", role)
|
||||
writeErr(w, http.StatusForbidden, "mount is system/backup-protected — eject refused (role: "+string(role)+")")
|
||||
return
|
||||
}
|
||||
dependents := s.dependentGuests(r.Context(), req.Where)
|
||||
if err := s.disks.Unmount(r.Context(), req.Where); err != nil {
|
||||
s.logger.Error("local-api: disk eject", "vmid", vmid, "where", req.Where, "err", err)
|
||||
@@ -338,11 +349,39 @@ func (s *Server) handleDiskFormat(w http.ResponseWriter, r *http.Request, vmid i
|
||||
"device is system/backup-protected — format requires an operator signature ("+dec.Reason+")")
|
||||
}
|
||||
|
||||
// hostReader returns the injected root-free host topology reader, or the production default. The seam
|
||||
// keeps the role classification (SystemDisks) testable without touching the real /proc /dev /sys.
|
||||
func (s *Server) hostReader() storage.HostReader {
|
||||
if s.host != nil {
|
||||
return s.host
|
||||
}
|
||||
return storage.NewProcHostReader()
|
||||
}
|
||||
|
||||
// roleForMountPath resolves the AUTHORITATIVE protection role of the storage mounted at `where`, from
|
||||
// the agent's OWN storage view + host topology (never the caller's claim). It mirrors deviceRole but
|
||||
// keys on the mount path (the eject input). It FAILS SAFE to system (most-protected) on any
|
||||
// ambiguity — a view error, or no storage target found at `where` — so an unresolvable eject is
|
||||
// refused rather than silently unmounted.
|
||||
func (s *Server) roleForMountPath(ctx context.Context, where string) storage.DeviceRole {
|
||||
sysDisks, sysKnown := storage.SystemDisks(s.hostReader())
|
||||
targets, err := s.storage.Observe(ctx)
|
||||
if err != nil {
|
||||
return storage.RoleSystem // can't read the view → treat as protected
|
||||
}
|
||||
for _, t := range targets {
|
||||
if t.MountPath == where {
|
||||
return storage.RoleForStorage(t.Type, t.BackingDevice, sysDisks, sysKnown)
|
||||
}
|
||||
}
|
||||
return storage.RoleSystem // no storage target at this mount → fail safe to protected
|
||||
}
|
||||
|
||||
// deviceRole resolves a device's AUTHORITATIVE protection tier. It prefers a known storage target's
|
||||
// role (so a PBS-backed device is recognized as backup), falling back to a raw-device classification
|
||||
// (for a fresh disk not yet a PVE storage — the init flow). Defaults to system on ambiguity.
|
||||
func (s *Server) deviceRole(ctx context.Context, device string) storage.DeviceRole {
|
||||
sysDisks, sysKnown := storage.SystemDisks(storage.NewProcHostReader())
|
||||
sysDisks, sysKnown := storage.SystemDisks(s.hostReader())
|
||||
if targets, err := s.storage.Observe(ctx); err == nil {
|
||||
for _, t := range targets {
|
||||
if t.BackingDevice != "" && t.BackingDevice == device {
|
||||
|
||||
Reference in New Issue
Block a user