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:
+19
-6
@@ -17,6 +17,7 @@ type Client struct {
|
||||
base string // https://<server>:<port>/api2/json
|
||||
authHeader string // "PBSAPIToken=<tokenid>:<secret>" — SECRET; never logged
|
||||
http *http.Client
|
||||
namespace string // "" = root ns (whole-datastore); set = per-customer tenant scope (S4)
|
||||
}
|
||||
|
||||
// Config builds a Client. Secret is read by the caller from /etc/pve/priv/storage/<id>.pw at
|
||||
@@ -27,6 +28,7 @@ type Config struct {
|
||||
Fingerprint string // SHA-256 of the PBS leaf cert (colons optional)
|
||||
TokenID string // e.g. "felhom@pbs!n100" (from storage.cfg `username`)
|
||||
Secret string // token secret (from <id>.pw)
|
||||
Namespace string // PBS namespace (from storage.cfg `namespace`); "" = root. S4 per-customer tenancy.
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
@@ -50,6 +52,7 @@ func NewClient(cfg Config) (*Client, error) {
|
||||
return &Client{
|
||||
base: fmt.Sprintf("https://%s:%d/api2/json", cfg.Server, port),
|
||||
authHeader: "PBSAPIToken=" + cfg.TokenID + ":" + cfg.Secret,
|
||||
namespace: cfg.Namespace,
|
||||
http: &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{TLSClientConfig: tlsCfg},
|
||||
@@ -57,11 +60,12 @@ func NewClient(cfg Config) (*Client, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Verify triggers a datastore verify (POST /admin/datastore/<ds>/verify) and returns the
|
||||
// task UPID. With no snapshots it verifies the whole datastore; the cheap, key-free,
|
||||
// ciphertext-level integrity check (doc 03 §8). Needs the token's Datastore.Verify (in
|
||||
// DatastoreAdmin). Per-snapshot scoping is a future refinement; whole-datastore is the spike-
|
||||
// proven path.
|
||||
// Verify triggers a verify (POST /admin/datastore/<ds>/verify) and returns the task UPID; the
|
||||
// cheap, key-free, ciphertext-level integrity check (doc 03 §8). When the client is namespace-
|
||||
// scoped (S4 per-customer tenancy) the verify is confined to that namespace (`ns=`), which a
|
||||
// DatastoreBackup token can trigger on its OWN namespace — no Datastore.Verify / admin widening
|
||||
// (Phase-1 confirmed live 2026-07-04). Root-ns (unscoped) clients verify the whole datastore as
|
||||
// before (needs Datastore.Verify, e.g. the DooPlex felhom-pbs n100 token).
|
||||
func (c *Client) Verify(ctx context.Context, datastore string, _ ...string) (string, error) {
|
||||
var out struct {
|
||||
Data string `json:"data"`
|
||||
@@ -72,6 +76,9 @@ func (c *Client) Verify(ctx context.Context, datastore string, _ ...string) (str
|
||||
// a chunk that rots after its first verify would never be re-checked). The cost is real
|
||||
// re-read I/O; for a large datastore a future refinement is outdated-after-based scoping.
|
||||
form := url.Values{"ignore-verified": {"false"}}
|
||||
if c.namespace != "" {
|
||||
form.Set("ns", c.namespace)
|
||||
}
|
||||
if err := c.post(ctx, path, form, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -98,12 +105,18 @@ type Snapshot struct {
|
||||
} `json:"files"`
|
||||
}
|
||||
|
||||
// Snapshots lists the datastore's snapshots (incl. the verification field).
|
||||
// Snapshots lists the datastore's snapshots (incl. the verification field). A namespace-scoped
|
||||
// client (S4) lists ONLY its namespace (`?ns=`) — the unscoped call targets the datastore root,
|
||||
// which a per-customer DatastoreBackup token cannot read (Phase-1: 403 without ns). Root-ns
|
||||
// clients list the root namespace as before.
|
||||
func (c *Client) Snapshots(ctx context.Context, datastore string) ([]Snapshot, error) {
|
||||
var out struct {
|
||||
Data []Snapshot `json:"data"`
|
||||
}
|
||||
path := fmt.Sprintf("/admin/datastore/%s/snapshots", url.PathEscape(datastore))
|
||||
if c.namespace != "" {
|
||||
path += "?ns=" + url.QueryEscape(c.namespace)
|
||||
}
|
||||
if err := c.do(ctx, http.MethodGet, path, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user