From c4084de132881584def40b3942019fd4246a2151 Mon Sep 17 00:00:00 2001 From: AlenHay <82121826+AlenHay@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:07:57 +0400 Subject: [PATCH] feat: per-tenant admin flag + doctor key-placement invariants Add an `admin: true` per-tenant flag to tenants.yaml (default false), parsed and round-tripped by the config package alongside `services:`. vswarm never touches host ssh config or authorized_keys; it only carries the flag and gains two doctor invariants over the well-known admin key path config//home/.ssh/vswarm-admin: (a) no non-admin tenant home holds a vswarm-admin key file; (b) every admin tenant's key file exists with mode 0600. Document the split-ownership contract and `ssh -i ~/.ssh/vswarm-admin ubuntu@` usage in DEPLOYMENT.md; extend the config round-trip test to cover the new key. --- DEPLOYMENT.md | 32 ++++++++++++++++++++++++++++++++ cmd/vswarm/doctor.go | 30 ++++++++++++++++++++++++++++++ internal/config/config.go | 6 ++++++ internal/config/config_test.go | 8 +++++++- tenants.example.yaml | 5 +++++ 5 files changed, 80 insertions(+), 1 deletion(-) diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 77d305f..0ef3c61 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -74,6 +74,38 @@ Apps run natively in the workspace (`bun run start:dev`) against it; reset with workspace can open a TCP connection to this tenant's db container, and (b) the db container is attached to exactly its own tenant network. +### Admin host SSH access (optional, per tenant) + +Mark a tenant with `admin: true` in `tenants.yaml` to grant it SSH access to the +host from inside its workspace. This is **split-ownership**: vswarm carries the +flag and enforces invariants, but **vswarm NEVER touches host ssh config or +`authorized_keys`** — minting and delivering the key is the deployment layer's +job (for us, the `vswarm` Ansible role). + +Contract the deployment layer implements: + +- A dedicated ed25519 keypair per admin tenant (not the tenant's git key, so + revocation is independent and the sshd audit trail is clean). +- The **private** half is delivered to the well-known path `~/.ssh/vswarm-admin` + inside the tenant home (mode `0600`, uid `1000`). +- The **public** half goes into the host user's `authorized_keys`, source-pinned + to the tenant's own subnet (`from="172.31.<10+index>.0/24"`), so the key is + useless anywhere but that workspace. Revocation = flip `admin` off and + re-apply (the `authorized_keys` line is removed). + +`vswarm doctor` gains two invariants: + +- **(a)** no NON-admin tenant home contains a `~/.ssh/vswarm-admin` file — a + stranded admin key on a tenant that lost the flag fails the gate; +- **(b)** every admin tenant's `~/.ssh/vswarm-admin` exists with mode `0600`. + +Usage from inside an admin workspace (the gateway is the tenant's own bridge +gateway, `172.31.<10+index>.1`, where index is the tenant's roster position): + +```sh +ssh -i ~/.ssh/vswarm-admin ubuntu@172.31.10.1 +``` + ## Commands the deployment layer runs ```bash diff --git a/cmd/vswarm/doctor.go b/cmd/vswarm/doctor.go index cca5a76..35bfd71 100644 --- a/cmd/vswarm/doctor.go +++ b/cmd/vswarm/doctor.go @@ -49,6 +49,22 @@ func cmdDoctor() error { check("ssh perms 700 for "+t.Name, merr == nil && mode == 0o700, modeStr(mode, merr)) } + for _, t := range c.Tenants { + if t.Admin { + continue + } + _, err := os.Stat(adminKeyPath(t.Name)) + check("no admin key in non-admin home: "+t.Name, os.IsNotExist(err), adminKeyDetail(err)) + } + + for _, t := range c.Tenants { + if !t.Admin { + continue + } + mode, merr := dirMode(adminKeyPath(t.Name)) + check("admin key 0600 for "+t.Name, merr == nil && mode == 0o600, modeStr(mode, merr)) + } + for _, t := range c.Tenants { if !t.HasService("postgres") { continue @@ -153,6 +169,20 @@ func tokenFromLine(s string) string { return fields[3] } +func adminKeyPath(name string) string { + return filepath.Join("config", name, "home", ".ssh", "vswarm-admin") +} + +func adminKeyDetail(err error) string { + if err == nil { + return "present" + } + if os.IsNotExist(err) { + return "" + } + return err.Error() +} + func dirMode(p string) (os.FileMode, error) { fi, err := os.Stat(p) if err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index 46fc672..c3bbff1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,6 +12,7 @@ type Tenant struct { Email string Name string Services []string + Admin bool } func (t Tenant) HasService(name string) bool { @@ -166,6 +167,8 @@ func applyTenant(t *Tenant, k, v string) error { } t.Services = append(t.Services, s) } + case "admin": + t.Admin = parseBool(v) } return nil } @@ -251,6 +254,9 @@ func (c *Config) Save() error { if len(t.Services) > 0 { fmt.Fprintf(&b, " services: [%s]\n", strings.Join(t.Services, ", ")) } + if t.Admin { + b.WriteString(" admin: true\n") + } } return os.WriteFile(c.Path, []byte(b.String()), 0o644) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 375c471..34390d3 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -24,6 +24,7 @@ tenants: - email: alice@example.com name: alice services: [postgres] + admin: true - email: bob@example.com name: bob-dev ` @@ -56,6 +57,9 @@ tenants: if !got.Tenants[0].HasService("postgres") || got.Tenants[1].HasService("postgres") { t.Fatalf("unexpected services: %#v", got.Tenants) } + if !got.Tenants[0].Admin || got.Tenants[1].Admin { + t.Fatalf("unexpected admin flags: %#v", got.Tenants) + } if err := got.Save(); err != nil { t.Fatalf("Save() error = %v", err) @@ -76,7 +80,9 @@ tenants: roundTrip.ManageTunnel != got.ManageTunnel || roundTrip.EdgeExternal != got.EdgeExternal || len(roundTrip.Tenants) != len(got.Tenants) || - !roundTrip.Tenants[0].HasService("postgres") { + !roundTrip.Tenants[0].HasService("postgres") || + roundTrip.Tenants[0].Admin != got.Tenants[0].Admin || + roundTrip.Tenants[1].Admin != got.Tenants[1].Admin { t.Fatalf("round trip mismatch:\nwant %#v\ngot %#v", got, roundTrip) } } diff --git a/tenants.example.yaml b/tenants.example.yaml index f1943af..78028d5 100644 --- a/tenants.example.yaml +++ b/tenants.example.yaml @@ -40,9 +40,14 @@ token_ttl: 30d # does not support block-style `-` lists). Only known services are accepted; # an unknown name is rejected at parse time. `postgres` starts a dev sidecar # and delivers ~/.pg.env into the tenant home (see docs/DEPLOYMENT.md). +# Optional `admin: true` marks a tenant for host SSH access. vswarm NEVER +# touches host ssh config or authorized_keys — the deployment layer mints the +# key at ~/.ssh/vswarm-admin; vswarm only carries the flag and enforces +# doctor invariants around the key file (see docs/DEPLOYMENT.md). tenants: - email: sarah@example.com name: sarah services: [postgres] + admin: true - email: alex@example.com name: alex