Skip to content

Commit c82cc69

Browse files
Merge pull request #113 from open-source-cloud/feat/remote-tenant
feat(tenant): per-user tenant identity for a shared team backend (spec 21)
2 parents d8e0ecf + 93dde02 commit c82cc69

2 files changed

Lines changed: 229 additions & 0 deletions

File tree

‎internal/tenant/tenant.go‎

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Package tenant resolves the per-user tenant identity for a shared backend
2+
// (spec 21 §per-user isolation, Q-REMOTE-TENANT). On a LOCAL backend there is no
3+
// tenant: names stay <project>, exactly as today (backward compatible). On a
4+
// REMOTE team cluster, two developers both running a project named `app` would
5+
// collide and read each other's data — so every provisioned resource is
6+
// namespaced by the developer's tenant identity (role u_<user>_<project>, db
7+
// <user>_<project>, bucket <user>-<project>-…). This package owns ONLY resolving
8+
// and sanitizing that identity; each engine provisioner composes the namespaced
9+
// name with its own separator (spec 27: a provisioner owns its identifier map).
10+
package tenant
11+
12+
import (
13+
"os"
14+
"os/user"
15+
"strings"
16+
)
17+
18+
// maxLen bounds a sanitized tenant fragment so a namespaced identifier stays
19+
// within the strictest engine limit (S3 bucket names are 63 chars, shared with a
20+
// project + suffix — keep the tenant portion short).
21+
const maxLen = 32
22+
23+
// Identity is the resolved tenant for a backend. An empty Name means LOCAL (no
24+
// namespacing); a non-empty Name is the sanitized per-user identity on a team
25+
// backend.
26+
type Identity struct {
27+
Name string
28+
}
29+
30+
// IsTenant reports whether namespacing applies (a remote team backend).
31+
func (id Identity) IsTenant() bool { return id.Name != "" }
32+
33+
// Qualify namespaces a resource name with the tenant using the engine's
34+
// separator. Local (empty tenant) returns name unchanged — the existing
35+
// <project> naming, so nothing on a single-user machine shifts. On a team
36+
// backend it returns <tenant><sep><name>.
37+
func (id Identity) Qualify(name, sep string) string {
38+
if id.Name == "" {
39+
return name
40+
}
41+
return id.Name + sep + name
42+
}
43+
44+
// Deps injects the environment + OS-user lookups so Resolve is unit-testable
45+
// without touching the real environment.
46+
type Deps struct {
47+
Getenv func(string) string
48+
OSUser func() (string, error)
49+
}
50+
51+
// DefaultDeps wires the real os.Getenv + the OS username.
52+
func DefaultDeps() Deps {
53+
return Deps{
54+
Getenv: os.Getenv,
55+
OSUser: osUsername,
56+
}
57+
}
58+
59+
// Resolve derives the tenant identity for a backend. LOCAL backends have no
60+
// tenant (empty). For a REMOTE team backend the precedence (Q-REMOTE-TENANT) is:
61+
//
62+
// DEVSTACK_TENANT env → the workspace-configured identity → the OS username
63+
//
64+
// falling back to "user" so even a nameless account still isolates. The result
65+
// is sanitized to a safe, stable identifier fragment (the same user always maps
66+
// to the same tenant across runs and machines).
67+
func Resolve(remote bool, configured string, deps Deps) Identity {
68+
if !remote {
69+
return Identity{}
70+
}
71+
if deps.Getenv == nil {
72+
deps.Getenv = os.Getenv
73+
}
74+
if deps.OSUser == nil {
75+
deps.OSUser = osUsername
76+
}
77+
raw := deps.Getenv("DEVSTACK_TENANT")
78+
if raw == "" {
79+
raw = configured
80+
}
81+
if raw == "" {
82+
if u, err := deps.OSUser(); err == nil {
83+
raw = u
84+
}
85+
}
86+
name := Sanitize(raw)
87+
if name == "" {
88+
name = "user"
89+
}
90+
return Identity{Name: name}
91+
}
92+
93+
// Sanitize maps an arbitrary identity to a safe, stable identifier fragment that
94+
// is valid across every engine's namespace (the strictest being S3 buckets:
95+
// lowercase, alphanumeric + hyphen). It lowercases, replaces each run of
96+
// non-alphanumeric characters with a single hyphen, trims leading/trailing
97+
// hyphens, and caps the length. Deterministic.
98+
func Sanitize(s string) string {
99+
s = strings.ToLower(strings.TrimSpace(s))
100+
var b strings.Builder
101+
prevHyphen := false
102+
for _, r := range s {
103+
switch {
104+
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
105+
b.WriteRune(r)
106+
prevHyphen = false
107+
default:
108+
if !prevHyphen && b.Len() > 0 {
109+
b.WriteByte('-')
110+
prevHyphen = true
111+
}
112+
}
113+
}
114+
out := strings.Trim(b.String(), "-")
115+
if len(out) > maxLen {
116+
out = strings.Trim(out[:maxLen], "-")
117+
}
118+
return out
119+
}
120+
121+
// osUsername returns the current OS user's username (lowercased happens in
122+
// Sanitize). Separated so tests inject a fake.
123+
func osUsername() (string, error) {
124+
u, err := user.Current()
125+
if err != nil {
126+
return "", err
127+
}
128+
return u.Username, nil
129+
}

‎internal/tenant/tenant_test.go‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package tenant
2+
3+
import (
4+
"errors"
5+
"testing"
6+
)
7+
8+
func fixedEnv(m map[string]string) func(string) string {
9+
return func(k string) string { return m[k] }
10+
}
11+
12+
func TestResolve_LocalHasNoTenant(t *testing.T) {
13+
id := Resolve(false, "ignored", Deps{
14+
Getenv: fixedEnv(map[string]string{"DEVSTACK_TENANT": "bob"}),
15+
OSUser: func() (string, error) { return "bob", nil },
16+
})
17+
if id.IsTenant() || id.Name != "" {
18+
t.Fatalf("local backend must have no tenant, got %q", id.Name)
19+
}
20+
// Qualify is a no-op locally → backward-compatible names.
21+
if got := id.Qualify("app", "_"); got != "app" {
22+
t.Errorf("local Qualify = %q, want app", got)
23+
}
24+
}
25+
26+
func TestResolve_RemotePrecedence(t *testing.T) {
27+
cases := []struct {
28+
name string
29+
env map[string]string
30+
configured string
31+
osUser string
32+
osErr error
33+
want string
34+
}{
35+
{"env wins", map[string]string{"DEVSTACK_TENANT": "Alice.Dev"}, "cfg", "root", nil, "alice-dev"},
36+
{"configured next", nil, "Team-One", "root", nil, "team-one"},
37+
{"os user fallback", nil, "", "Gustavo.Bertoi", nil, "gustavo-bertoi"},
38+
{"nameless fallback", nil, "", "", errors.New("no user"), "user"},
39+
}
40+
for _, tc := range cases {
41+
t.Run(tc.name, func(t *testing.T) {
42+
id := Resolve(true, tc.configured, Deps{
43+
Getenv: fixedEnv(tc.env),
44+
OSUser: func() (string, error) { return tc.osUser, tc.osErr },
45+
})
46+
if id.Name != tc.want {
47+
t.Errorf("Resolve = %q, want %q", id.Name, tc.want)
48+
}
49+
})
50+
}
51+
}
52+
53+
func TestResolve_JunkConfiguredFallsToUser(t *testing.T) {
54+
// A configured value that sanitizes to empty must not yield an empty tenant.
55+
id := Resolve(true, "***", Deps{
56+
Getenv: fixedEnv(nil),
57+
OSUser: func() (string, error) { return "", errors.New("none") },
58+
})
59+
if id.Name != "user" {
60+
t.Errorf("junk configured → %q, want user", id.Name)
61+
}
62+
}
63+
64+
func TestQualify_RemoteNamespaces(t *testing.T) {
65+
id := Identity{Name: "alice"}
66+
if got := id.Qualify("app", "_"); got != "alice_app" {
67+
t.Errorf("sql Qualify = %q, want alice_app", got)
68+
}
69+
if got := id.Qualify("uploads", "-"); got != "alice-uploads" {
70+
t.Errorf("bucket Qualify = %q, want alice-uploads", got)
71+
}
72+
}
73+
74+
func TestSanitize(t *testing.T) {
75+
cases := map[string]string{
76+
"Gustavo.Bertoi": "gustavo-bertoi",
77+
"UPPER_snake": "upper-snake",
78+
" trim.me ": "trim-me",
79+
"a@@@b---c": "a-b-c",
80+
"---leading": "leading",
81+
"trailing---": "trailing",
82+
"only***symbols": "only-symbols",
83+
"***": "",
84+
"good123": "good123",
85+
"日本語user": "user", // non-ascii dropped, leaving "user"
86+
}
87+
for in, want := range cases {
88+
if got := Sanitize(in); got != want {
89+
t.Errorf("Sanitize(%q) = %q, want %q", in, got, want)
90+
}
91+
}
92+
}
93+
94+
func TestSanitize_LengthCapped(t *testing.T) {
95+
long := "abcdefghijklmnopqrstuvwxyz0123456789abcdefghij" // 45 chars
96+
got := Sanitize(long)
97+
if len(got) > maxLen {
98+
t.Errorf("Sanitize length = %d, want <= %d", len(got), maxLen)
99+
}
100+
}

0 commit comments

Comments
 (0)