diff --git a/docs/superpowers/plans/2026-07-21-saas-auth-orgs.md b/docs/superpowers/plans/2026-07-21-saas-auth-orgs.md new file mode 100644 index 0000000..5cd392d --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-saas-auth-orgs.md @@ -0,0 +1,1435 @@ +# SaaS Auth + Organizations Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn Vantage from single-admin global-OIDC into a multi-tenant app with local email/password auth, organizations, per-org OIDC, org-scoped data, and per-org subdomains. + +**Architecture:** One shared deployment, one shared MongoDB. Isolation is `org_id` on every domain collection, filtered in the service layer; org derived from the Redis session (never from client input). Per-org subdomain (`.vantage.hostxtra.co.uk`) is a routing/UX hint via wildcard DNS+TLS — not an auth boundary. gRPC endpoint stays single and shared; agent RPCs resolve org from the `servers` record. + +**Tech Stack:** Go (gin, mongo-driver v2, go-oidc/v3, oauth2, golang.org/x/crypto/bcrypt), Redis sessions, Next.js (app router, React). + +## Global Constraints + +- **No Go test files.** Tests are out of scope (spec §10). Each task verifies with `go build ./...` (server) / `npm run build` (web) plus the stated manual check. Do NOT create `*_test.go`. +- Isolation boundary is the **service layer**: every scoped query filters `org_id`; every insert sets `org_id`. Handlers derive org via `auth.OrgID(c)` — **never** accept `org_id` from the client body/query. +- Hostname/subdomain is a routing hint only; it MUST NOT grant access. Authorization always uses session `org_id`. +- bcrypt cost ≥ 12. Passwords never returned in any response. +- Org OIDC client secret encrypted at rest with existing `services` AES (`encryptString`/`decryptString` in `crypto.go`). +- Commit after every task. Work stays on branch `feat/saas-auth-orgs`. **No push to main, no deploy, no agent tag.** +- Scoped collections (each gains `org_id string`): `servers`, `keys`, `assignments`, `secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `audit`, `monitors`, `channels`. New collections: `orgs`, `users`, `org_oidc`, `migrations`. + +--- + +### Task 1: Models — orgs, users, org_oidc, and org_id on existing models + +**Files:** +- Create: `server/internal/models/org.go` +- Create: `server/internal/models/user.go` +- Create: `server/internal/models/org_oidc.go` +- Modify: `server/internal/models/server.go`, `key.go`, `assignment.go`, `secret.go`, `workflow.go`, `monitor.go`, `channel.go`, `audit.go` + +**Interfaces:** +- Produces: `models.Org{ID, OrgID, Name, Slug, CreatedAt}`, `models.User{ID, UserID, OrgID, Email, PasswordHash, Role, AuthSource, CreatedAt, LastLogin}`, `models.OrgOIDC{ID, OrgID, Issuer, ClientID, ClientSecretEnc, Enabled, UpdatedAt}`. Each existing scoped model gains `OrgID string` with tag `bson:"org_id" json:"org_id"`. + +- [ ] **Step 1: Create `models/org.go`** + +```go +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +type Org struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + OrgID string `bson:"org_id" json:"org_id"` + Name string `bson:"name" json:"name"` + Slug string `bson:"slug" json:"slug"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` +} +``` + +- [ ] **Step 2: Create `models/user.go`** + +```go +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +type User struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + UserID string `bson:"user_id" json:"user_id"` + OrgID string `bson:"org_id" json:"org_id"` + Email string `bson:"email" json:"email"` + PasswordHash string `bson:"password_hash,omitempty" json:"-"` + Role string `bson:"role" json:"role"` // owner|admin|member + AuthSource string `bson:"auth_source" json:"auth_source"` // local|oidc + CreatedAt time.Time `bson:"created_at" json:"created_at"` + LastLogin *time.Time `bson:"last_login,omitempty" json:"last_login,omitempty"` +} +``` + +- [ ] **Step 3: Create `models/org_oidc.go`** + +```go +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +type OrgOIDC struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + OrgID string `bson:"org_id" json:"org_id"` + Issuer string `bson:"issuer" json:"issuer"` + ClientID string `bson:"client_id" json:"client_id"` + ClientSecretEnc string `bson:"client_secret_enc,omitempty" json:"-"` + Enabled bool `bson:"enabled" json:"enabled"` + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` +} +``` + +- [ ] **Step 4: Add `OrgID` to each existing scoped model.** In `server.go`, `key.go`, `assignment.go`, `secret.go`, `workflow.go` (the `Workflow`, `WorkflowStep`, `WorkflowRun` structs), `monitor.go`, `channel.go`, `audit.go` — add this field right after the leading `ID`/primary-id field: + +```go + OrgID string `bson:"org_id" json:"org_id"` +``` + +- [ ] **Step 5: Verify build** + +Run: `cd server && go build ./...` +Expected: compiles (new fields unused yet is fine). + +- [ ] **Step 6: Commit** + +```bash +git add server/internal/models +git commit -m "feat(models): add org/user/org_oidc models and org_id on scoped collections" +``` + +--- + +### Task 2: DB indexes + one-shot migration (backfill default org) + +**Files:** +- Create: `server/internal/services/orgs.go` (org CRUD + slug logic — see Task 6 for slug; here just create-with-slug helper stub used by migration) +- Create: `server/internal/services/migrate.go` +- Modify: `server/cmd/main.go:26-51` (call migration + index ensure after `db.Connect`) + +**Interfaces:** +- Produces: `services.RunMigrations() error`, `services.EnsureAuthIndexes() error`. Consumes: `db.Col`, slugify from Task 6 is NOT yet available — migration uses fixed slug `"default"`. + +- [ ] **Step 1: Create `services/migrate.go`** + +```go +package services + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/mrhid6/vantage/server/internal/db" + "github.com/mrhid6/vantage/server/internal/models" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +var scopedCollections = []string{ + "servers", "keys", "assignments", "secrets", + "workflows", "workflow_steps", "workflow_runs", + "audit", "monitors", "channels", +} + +// EnsureAuthIndexes creates unique indexes for the new auth collections. +func EnsureAuthIndexes() error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if _, err := db.Col("users").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "email", Value: 1}}, + Options: options.Index().SetUnique(true), + }); err != nil { + return err + } + if _, err := db.Col("orgs").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "slug", Value: 1}}, + Options: options.Index().SetUnique(true), + }); err != nil { + return err + } + if _, err := db.Col("org_oidc").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "org_id", Value: 1}}, + Options: options.Index().SetUnique(true), + }); err != nil { + return err + } + return nil +} + +// RunMigrations backfills a default org onto pre-existing documents. Idempotent +// via a marker in the migrations collection. +func RunMigrations() error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + const marker = "0001_default_org_backfill" + if n, _ := db.Col("migrations").CountDocuments(ctx, bson.M{"_id": marker}); n > 0 { + return nil + } + + // Only backfill if there is legacy data lacking org_id. + needs := false + for _, col := range scopedCollections { + n, _ := db.Col(col).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": false}}) + if n > 0 { + needs = true + break + } + } + + if needs { + var org models.Org + err := db.Col("orgs").FindOne(ctx, bson.M{"slug": "default"}).Decode(&org) + if err != nil { + org = models.Org{OrgID: uuid.NewString(), Name: "Default", Slug: "default", CreatedAt: time.Now()} + if _, err := db.Col("orgs").InsertOne(ctx, org); err != nil { + return err + } + } + for _, col := range scopedCollections { + if _, err := db.Col(col).UpdateMany(ctx, + bson.M{"org_id": bson.M{"$exists": false}}, + bson.M{"$set": bson.M{"org_id": org.OrgID}}, + ); err != nil { + return err + } + } + } + + _, err := db.Col("migrations").InsertOne(ctx, bson.M{"_id": marker, "applied_at": time.Now()}) + return err +} +``` + +- [ ] **Step 2: Wire into `main.go`** — after the `db.Connect` success log (line ~25), before `EnsureSecretIndexes`: + +```go + if err := services.EnsureAuthIndexes(); err != nil { + log.Printf("warning: failed to ensure auth indexes: %v", err) + } + if err := services.RunMigrations(); err != nil { + log.Fatalf("migration failed: %v", err) + } +``` + +- [ ] **Step 3: Verify build** + +Run: `cd server && go build ./...` +Expected: compiles. + +- [ ] **Step 4: Commit** + +```bash +git add server/internal/services/migrate.go server/cmd/main.go +git commit -m "feat(server): auth indexes + default-org backfill migration" +``` + +--- + +### Task 3: Session carries org — struct + helpers + +**Files:** +- Modify: `server/internal/auth/session.go:18-22` (Session struct) +- Modify: `server/internal/auth/middleware.go` (add `OrgID`/`Role`/`UserID` helpers; keep loading session) + +**Interfaces:** +- Produces: `Session{UserID, OrgID, Role, Email, Name}`; `auth.OrgID(c *gin.Context) string`, `auth.Role(c *gin.Context) string`, `auth.UserID(c *gin.Context) string`. + +- [ ] **Step 1: Extend `Session` in `session.go`** + +```go +type Session struct { + UserID string `json:"user_id"` + OrgID string `json:"org_id"` + Role string `json:"role"` + Email string `json:"email"` + Name string `json:"name"` +} +``` + +- [ ] **Step 2: Add helpers to `middleware.go`** (below `GetSessionFromContext`): + +```go +func OrgID(c *gin.Context) string { + if s := GetSessionFromContext(c); s != nil { + return s.OrgID + } + return "" +} + +func Role(c *gin.Context) string { + if s := GetSessionFromContext(c); s != nil { + return s.Role + } + return "" +} + +func UserID(c *gin.Context) string { + if s := GetSessionFromContext(c); s != nil { + return s.UserID + } + return "" +} +``` + +- [ ] **Step 3: Verify build** + +Run: `cd server && go build ./...` +Expected: compiles. + +- [ ] **Step 4: Commit** + +```bash +git add server/internal/auth/session.go server/internal/auth/middleware.go +git commit -m "feat(auth): session carries org_id/role; add context helpers" +``` + +--- + +### Task 4: Org + user services with slug + bcrypt + +**Files:** +- Modify/expand: `server/internal/services/orgs.go` +- Create: `server/internal/services/users.go` + +**Interfaces:** +- Produces: + - `services.Slugify(name string) string` + - `services.CreateOrg(name string) (*models.Org, error)` (generates unique slug, rejects reserved) + - `services.GetOrgBySlug(slug string) (*models.Org, error)`, `services.GetOrg(orgID string) (*models.Org, error)` + - `services.CreateUser(orgID, email, password, role, authSource string) (*models.User, error)` (bcrypt when password non-empty) + - `services.GetUserByEmail(email string) (*models.User, error)` + - `services.VerifyPassword(u *models.User, password string) bool` + - `services.ListUsers(orgID string) ([]models.User, error)`, `services.UpdateUserRole(orgID, userID, role string) error`, `services.DeleteUser(orgID, userID string) error` + - `services.CountUsers() (int64, error)`, `services.TouchLastLogin(userID string) error` + +- [ ] **Step 1: Create `services/orgs.go`** with slug logic and reserved list + +```go +package services + +import ( + "context" + "fmt" + "regexp" + "strings" + "time" + + "github.com/google/uuid" + "github.com/mrhid6/vantage/server/internal/db" + "github.com/mrhid6/vantage/server/internal/models" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +var reservedSlugs = map[string]bool{ + "www": true, "api": true, "app": true, "admin": true, "auth": true, + "install": true, "static": true, "_next": true, "default": true, +} + +var slugStrip = regexp.MustCompile(`[^a-z0-9-]+`) +var slugDashes = regexp.MustCompile(`-+`) + +func Slugify(name string) string { + s := strings.ToLower(strings.TrimSpace(name)) + s = strings.ReplaceAll(s, "_", "-") + s = strings.ReplaceAll(s, " ", "-") + s = slugStrip.ReplaceAllString(s, "") + s = slugDashes.ReplaceAllString(s, "-") + return strings.Trim(s, "-") +} + +func GetOrg(orgID string) (*models.Org, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var o models.Org + err := db.Col("orgs").FindOne(ctx, bson.M{"org_id": orgID}).Decode(&o) + if err != nil { + return nil, err + } + return &o, nil +} + +func GetOrgBySlug(slug string) (*models.Org, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var o models.Org + err := db.Col("orgs").FindOne(ctx, bson.M{"slug": slug}).Decode(&o) + if err != nil { + return nil, err + } + return &o, nil +} + +func CreateOrg(name string) (*models.Org, error) { + base := Slugify(name) + if len(base) < 3 { + return nil, fmt.Errorf("organization name too short (slug must be >= 3 chars)") + } + if len(base) > 40 { + base = base[:40] + } + if reservedSlugs[base] { + return nil, fmt.Errorf("organization name is reserved") + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Resolve slug collision by suffixing -2, -3, ... + slug := base + for i := 2; ; i++ { + n, err := db.Col("orgs").CountDocuments(ctx, bson.M{"slug": slug}) + if err != nil { + return nil, err + } + if n == 0 { + break + } + slug = fmt.Sprintf("%s-%d", base, i) + } + + o := &models.Org{OrgID: uuid.NewString(), Name: name, Slug: slug, CreatedAt: time.Now()} + if _, err := db.Col("orgs").InsertOne(ctx, o); err != nil { + if mongo.IsDuplicateKeyError(err) { + return nil, fmt.Errorf("organization slug already taken") + } + return nil, err + } + return o, nil +} +``` + +- [ ] **Step 2: Create `services/users.go`** + +```go +package services + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/mrhid6/vantage/server/internal/db" + "github.com/mrhid6/vantage/server/internal/models" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "golang.org/x/crypto/bcrypt" +) + +func CountUsers() (int64, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return db.Col("users").CountDocuments(ctx, bson.M{}) +} + +func CreateUser(orgID, email, password, role, authSource string) (*models.User, error) { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return nil, fmt.Errorf("email required") + } + u := &models.User{ + UserID: uuid.NewString(), + OrgID: orgID, + Email: email, + Role: role, + AuthSource: authSource, + CreatedAt: time.Now(), + } + if password != "" { + hash, err := bcrypt.GenerateFromPassword([]byte(password), 12) + if err != nil { + return nil, err + } + u.PasswordHash = string(hash) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := db.Col("users").InsertOne(ctx, u); err != nil { + if mongo.IsDuplicateKeyError(err) { + return nil, fmt.Errorf("email already registered") + } + return nil, err + } + return u, nil +} + +func GetUserByEmail(email string) (*models.User, error) { + email = strings.ToLower(strings.TrimSpace(email)) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var u models.User + err := db.Col("users").FindOne(ctx, bson.M{"email": email}).Decode(&u) + if err != nil { + return nil, err + } + return &u, nil +} + +func VerifyPassword(u *models.User, password string) bool { + if u == nil || u.PasswordHash == "" { + return false + } + return bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil +} + +func TouchLastLogin(userID string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + now := time.Now() + _, err := db.Col("users").UpdateOne(ctx, bson.M{"user_id": userID}, + bson.M{"$set": bson.M{"last_login": now}}) + return err +} + +func ListUsers(orgID string) ([]models.User, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cursor, err := db.Col("users").Find(ctx, bson.M{"org_id": orgID}) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var users []models.User + if err := cursor.All(ctx, &users); err != nil { + return nil, err + } + return users, nil +} + +func UpdateUserRole(orgID, userID, role string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := db.Col("users").UpdateOne(ctx, + bson.M{"user_id": userID, "org_id": orgID}, + bson.M{"$set": bson.M{"role": role}}) + return err +} + +func DeleteUser(orgID, userID string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := db.Col("users").DeleteOne(ctx, bson.M{"user_id": userID, "org_id": orgID}) + return err +} +``` + +- [ ] **Step 3: Verify build** + +Run: `cd server && go build ./...` +Expected: compiles. + +- [ ] **Step 4: Commit** + +```bash +git add server/internal/services/orgs.go server/internal/services/users.go +git commit -m "feat(services): org create+slug and user service with bcrypt" +``` + +--- + +### Task 5: Local auth handlers + bootstrap + +**Files:** +- Create: `server/internal/auth/local.go` (login/register/logout/me/bootstrap handlers + cookie helper) +- Modify: `server/internal/auth/oidc.go` (extract shared `setSessionCookie`; keep existing OIDC handlers until Task 7) + +**Interfaces:** +- Consumes: `services.GetUserByEmail`, `services.VerifyPassword`, `services.CreateUser`, `services.CreateOrg`, `services.CountUsers`, `services.TouchLastLogin`, `SaveSession`, `Session`. +- Produces: `auth.HandleLocalLogin`, `auth.HandleLogout` (reuse existing), `auth.HandleMe` (rewritten), `auth.HandleBootstrapStatus`, `auth.HandleBootstrap`, `auth.SetSessionCookie(c, sessionID)`. + +- [ ] **Step 1: Add `SetSessionCookie` helper in `local.go`** (extract the cookie block currently inline in `oidc.go:106-115`) + +```go +package auth + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/server/internal/services" +) + +func SetSessionCookie(c *gin.Context, sessionID string) { + secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" + http.SetCookie(c.Writer, &http.Cookie{ + Name: sessionCookieName, + Value: sessionID, + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + MaxAge: int(sessionTTL.Seconds()), + }) +} +``` + +- [ ] **Step 2: Add local login handler** to `local.go` + +```go +func HandleLocalLogin(c *gin.Context) { + var body struct { + Email string `json:"email"` + Password string `json:"password"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "email and password required"}) + return + } + u, err := services.GetUserByEmail(body.Email) + if err != nil || !services.VerifyPassword(u, body.Password) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + return + } + sessionID, err := SaveSession(c.Request.Context(), &Session{ + UserID: u.UserID, OrgID: u.OrgID, Role: u.Role, Email: u.Email, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"}) + return + } + _ = services.TouchLastLogin(u.UserID) + SetSessionCookie(c, sessionID) + c.JSON(http.StatusOK, gin.H{"ok": true}) +} +``` + +- [ ] **Step 3: Add bootstrap handlers** to `local.go` + +```go +func HandleBootstrapStatus(c *gin.Context) { + n, err := services.CountUsers() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"needs_setup": n == 0}) +} + +func HandleBootstrap(c *gin.Context) { + n, err := services.CountUsers() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if n > 0 { + c.JSON(http.StatusConflict, gin.H{"error": "setup already complete"}) + return + } + var body struct { + OrgName string `json:"org_name"` + Email string `json:"email"` + Password string `json:"password"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.OrgName == "" || body.Email == "" || len(body.Password) < 8 { + c.JSON(http.StatusBadRequest, gin.H{"error": "org_name, email, and password (>=8 chars) required"}) + return + } + org, err := services.CreateOrg(body.OrgName) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + u, err := services.CreateUser(org.OrgID, body.Email, body.Password, "owner", "local") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + sessionID, err := SaveSession(c.Request.Context(), &Session{ + UserID: u.UserID, OrgID: u.OrgID, Role: u.Role, Email: u.Email, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"}) + return + } + SetSessionCookie(c, sessionID) + c.JSON(http.StatusCreated, gin.H{"org": org, "slug": org.Slug}) +} +``` + +- [ ] **Step 4: Rewrite `HandleMe`** (move out of `oidc.go` into `local.go`; delete the old one in `oidc.go`) so it returns the session + org: + +```go +func HandleMe(c *gin.Context) { + cookie, err := c.Request.Cookie(sessionCookieName) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"}) + return + } + sess, err := GetSession(c.Request.Context(), cookie.Value) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "session expired"}) + return + } + org, _ := services.GetOrg(sess.OrgID) + c.JSON(http.StatusOK, gin.H{"user": sess, "org": org}) +} +``` + +- [ ] **Step 5: Delete the old `HandleMe` from `oidc.go`** and the now-duplicated cookie block (Task 7 rewrites OIDC fully; for now just make it use `SetSessionCookie`). Verify build. + +Run: `cd server && go build ./...` +Expected: compiles (no duplicate `HandleMe`). + +- [ ] **Step 6: Commit** + +```bash +git add server/internal/auth +git commit -m "feat(auth): local email/password login + first-run bootstrap" +``` + +--- + +### Task 6: Host-based org resolution middleware + +**Files:** +- Create: `server/internal/auth/orghost.go` +- Modify: `server/internal/auth/middleware.go` (in `Middleware`, after loading session, reject when host org ≠ session org) + +**Interfaces:** +- Produces: `auth.OrgFromHost(c *gin.Context) (*models.Org, bool)` (parses subdomain label, looks up `services.GetOrgBySlug`, cached). `Middleware` now enforces host/session org match when a subdomain org is present. +- Consumes: `services.GetOrgBySlug`. + +- [ ] **Step 1: Create `orghost.go`** with a slug→org cache and host parsing + +```go +package auth + +import ( + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/server/internal/models" + "github.com/mrhid6/vantage/server/internal/services" +) + +type cachedOrg struct { + org *models.Org + at time.Time +} + +var ( + orgCacheMu sync.Mutex + orgCache = map[string]cachedOrg{} +) + +const orgCacheTTL = 60 * time.Second + +// hostSlug extracts the leftmost DNS label if the host is a subdomain of the +// app root. Returns "" for the apex or an unknown host shape. +func hostSlug(host string) string { + host = strings.ToLower(host) + if i := strings.IndexByte(host, ':'); i >= 0 { + host = host[:i] + } + // Expect .vantage.<...>; apex is vantage.<...> + parts := strings.Split(host, ".") + if len(parts) < 3 { + return "" + } + if parts[1] != "vantage" { + return "" + } + if parts[0] == "vantage" || parts[0] == "www" { + return "" + } + return parts[0] +} + +func OrgFromHost(c *gin.Context) (*models.Org, bool) { + slug := hostSlug(c.Request.Host) + if slug == "" { + return nil, false + } + orgCacheMu.Lock() + if e, ok := orgCache[slug]; ok && time.Since(e.at) < orgCacheTTL { + orgCacheMu.Unlock() + return e.org, e.org != nil + } + orgCacheMu.Unlock() + + org, err := services.GetOrgBySlug(slug) + if err != nil { + org = nil + } + orgCacheMu.Lock() + orgCache[slug] = cachedOrg{org: org, at: time.Now()} + orgCacheMu.Unlock() + return org, org != nil +} +``` + +- [ ] **Step 2: Enforce host/session match in `Middleware`** — replace the body after `c.Set(ctxSessionKey, sess)` so a mismatched subdomain is rejected (defense in depth; session stays the authority): + +```go + c.Set(ctxSessionKey, sess) + + if hostOrg, ok := OrgFromHost(c); ok && hostOrg.OrgID != sess.OrgID { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "org host mismatch"}) + return + } + + c.Next() +``` + +- [ ] **Step 3: Verify build** + +Run: `cd server && go build ./...` +Expected: compiles. + +- [ ] **Step 4: Commit** + +```bash +git add server/internal/auth +git commit -m "feat(auth): host-based org resolution + session/host match guard" +``` + +--- + +### Task 7: Per-org OIDC resolver (replace global OIDC) + +**Files:** +- Rewrite: `server/internal/auth/oidc.go` +- Create: `server/internal/services/org_oidc.go` (config CRUD, secret encrypt/decrypt) + +**Interfaces:** +- Produces: + - `services.GetOrgOIDC(orgID string) (*models.OrgOIDC, error)`, `services.SaveOrgOIDC(orgID, issuer, clientID, clientSecret string, enabled bool) error` (encrypts secret; empty clientSecret keeps existing), `services.GetOrgOIDCSecret(orgID string) (string, error)`. + - `auth.HandleOIDCStart(c)` (org from host), `auth.HandleOIDCCallback(c)` (state carries org_id; provision/login within org). Redirect URL derived from `https:///auth/oidc/callback`. +- Consumes: `SaveState`/`ConsumeState` extended to carry org_id (store `org_id` as the Redis value instead of `"1"`). + +- [ ] **Step 1: Extend state storage** in `session.go` to carry org_id + +```go +func SaveStateOrg(ctx context.Context, state, orgID string) error { + return rdb.Set(ctx, statePrefix+state, orgID, 10*time.Minute).Err() +} + +func ConsumeStateOrg(ctx context.Context, state string) (string, bool) { + orgID, err := rdb.GetDel(ctx, statePrefix+state).Result() + if err != nil || orgID == "" { + return "", false + } + return orgID, true +} +``` + +- [ ] **Step 2: Create `services/org_oidc.go`** + +```go +package services + +import ( + "context" + "time" + + "github.com/mrhid6/vantage/server/internal/db" + "github.com/mrhid6/vantage/server/internal/models" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +func GetOrgOIDC(orgID string) (*models.OrgOIDC, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var o models.OrgOIDC + err := db.Col("org_oidc").FindOne(ctx, bson.M{"org_id": orgID}).Decode(&o) + if err != nil { + return nil, err + } + return &o, nil +} + +func GetOrgOIDCSecret(orgID string) (string, error) { + o, err := GetOrgOIDC(orgID) + if err != nil { + return "", err + } + return decryptString(o.ClientSecretEnc) +} + +// SaveOrgOIDC upserts the org's provider config. An empty clientSecret keeps the +// stored secret (so the UI need not resend it). +func SaveOrgOIDC(orgID, issuer, clientID, clientSecret string, enabled bool) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + set := bson.M{ + "org_id": orgID, "issuer": issuer, "client_id": clientID, + "enabled": enabled, "updated_at": time.Now(), + } + if clientSecret != "" { + enc, err := encryptString(clientSecret) + if err != nil { + return err + } + set["client_secret_enc"] = enc + } + _, err := db.Col("org_oidc").UpdateOne(ctx, + bson.M{"org_id": orgID}, bson.M{"$set": set}, + options.UpdateOne().SetUpsert(true)) + return err +} +``` + +- [ ] **Step 3: Rewrite `oidc.go`** — drop global `InitOIDC`/`authEnabled`; build provider per org on demand with a cache + +```go +package auth + +import ( + "context" + "fmt" + "net/http" + "strings" + "sync" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/server/internal/services" + "golang.org/x/oauth2" +) + +type orgProvider struct { + provider *oidc.Provider + oauth *oauth2.Config +} + +var ( + provMu sync.Mutex + provCache = map[string]*orgProvider{} +) + +func redirectURL(c *gin.Context) string { + scheme := "https" + if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" { + scheme = "http" + } + return fmt.Sprintf("%s://%s/auth/oidc/callback", scheme, c.Request.Host) +} + +func providerForOrg(ctx context.Context, c *gin.Context, orgID string) (*orgProvider, error) { + cfg, err := services.GetOrgOIDC(orgID) + if err != nil || !cfg.Enabled { + return nil, fmt.Errorf("org SSO not configured") + } + secret, err := services.GetOrgOIDCSecret(orgID) + if err != nil { + return nil, err + } + provMu.Lock() + op := provCache[orgID] + provMu.Unlock() + if op == nil || op.provider == nil { + p, err := oidc.NewProvider(ctx, cfg.Issuer) + if err != nil { + return nil, err + } + op = &orgProvider{provider: p} + provMu.Lock() + provCache[orgID] = op + provMu.Unlock() + } + op.oauth = &oauth2.Config{ + ClientID: cfg.ClientID, ClientSecret: secret, + RedirectURL: redirectURL(c), Endpoint: op.provider.Endpoint(), + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + } + return op, nil +} + +func HandleOIDCStart(c *gin.Context) { + org, ok := OrgFromHost(c) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "unknown organization host"}) + return + } + ctx := c.Request.Context() + op, err := providerForOrg(ctx, c, org.OrgID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + state, err := randomHex(16) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "state gen failed"}) + return + } + if err := SaveStateOrg(ctx, state, org.OrgID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "state save failed"}) + return + } + c.Redirect(http.StatusFound, op.oauth.AuthCodeURL(state)) +} + +func HandleOIDCCallback(c *gin.Context) { + ctx := c.Request.Context() + orgID, ok := ConsumeStateOrg(ctx, c.Query("state")) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state"}) + return + } + op, err := providerForOrg(ctx, c, orgID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + token, err := op.oauth.Exchange(ctx, c.Query("code")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "token exchange failed"}) + return + } + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + c.JSON(http.StatusInternalServerError, gin.H{"error": "missing id_token"}) + return + } + idToken, err := op.provider.Verifier(&oidc.Config{ClientID: op.oauth.ClientID}).Verify(ctx, rawIDToken) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "token verification failed"}) + return + } + var claims struct { + Email string `json:"email"` + Name string `json:"name"` + } + if err := idToken.Claims(&claims); err != nil || claims.Email == "" { + c.JSON(http.StatusInternalServerError, gin.H{"error": "claims extraction failed"}) + return + } + + email := strings.ToLower(claims.Email) + u, err := services.GetUserByEmail(email) + if err != nil { + // provision new member in THIS org + u, err = services.CreateUser(orgID, email, "", "member", "oidc") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "provisioning failed"}) + return + } + } else if u.OrgID != orgID { + c.JSON(http.StatusForbidden, gin.H{"error": "email belongs to a different organization"}) + return + } + + sessionID, err := SaveSession(ctx, &Session{ + UserID: u.UserID, OrgID: u.OrgID, Role: u.Role, Email: u.Email, Name: claims.Name, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"}) + return + } + _ = services.TouchLastLogin(u.UserID) + SetSessionCookie(c, sessionID) + c.Redirect(http.StatusFound, "/") +} +``` + +- [ ] **Step 4: Remove `InitOIDC` call** from `main.go:49-51`. Verify build. + +Run: `cd server && go build ./...` +Expected: compiles; no references to `InitOIDC`, `authEnabled`, `oidcProvider`, `oauth2Cfg` remain. + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/auth server/internal/services/org_oidc.go server/cmd/main.go +git commit -m "feat(auth): per-org OIDC resolver replaces global provider" +``` + +--- + +### Task 8: Org-scope the service layer (servers, keys, assignments, sync, inventory) + +**Files:** +- Modify: `server/internal/services/servers.go`, `keys.go`, `sync.go`, `inventory.go` + +**Interfaces:** +- Produces (new signatures — every scoped read/write gains a leading `orgID string` param and adds `"org_id": orgID` to its filter; inserts set `OrgID`): + - `ListServers(orgID string)`, `GetServer(orgID, serverID string)`, `CreateServer(orgID string)`, `DeleteServer(orgID, serverID string)`, `StoreAvailableUpdates(orgID, serverID string, ...)`. + - Agent-path functions **keep their non-org signatures** but resolve/verify org internally: `RegisterServer`, `ValidateAgentToken` (returns `*models.Server`, caller reads `.OrgID`), `UpdateServerLastSeen`, `BackfillConsoleConfig`, `GetServerByPreRegToken`, `MarkOfflineServers` (unchanged — cross-org sweep). + - `keys.go`: `ListKeys(orgID)`, `GetKey(orgID, keyID)`, `CreateKey(orgID, label, publicKey, source, generatedBy, privateKey, passphrase string)`, `DeleteKey(orgID, keyID)`, `AssignKey(orgID, keyID, serverID)`, `RevokeAssignment(orgID, keyID, serverID)`, `GetAssignmentsWithKeysForServer(orgID, serverID)`, `GetAssignmentsWithServers(orgID, keyID)`, `GetPrivateKey(orgID, keyID)`. + - `sync.go`: `BuildDesiredState(serverID)` resolves org from the server record internally (agent path) — no signature change, but the assignment/key lookups it performs filter by that server's `org_id`. + +- [ ] **Step 1: `servers.go` — add org to admin-facing funcs.** For `CreateServer`, add `orgID` param and set `s.OrgID = orgID` before insert. For `GetServer`, `DeleteServer`, `ListServers`, `StoreAvailableUpdates`, add `orgID` param and add `"org_id": orgID` to every `bson.M{...}` filter. Example — `GetServer`: + +```go +func GetServer(orgID, serverID string) (*models.Server, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var s models.Server + err := db.Col("servers").FindOne(ctx, bson.M{"server_id": serverID, "org_id": orgID}).Decode(&s) + if err != nil { + return nil, err + } + return &s, nil +} +``` + +And `DeleteServer` also scopes the assignments cleanup: `bson.M{"server_id": serverID, "org_id": orgID}`. + +- [ ] **Step 2: `servers.go` — agent path unchanged in signature but org travels via the record.** `RegisterServer` sets no new org (server row already has `org_id` from `CreateServer`). `ValidateAgentToken` already returns `*models.Server` — callers get `.OrgID` from it. Leave `MarkOfflineServers` cross-org (it sweeps all orgs; audit/alerts already per-server). + +- [ ] **Step 3: `keys.go` — add `orgID` to every listed func**, adding `"org_id": orgID` to filters and setting `OrgID` on the `CreateKey`/`AssignKey` inserts. For `AssignKey`, also verify the target server is in the same org before inserting the assignment: + +```go +func AssignKey(orgID, keyID, serverID string) (*models.Assignment, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + // ensure server belongs to org + if err := db.Col("servers").FindOne(ctx, bson.M{"server_id": serverID, "org_id": orgID}).Err(); err != nil { + return nil, fmt.Errorf("server not found in organization") + } + // ... existing upsert logic, adding org_id to filter + insert ... +} +``` + +- [ ] **Step 4: `sync.go` — resolve org from the server record.** `BuildDesiredState(serverID)` first loads the server (`GetServerByID` internal, no org filter — this is the agent path keyed by unique `server_id`), then filters assignments/keys by that server's `org_id`. Add `"org_id": srv.OrgID` to the assignment and key queries. + +- [ ] **Step 5: `inventory.go` — scope the read.** If inventory has an admin-facing getter, add `orgID` and filter; the agent write path keys by `server_id` (unique) and needs no org param. + +- [ ] **Step 6: Verify build** (handlers not yet updated — expect compile errors ONLY in `api/*.go`; services + this file must be internally consistent). Build just services: + +Run: `cd server && go build ./internal/services/... ./internal/models/... ./internal/auth/...` +Expected: services/models/auth compile. (api package updated in Task 9.) + +- [ ] **Step 7: Commit** + +```bash +git add server/internal/services +git commit -m "feat(services): org-scope servers/keys/assignments/sync/inventory" +``` + +--- + +### Task 9: Org-scope remaining services + all handlers + admin routes + +**Files:** +- Modify: `server/internal/services/secrets.go`, `workflows.go`, `workflow_runner.go`, `monitors.go`, `channels.go`, `audit.go`, `console.go`, `steplogs.go`, `stepresults.go`, `defaults.go` +- Modify: `server/internal/api/handlers.go`, `secrets.go`, `workflows.go`, `monitors.go`, `channels.go`, `console.go`, `install_ps1.go` +- Create: `server/internal/api/org.go` (org user mgmt + org OIDC endpoints) +- Modify: `server/internal/auth/middleware.go` (add `RequireRole`) + +**Interfaces:** +- Produces: same org-scoping rule for every scoped read/write in the remaining service files (leading `orgID string`, `"org_id": orgID` in filters, set on insert). `LogEvent`/`ListAuditEvents` gain `orgID`. Handlers pass `auth.OrgID(c)`. `auth.RequireRole(roles ...string) gin.HandlerFunc`. New endpoints under `/api/org`. +- **Console/step services that run inside a workflow run** resolve org from the parent run/server record, not a handler. + +- [ ] **Step 1: Scope remaining service files** using the identical pattern from Task 8: add `orgID` param to every admin-facing function, add `"org_id": orgID` to filters, set `OrgID` on inserts. Audit: `LogEvent(orgID, eventType, actor, serverID, keyID, message string)` and `ListAuditEvents(orgID string, limit int64)`. Workflow-runner internal calls resolve org from the run document. + +- [ ] **Step 2: Add `RequireRole` to `middleware.go`** + +```go +func RequireRole(roles ...string) gin.HandlerFunc { + return func(c *gin.Context) { + r := Role(c) + for _, want := range roles { + if r == want { + c.Next() + return + } + } + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "insufficient role"}) + } +} +``` + +- [ ] **Step 3: Create `api/org.go`** — user management + OIDC config handlers + +```go +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/server/internal/auth" + "github.com/mrhid6/vantage/server/internal/services" +) + +func listOrgUsers(c *gin.Context) { + users, err := services.ListUsers(auth.OrgID(c)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, users) +} + +func createOrgUser(c *gin.Context) { + var body struct { + Email string `json:"email"` + Password string `json:"password"` + Role string `json:"role"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.Email == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "email required"}) + return + } + if body.Role == "" { + body.Role = "member" + } + u, err := services.CreateUser(auth.OrgID(c), body.Email, body.Password, body.Role, "local") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, u) +} + +func updateOrgUserRole(c *gin.Context) { + var body struct { + Role string `json:"role"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.Role == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "role required"}) + return + } + if err := services.UpdateUserRole(auth.OrgID(c), c.Param("id"), body.Role); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func deleteOrgUser(c *gin.Context) { + if err := services.DeleteUser(auth.OrgID(c), c.Param("id")); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +func getOrgOIDC(c *gin.Context) { + cfg, err := services.GetOrgOIDC(auth.OrgID(c)) + if err != nil { + c.JSON(http.StatusOK, gin.H{"enabled": false}) + return + } + c.JSON(http.StatusOK, cfg) +} + +func putOrgOIDC(c *gin.Context) { + var body struct { + Issuer string `json:"issuer"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + Enabled bool `json:"enabled"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := services.SaveOrgOIDC(auth.OrgID(c), body.Issuer, body.ClientID, body.ClientSecret, body.Enabled); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"saved": true}) +} +``` + +- [ ] **Step 4: Update `handlers.go` route registration.** Replace the auth-endpoint block and add org routes: + +```go + // Unauthenticated auth endpoints + r.GET("/auth/bootstrap-status", auth.HandleBootstrapStatus) + r.POST("/auth/bootstrap", auth.HandleBootstrap) + r.POST("/auth/login", auth.HandleLocalLogin) + r.POST("/auth/logout", auth.HandleLogout) + r.GET("/auth/me", auth.HandleMe) + r.GET("/auth/oidc/start", auth.HandleOIDCStart) + r.GET("/auth/oidc/callback", auth.HandleOIDCCallback) +``` + +And inside the `apiGroup`: + +```go + org := apiGroup.Group("/org") + org.Use(auth.RequireRole("owner", "admin")) + { + org.GET("/users", listOrgUsers) + org.POST("/users", createOrgUser) + org.PUT("/users/:id/role", updateOrgUserRole) + org.DELETE("/users/:id", deleteOrgUser) + org.GET("/oidc", getOrgOIDC) + org.PUT("/oidc", putOrgOIDC) + } +``` + +- [ ] **Step 5: Thread `auth.OrgID(c)` through every existing handler** in `handlers.go`, `secrets.go`, `workflows.go`, `monitors.go`, `channels.go`, `console.go` — pass it as the new leading arg to the scoped service calls (e.g. `services.ListServers(auth.OrgID(c))`, `services.GetServer(auth.OrgID(c), id)`, `services.CreateKey(auth.OrgID(c), ...)`, `services.LogEvent(auth.OrgID(c), ...)`). `actorFromCtx` unchanged. + +- [ ] **Step 6: Verify full build** + +Run: `cd server && go build ./...` +Expected: whole server compiles. + +- [ ] **Step 7: Commit** + +```bash +git add server/internal +git commit -m "feat(server): org-scope remaining services + handlers + org admin API" +``` + +--- + +### Task 10: gRPC — resolve org from server record; install script + +**Files:** +- Modify: `server/internal/grpc/server.go` +- Modify: `server/internal/api/handlers.go` (install script gRPC host unchanged — confirm single `GRPC_HOST`) + +**Interfaces:** +- Consumes: `services.ValidateAgentToken` (returns `*models.Server` with `.OrgID`), `services.BuildDesiredState(serverID)`, `services.RegisterServer`, `services.UploadGeneratedKey` (add `orgID` internally resolved from the server). + +- [ ] **Step 1: In each gRPC handler** (`SyncKeys`, `UploadGeneratedKey`, inventory RPCs), after validating the agent token, use `srv.OrgID` when writing org-scoped documents (e.g. an uploaded generated key gets `srv.OrgID`). `Register` needs no change (row pre-exists with org). Sync already resolves org via `BuildDesiredState`. + +- [ ] **Step 2: Confirm install script uses single `GRPC_HOST`** — no per-org host. No change expected; verify `handleInstallScript` still emits `grpc-vantage...` from `GRPC_HOST`. + +- [ ] **Step 3: Verify build** + +Run: `cd server && go build ./...` +Expected: compiles. + +- [ ] **Step 4: Commit** + +```bash +git add server/internal/grpc +git commit -m "feat(grpc): resolve org from server record for agent RPCs" +``` + +--- + +### Task 11: Frontend — login, setup, org settings, AuthProvider + +**Files:** +- Create: `web/app/login/page.tsx`, `web/app/setup/page.tsx`, `web/app/settings/org/page.tsx` +- Modify: `web/components/AuthProvider.tsx`, `web/components/Sidebar.tsx` + +**Interfaces:** +- Consumes REST: `GET /auth/bootstrap-status`, `POST /auth/bootstrap`, `POST /auth/login`, `GET /auth/me` (now `{user, org}`), `GET/POST/PUT/DELETE /api/org/users`, `GET/PUT /api/org/oidc`, `GET /auth/oidc/start`. + +- [ ] **Step 1: Rewrite `AuthProvider.tsx`** — on mount, call `/auth/bootstrap-status`; if `needs_setup` → redirect `/setup`. Else `/auth/me`; 401 → redirect `/login`. Store `{user, org}` in context. Update the `User`/context types to include `org_id`, `role`, and `org`. + +```tsx +"use client"; + +import { createContext, useContext, useEffect, useState, ReactNode } from "react"; + +export interface SessionUser { + user_id: string; + org_id: string; + role: string; + email: string; + name: string; +} +export interface Org { org_id: string; name: string; slug: string; } + +interface AuthContextType { user: SessionUser | null; org: Org | null; } +const AuthContext = createContext({ user: null, org: null }); +export function useAuth() { return useContext(AuthContext); } + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [org, setOrg] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const path = window.location.pathname; + if (path === "/login" || path === "/setup") { setLoading(false); return; } + fetch("/auth/bootstrap-status", { credentials: "include" }) + .then((r) => r.json()) + .then((b) => { + if (b.needs_setup) { window.location.href = "/setup"; return Promise.reject("setup"); } + return fetch("/auth/me", { credentials: "include" }); + }) + .then(async (res) => { + if (!res) return; + if (res.status === 401) { window.location.href = "/login"; return; } + const data = await res.json(); + setUser(data.user); setOrg(data.org); setLoading(false); + }) + .catch(() => setLoading(false)); + }, []); + + if (loading) { + return ( +
+
+
+ ); + } + return {children}; +} +``` + +- [ ] **Step 2: Create `web/app/login/page.tsx`** — email/password form POSTing `/auth/login` (on success `window.location.href = "/"`), plus a "Sign in with your organization's SSO" button that sends the browser to `/auth/oidc/start` (org comes from the current subdomain host). + +- [ ] **Step 3: Create `web/app/setup/page.tsx`** — first-run form (org name, owner email, password) POSTing `/auth/bootstrap`; on success redirect to `https://.vantage.hostxtra.co.uk/` (use returned `slug`) or `/` in dev. + +- [ ] **Step 4: Create `web/app/settings/org/page.tsx`** — members table (list/create/role/delete via `/api/org/users`), and OIDC provider form (`/api/org/oidc`) showing the exact redirect URL `https:///auth/oidc/callback` for the admin to register. Guard UI by `role` from `useAuth()`. + +- [ ] **Step 5: Update `Sidebar.tsx`** — show current org name + user email; add an "Organization" settings link (visible to owner/admin). + +- [ ] **Step 6: Verify build** + +Run: `cd web && npm run build` +Expected: builds without type errors. + +- [ ] **Step 7: Commit** + +```bash +git add web +git commit -m "feat(web): login, first-run setup, org settings; org-aware AuthProvider" +``` + +--- + +### Task 12: Manual end-to-end verification + +**Files:** none (verification only). + +- [ ] **Step 1: Build everything** + +Run: `cd server && go build ./... && cd ../web && npm run build` +Expected: both succeed. + +- [ ] **Step 2: Document the manual smoke checklist** in the PR description (no automated tests per constraints): fresh DB → `/setup` creates org+owner → login → each existing page (servers/keys/secrets/workflows/monitors) loads scoped data → create a second org via a second owner (separate email) and confirm data isolation → configure org OIDC and confirm `/auth/oidc/start` redirects. Migration: point at a DB with legacy data, confirm default-org backfill and marker. + +- [ ] **Step 3: Final commit (if any doc edits)** + +```bash +git add -A +git commit -m "docs: manual verification checklist for saas auth" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** local auth (T4-5), orgs+slug (T4), per-org OIDC (T7), org_id on all collections (T1), request scoping (T8-9), remove global Authentik (T7), migration (T2), host resolution (T6), gRPC org (T10), frontend (T11), roles/RequireRole (T9). Billing/tests correctly omitted. +- **Deferred/immutable:** slug rename deferred (spec); `redirect_url` derived from host, not stored (Task 7 matches updated spec §3/§4). +- **Ordering risk:** Task 8 leaves `api/*` temporarily non-compiling; that's why Task 8 builds only sub-packages and Task 9 restores a full build. Reviewer should treat T8+T9 as a pair. +- **Cross-org guard:** enforced in service filters (primary) + host/session match (T6, defense-in-depth) + OIDC org-binding via state (T7).