From 93dde02dbe7e811db0c80fa4a1f21c376c98969d Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Wed, 1 Jul 2026 15:59:16 -0300 Subject: [PATCH] feat(tenant): per-user tenant identity for a shared team backend (spec 21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second spec-21 foundation (after the DistLock): the per-user isolation basis (Q-REMOTE-TENANT). On a shared team cluster the naive "name the role after the project" is NOT isolation — two developers' `app` projects collide and can read each other's data. internal/tenant resolves a per-developer identity that every provisioner namespaces resources by. - Identity{Name}: empty on a LOCAL backend (Qualify is a no-op → names stay , fully backward compatible), a sanitized per-user name on a REMOTE team backend. - Resolve(remote, configured, deps): local → empty; remote precedence is DEVSTACK_TENANT env → the workspace-configured identity → the OS username → "user" (a nameless account still isolates). Deps injects env + os-user lookups so it is unit-testable without touching the real environment. - Sanitize: lowercases, collapses non-alphanumeric runs to a single hyphen, trims and length-caps — a fragment valid across the strictest engine namespace (S3 buckets) and STABLE (the same user maps to the same tenant every run/machine). - Qualify(name, sep): engine-agnostic namespacing (role `u`+`_`, db `_`, bucket `-`) — each provisioner keeps ownership of its identifier map (spec 27). Unit-tested: local no-op, remote precedence, junk/nameless fallbacks, sanitize edge cases (unicode, symbols, length), and both separators. Not yet wired into the provisioners — that lands with the remote-ledger + tenant-column integration, validated on the owner's VPS per the release-quality bar. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/tenant/tenant.go | 129 +++++++++++++++++++++++++++++++++ internal/tenant/tenant_test.go | 100 +++++++++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 internal/tenant/tenant.go create mode 100644 internal/tenant/tenant_test.go diff --git a/internal/tenant/tenant.go b/internal/tenant/tenant.go new file mode 100644 index 0000000..bd9ae1f --- /dev/null +++ b/internal/tenant/tenant.go @@ -0,0 +1,129 @@ +// Package tenant resolves the per-user tenant identity for a shared backend +// (spec 21 §per-user isolation, Q-REMOTE-TENANT). On a LOCAL backend there is no +// tenant: names stay , exactly as today (backward compatible). On a +// REMOTE team cluster, two developers both running a project named `app` would +// collide and read each other's data — so every provisioned resource is +// namespaced by the developer's tenant identity (role u__, db +// _, bucket --…). This package owns ONLY resolving +// and sanitizing that identity; each engine provisioner composes the namespaced +// name with its own separator (spec 27: a provisioner owns its identifier map). +package tenant + +import ( + "os" + "os/user" + "strings" +) + +// maxLen bounds a sanitized tenant fragment so a namespaced identifier stays +// within the strictest engine limit (S3 bucket names are 63 chars, shared with a +// project + suffix — keep the tenant portion short). +const maxLen = 32 + +// Identity is the resolved tenant for a backend. An empty Name means LOCAL (no +// namespacing); a non-empty Name is the sanitized per-user identity on a team +// backend. +type Identity struct { + Name string +} + +// IsTenant reports whether namespacing applies (a remote team backend). +func (id Identity) IsTenant() bool { return id.Name != "" } + +// Qualify namespaces a resource name with the tenant using the engine's +// separator. Local (empty tenant) returns name unchanged — the existing +// naming, so nothing on a single-user machine shifts. On a team +// backend it returns . +func (id Identity) Qualify(name, sep string) string { + if id.Name == "" { + return name + } + return id.Name + sep + name +} + +// Deps injects the environment + OS-user lookups so Resolve is unit-testable +// without touching the real environment. +type Deps struct { + Getenv func(string) string + OSUser func() (string, error) +} + +// DefaultDeps wires the real os.Getenv + the OS username. +func DefaultDeps() Deps { + return Deps{ + Getenv: os.Getenv, + OSUser: osUsername, + } +} + +// Resolve derives the tenant identity for a backend. LOCAL backends have no +// tenant (empty). For a REMOTE team backend the precedence (Q-REMOTE-TENANT) is: +// +// DEVSTACK_TENANT env → the workspace-configured identity → the OS username +// +// falling back to "user" so even a nameless account still isolates. The result +// is sanitized to a safe, stable identifier fragment (the same user always maps +// to the same tenant across runs and machines). +func Resolve(remote bool, configured string, deps Deps) Identity { + if !remote { + return Identity{} + } + if deps.Getenv == nil { + deps.Getenv = os.Getenv + } + if deps.OSUser == nil { + deps.OSUser = osUsername + } + raw := deps.Getenv("DEVSTACK_TENANT") + if raw == "" { + raw = configured + } + if raw == "" { + if u, err := deps.OSUser(); err == nil { + raw = u + } + } + name := Sanitize(raw) + if name == "" { + name = "user" + } + return Identity{Name: name} +} + +// Sanitize maps an arbitrary identity to a safe, stable identifier fragment that +// is valid across every engine's namespace (the strictest being S3 buckets: +// lowercase, alphanumeric + hyphen). It lowercases, replaces each run of +// non-alphanumeric characters with a single hyphen, trims leading/trailing +// hyphens, and caps the length. Deterministic. +func Sanitize(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + var b strings.Builder + prevHyphen := false + for _, r := range s { + switch { + case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'): + b.WriteRune(r) + prevHyphen = false + default: + if !prevHyphen && b.Len() > 0 { + b.WriteByte('-') + prevHyphen = true + } + } + } + out := strings.Trim(b.String(), "-") + if len(out) > maxLen { + out = strings.Trim(out[:maxLen], "-") + } + return out +} + +// osUsername returns the current OS user's username (lowercased happens in +// Sanitize). Separated so tests inject a fake. +func osUsername() (string, error) { + u, err := user.Current() + if err != nil { + return "", err + } + return u.Username, nil +} diff --git a/internal/tenant/tenant_test.go b/internal/tenant/tenant_test.go new file mode 100644 index 0000000..22cad04 --- /dev/null +++ b/internal/tenant/tenant_test.go @@ -0,0 +1,100 @@ +package tenant + +import ( + "errors" + "testing" +) + +func fixedEnv(m map[string]string) func(string) string { + return func(k string) string { return m[k] } +} + +func TestResolve_LocalHasNoTenant(t *testing.T) { + id := Resolve(false, "ignored", Deps{ + Getenv: fixedEnv(map[string]string{"DEVSTACK_TENANT": "bob"}), + OSUser: func() (string, error) { return "bob", nil }, + }) + if id.IsTenant() || id.Name != "" { + t.Fatalf("local backend must have no tenant, got %q", id.Name) + } + // Qualify is a no-op locally → backward-compatible names. + if got := id.Qualify("app", "_"); got != "app" { + t.Errorf("local Qualify = %q, want app", got) + } +} + +func TestResolve_RemotePrecedence(t *testing.T) { + cases := []struct { + name string + env map[string]string + configured string + osUser string + osErr error + want string + }{ + {"env wins", map[string]string{"DEVSTACK_TENANT": "Alice.Dev"}, "cfg", "root", nil, "alice-dev"}, + {"configured next", nil, "Team-One", "root", nil, "team-one"}, + {"os user fallback", nil, "", "Gustavo.Bertoi", nil, "gustavo-bertoi"}, + {"nameless fallback", nil, "", "", errors.New("no user"), "user"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + id := Resolve(true, tc.configured, Deps{ + Getenv: fixedEnv(tc.env), + OSUser: func() (string, error) { return tc.osUser, tc.osErr }, + }) + if id.Name != tc.want { + t.Errorf("Resolve = %q, want %q", id.Name, tc.want) + } + }) + } +} + +func TestResolve_JunkConfiguredFallsToUser(t *testing.T) { + // A configured value that sanitizes to empty must not yield an empty tenant. + id := Resolve(true, "***", Deps{ + Getenv: fixedEnv(nil), + OSUser: func() (string, error) { return "", errors.New("none") }, + }) + if id.Name != "user" { + t.Errorf("junk configured → %q, want user", id.Name) + } +} + +func TestQualify_RemoteNamespaces(t *testing.T) { + id := Identity{Name: "alice"} + if got := id.Qualify("app", "_"); got != "alice_app" { + t.Errorf("sql Qualify = %q, want alice_app", got) + } + if got := id.Qualify("uploads", "-"); got != "alice-uploads" { + t.Errorf("bucket Qualify = %q, want alice-uploads", got) + } +} + +func TestSanitize(t *testing.T) { + cases := map[string]string{ + "Gustavo.Bertoi": "gustavo-bertoi", + "UPPER_snake": "upper-snake", + " trim.me ": "trim-me", + "a@@@b---c": "a-b-c", + "---leading": "leading", + "trailing---": "trailing", + "only***symbols": "only-symbols", + "***": "", + "good123": "good123", + "日本語user": "user", // non-ascii dropped, leaving "user" + } + for in, want := range cases { + if got := Sanitize(in); got != want { + t.Errorf("Sanitize(%q) = %q, want %q", in, got, want) + } + } +} + +func TestSanitize_LengthCapped(t *testing.T) { + long := "abcdefghijklmnopqrstuvwxyz0123456789abcdefghij" // 45 chars + got := Sanitize(long) + if len(got) > maxLen { + t.Errorf("Sanitize length = %d, want <= %d", len(got), maxLen) + } +}