pbs: namespace-aware client for per-customer offsite tenancy (S4)
Phase-1 live probe (felhom-hetzner) proved backup/restore/list/isolation over the tunnel with a per-customer DatastoreBackup token, but the agent's PBS client was namespace-unaware: Snapshots hit the datastore root (403 for a scoped token) and Verify was whole-datastore (needs Datastore.Verify ~ admin). Operator- approved fix. - pbs.Config.Namespace + Client.namespace; Snapshots appends ?ns=; Verify sends ns= (ns-scoped verify works with DatastoreBackup on the own ns — no admin widening, Phase-1 confirmed). Root-ns clients unchanged (whole-datastore). - proxmox.Storage.Namespace (parsed from /storage `namespace`). - pbsTargetsFromPVE threads s.Namespace into the client. Confirmed tenant ACL: DatastoreBackup on /datastore/felhom-offsite/<ns> (NOT /ns/<ns>) to BOTH felhom@pbs (user) AND felhom@pbs!<ns> (token) — PBS privsep = intersection; isolation holds (cross-ns 403 proven). TestClient_NamespaceScoping red-proofed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -159,3 +159,64 @@ func TestNormalizeFingerprint(t *testing.T) {
|
||||
t.Errorf("normalize = %q err=%v (want lowercased, colons stripped)", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClient_NamespaceScoping pins the S4 per-customer tenancy behavior: a namespace-scoped client
|
||||
// lists ONLY its namespace (snapshots ?ns=) and verifies ONLY its namespace (verify ns=), so a
|
||||
// DatastoreBackup token never touches the datastore root; a root client (no namespace) sends
|
||||
// neither — whole-datastore behavior preserved (the DooPlex felhom-pbs path). Red-proof: drop the
|
||||
// `if c.namespace != ""` guard in Snapshots/Verify and the scoped assertions fail.
|
||||
func TestClient_NamespaceScoping(t *testing.T) {
|
||||
var gotSnapQuery, gotVerifyNS string
|
||||
ts, fp := newPBSTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/snapshots"):
|
||||
gotSnapQuery = r.URL.RawQuery
|
||||
w.Write([]byte(`{"data":[]}`))
|
||||
case strings.Contains(r.URL.Path, "/verify"):
|
||||
r.ParseForm()
|
||||
gotVerifyNS = r.PostFormValue("ns")
|
||||
w.Write([]byte(`{"data":"UPID:node:1:2:3:4:verify:ds:felhom@pbs!demo:"}`))
|
||||
default:
|
||||
w.Write([]byte(`{"data":[]}`))
|
||||
}
|
||||
})
|
||||
host, port := hostPort(t, ts.URL)
|
||||
ctx := context.Background()
|
||||
|
||||
// Namespace-scoped tenant client.
|
||||
scoped, err := NewClient(Config{Server: host, Port: port, Fingerprint: fp, TokenID: "felhom@pbs!demo-felhom-01", Secret: "s", Namespace: "demo-felhom-01"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := scoped.Snapshots(ctx, "felhom-offsite"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotSnapQuery != "ns=demo-felhom-01" {
|
||||
t.Errorf("scoped snapshots query = %q, want ns=demo-felhom-01", gotSnapQuery)
|
||||
}
|
||||
if _, err := scoped.Verify(ctx, "felhom-offsite"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotVerifyNS != "demo-felhom-01" {
|
||||
t.Errorf("scoped verify ns = %q, want demo-felhom-01", gotVerifyNS)
|
||||
}
|
||||
|
||||
// Root client: no namespace → whole-datastore, no ns on either call.
|
||||
gotSnapQuery, gotVerifyNS = "SENTINEL", "SENTINEL"
|
||||
root, err := NewClient(Config{Server: host, Port: port, Fingerprint: fp, TokenID: "felhom@pbs!n100", Secret: "s"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := root.Snapshots(ctx, "felhom-spike"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotSnapQuery != "" {
|
||||
t.Errorf("root snapshots query = %q, want empty (no ns → whole datastore)", gotSnapQuery)
|
||||
}
|
||||
if _, err := root.Verify(ctx, "felhom-spike"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotVerifyNS != "" {
|
||||
t.Errorf("root verify ns = %q, want empty (no ns → whole datastore)", gotVerifyNS)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user