diff --git a/docs/superpowers/plans/2026-07-20-saas-auth-orgs.md b/docs/superpowers/plans/2026-07-20-saas-auth-orgs.md deleted file mode 100644 index a8a4203..0000000 --- a/docs/superpowers/plans/2026-07-20-saas-auth-orgs.md +++ /dev/null @@ -1,1174 +0,0 @@ -# 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:** Replace the global Authentik OIDC with local email/password accounts, introduce organizations that scope every domain object via `org_id`, and let org admins configure their own per-org OpenID provider. - -**Architecture:** Local accounts (bcrypt) are primary auth; sessions carry `{user_id, org_id, role, email}`. A per-org OIDC resolver builds providers on demand from an `org_oidc` collection. Every scoped service function takes an `orgID` and filters on it; handlers derive `orgID` from the session (never from the client). A one-shot idempotent migration backfills existing data into a "Default" org. - -**Tech Stack:** Go (gin, mongo-driver v2, Redis sessions, `go-oidc`/`oauth2`, `golang.org/x/crypto/bcrypt`), Next.js 16 + react-query + Tailwind. - -## Global Constraints - -- **No tests this iteration.** Verify with `go build ./...`, `go vet ./...`, `npm run build`. -- **Org isolation is a security boundary:** handlers MUST derive `org_id` from the session via `auth.OrgID(c)`; never accept it in a request body/query. Every scoped Mongo query includes `"org_id": orgID` in its filter and on insert. -- Reuse `server/internal/services/crypto.go` (`encryptString`/`decryptString`) for the org OIDC client secret. -- bcrypt cost ≥ 12. Passwords never serialized to JSON (`json:"-"`). -- Module path `github.com/mrhid6/vantage`. Sessions live in Redis (`server/internal/auth/session.go`). -- Scoped collections: `servers`, `keys`, `assignments`, `secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `audit`. -- Frontend: `@/lib/api`, `@/components/ui`, Tailwind tokens, react-query. Follow `web/app/secrets/page.tsx` conventions. - ---- - -## Task 1: Org + user + org-OIDC models - -**Files:** -- Create: `server/internal/models/org.go` - -**Interfaces:** -- Produces: `models.Org`, `models.User`, `models.OrgOIDC`. - -- [ ] **Step 1: Write models** - -```go -package models - -import "time" - -type Org struct { - ID string `bson:"_id,omitempty" json:"-"` - OrgID string `bson:"org_id" json:"org_id"` - Name string `bson:"name" json:"name"` - CreatedAt time.Time `bson:"created_at" json:"created_at"` -} - -type User struct { - ID string `bson:"_id,omitempty" json:"-"` - 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"` -} - -type OrgOIDC struct { - ID string `bson:"_id,omitempty" json:"-"` - 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" json:"-"` - RedirectURL string `bson:"redirect_url" json:"redirect_url"` - Enabled bool `bson:"enabled" json:"enabled"` - UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` -} -``` - -- [ ] **Step 2: Verify build** - -Run: `cd server && go build ./...` -Expected: success. - -- [ ] **Step 3: Commit** - -```bash -git add server/internal/models/org.go -git commit -m "feat(models): org, user, org-oidc models" -``` - ---- - -## Task 2: Org + user services (bcrypt) - -**Files:** -- Create: `server/internal/services/orgs.go` -- Create: `server/internal/services/users.go` - -**Interfaces:** -- Produces: - - orgs: `EnsureAuthIndexes() error`, `CreateOrg(name string) (*models.Org, error)`, `GetOrg(orgID string) (*models.Org, error)`, `CountUsers() (int64, error)` - - users: `CreateUser(orgID, email, password, role string) (*models.User, error)`, `GetUserByEmail(email string) (*models.User, error)`, `GetUserByID(userID string) (*models.User, error)`, `VerifyPassword(u *models.User, password string) bool`, `ListUsers(orgID string) ([]models.User, error)`, `UpdateUserRole(orgID, userID, role string) error`, `DeleteUser(orgID, userID string) error`, `ProvisionOIDCUser(orgID, email string) (*models.User, error)`, `TouchLastLogin(userID string)`. - -- [ ] **Step 1: Write orgs.go** - -```go -package services - -import ( - "context" - "fmt" - "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" -) - -func authCtx() (context.Context, context.CancelFunc) { - return context.WithTimeout(context.Background(), 10*time.Second) -} - -func EnsureAuthIndexes() error { - ctx, cancel := authCtx() - 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: "org_id", Value: 1}}, Options: options.Index().SetUnique(true), - }); err != nil { - return err - } - _, err := db.Col("org_oidc").Indexes().CreateOne(ctx, mongo.IndexModel{ - Keys: bson.D{{Key: "org_id", Value: 1}}, Options: options.Index().SetUnique(true), - }) - return err -} - -func CreateOrg(name string) (*models.Org, error) { - ctx, cancel := authCtx() - defer cancel() - o := models.Org{OrgID: uuid.New().String(), Name: name, CreatedAt: time.Now()} - if _, err := db.Col("orgs").InsertOne(ctx, o); err != nil { - return nil, err - } - return &o, nil -} - -func GetOrg(orgID string) (*models.Org, error) { - ctx, cancel := authCtx() - defer cancel() - var o models.Org - err := db.Col("orgs").FindOne(ctx, bson.M{"org_id": orgID}).Decode(&o) - if err == mongo.ErrNoDocuments { - return nil, fmt.Errorf("org not found") - } - return &o, err -} - -func CountUsers() (int64, error) { - ctx, cancel := authCtx() - defer cancel() - return db.Col("users").CountDocuments(ctx, bson.M{}) -} -``` - -- [ ] **Step 2: Write users.go** - -```go -package services - -import ( - "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" - "go.mongodb.org/mongo-driver/v2/mongo/options" - "golang.org/x/crypto/bcrypt" -) - -func normalizeEmail(e string) string { return strings.ToLower(strings.TrimSpace(e)) } - -func CreateUser(orgID, email, password, role string) (*models.User, error) { - ctx, cancel := authCtx() - defer cancel() - u := models.User{ - UserID: uuid.New().String(), OrgID: orgID, Email: normalizeEmail(email), - Role: role, AuthSource: "local", CreatedAt: time.Now(), - } - if password != "" { - h, err := bcrypt.GenerateFromPassword([]byte(password), 12) - if err != nil { - return nil, err - } - u.PasswordHash = string(h) - } - if _, err := db.Col("users").InsertOne(ctx, u); err != nil { - return nil, err - } - return &u, nil -} - -func GetUserByEmail(email string) (*models.User, error) { - ctx, cancel := authCtx() - defer cancel() - var u models.User - err := db.Col("users").FindOne(ctx, bson.M{"email": normalizeEmail(email)}).Decode(&u) - if err == mongo.ErrNoDocuments { - return nil, fmt.Errorf("user not found") - } - return &u, err -} - -func GetUserByID(userID string) (*models.User, error) { - ctx, cancel := authCtx() - defer cancel() - var u models.User - err := db.Col("users").FindOne(ctx, bson.M{"user_id": userID}).Decode(&u) - if err == mongo.ErrNoDocuments { - return nil, fmt.Errorf("user not found") - } - return &u, err -} - -func VerifyPassword(u *models.User, password string) bool { - if u.PasswordHash == "" { - return false - } - return bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil -} - -func ListUsers(orgID string) ([]models.User, error) { - ctx, cancel := authCtx() - defer cancel() - cur, err := db.Col("users").Find(ctx, bson.M{"org_id": orgID}, - options.Find().SetSort(bson.D{{Key: "email", Value: 1}})) - if err != nil { - return nil, err - } - defer cur.Close(ctx) - users := []models.User{} - if err := cur.All(ctx, &users); err != nil { - return nil, err - } - return users, nil -} - -func UpdateUserRole(orgID, userID, role string) error { - ctx, cancel := authCtx() - defer cancel() - _, err := db.Col("users").UpdateOne(ctx, bson.M{"org_id": orgID, "user_id": userID}, - bson.M{"$set": bson.M{"role": role}}) - return err -} - -func DeleteUser(orgID, userID string) error { - ctx, cancel := authCtx() - defer cancel() - _, err := db.Col("users").DeleteOne(ctx, bson.M{"org_id": orgID, "user_id": userID}) - return err -} - -func ProvisionOIDCUser(orgID, email string) (*models.User, error) { - if existing, err := GetUserByEmail(email); err == nil { - if existing.OrgID != orgID { - return nil, fmt.Errorf("email belongs to another organization") - } - return existing, nil - } - ctx, cancel := authCtx() - defer cancel() - u := models.User{ - UserID: uuid.New().String(), OrgID: orgID, Email: normalizeEmail(email), - Role: "member", AuthSource: "oidc", CreatedAt: time.Now(), - } - if _, err := db.Col("users").InsertOne(ctx, u); err != nil { - return nil, err - } - return &u, nil -} - -func TouchLastLogin(userID string) { - ctx, cancel := authCtx() - defer cancel() - now := time.Now() - _, _ = db.Col("users").UpdateOne(ctx, bson.M{"user_id": userID}, bson.M{"$set": bson.M{"last_login": now}}) -} -``` - -- [ ] **Step 3: Add bcrypt dependency** - -Run: `cd server && go get golang.org/x/crypto/bcrypt && go mod tidy` -Expected: module added. - -- [ ] **Step 4: Register indexes at startup** - -In `server/cmd/main.go`, call `services.EnsureAuthIndexes()` next to the other `Ensure*Indexes()` calls, with the same error handling. - -- [ ] **Step 5: Verify build** - -Run: `cd server && go build ./... && go vet ./...` -Expected: success. - -- [ ] **Step 6: Commit** - -```bash -git add server/internal/services/orgs.go server/internal/services/users.go server/cmd/main.go server/go.mod server/go.sum -git commit -m "feat(server): org and user services with bcrypt auth" -``` - ---- - -## Task 3: Session carries user/org/role - -**Files:** -- Modify: `server/internal/auth/session.go` -- Modify: `server/internal/auth/middleware.go` - -**Interfaces:** -- Produces: `Session{UserID, OrgID, Email, Role}`; `auth.OrgID(c) string`, `auth.UserID(c) string`, `auth.Role(c) string`; `auth.CreateSession(ctx, *Session) (token string, err error)` (or extend the existing creator). - -- [ ] **Step 1: Read the current Session struct** - -Open `server/internal/auth/session.go`, find the `Session` struct and `GetSession`/save functions. Extend `Session`: - -```go -type Session struct { - UserID string `json:"user_id"` - OrgID string `json:"org_id"` - Email string `json:"email"` - Role string `json:"role"` -} -``` - -Keep existing fields if any are still used; add these. Ensure the create/save path (currently used by the OIDC callback) accepts a full `*Session`. If the current signature is `SaveSession(ctx, token, email)`, add `CreateSession(ctx context.Context, s *Session) (string, error)` that generates a token (reuse `randomHex`), stores the JSON under `sessionPrefix+token` with `sessionTTL`, and returns the token. - -- [ ] **Step 2: Add context helpers to middleware.go** - -```go -func OrgID(c *gin.Context) string { - if s := GetSessionFromContext(c); s != nil { - return s.OrgID - } - return "" -} -func UserID(c *gin.Context) string { - if s := GetSessionFromContext(c); s != nil { - return s.UserID - } - return "" -} -func Role(c *gin.Context) string { - if s := GetSessionFromContext(c); s != nil { - return s.Role - } - return "" -} - -// RequireRole aborts unless the session role is in allowed. -func RequireRole(allowed ...string) gin.HandlerFunc { - return func(c *gin.Context) { - role := Role(c) - for _, a := range allowed { - if role == a { - c.Next() - return - } - } - c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"}) - } -} -``` - -- [ ] **Step 3: Verify build** - -Run: `cd server && go build ./...` -Expected: may fail where the old session creator is called (OIDC). That's fixed in Task 5. If the failure is only in `oidc.go`, proceed; otherwise fix references in session.go. - -- [ ] **Step 4: Commit** - -```bash -git add server/internal/auth/session.go server/internal/auth/middleware.go -git commit -m "feat(auth): session carries user/org/role + role middleware" -``` - ---- - -## Task 4: Local auth + bootstrap handlers - -**Files:** -- Create: `server/internal/auth/local.go` - -**Interfaces:** -- Consumes: user/org services (T2), session (T3). -- Produces: gin handlers `HandleLocalLogin`, `HandleLogout` (may reuse existing), `HandleMe`, `HandleBootstrapStatus`, `HandleBootstrap`. - -- [ ] **Step 1: Write local.go** - -```go -package auth - -import ( - "net/http" - "time" - - "github.com/gin-gonic/gin" - "github.com/mrhid6/vantage/server/internal/services" -) - -func setSessionCookie(c *gin.Context, token string) { - http.SetCookie(c.Writer, &http.Cookie{ - Name: sessionCookieName, Value: token, Path: "/", - HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, - Expires: time.Now().Add(sessionTTL), - }) -} - -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}) -} - -// HandleBootstrap creates the first org + owner. Only allowed when no users exist. -func HandleBootstrap(c *gin.Context) { - n, err := services.CountUsers() - if err != nil || n > 0 { - c.JSON(http.StatusForbidden, gin.H{"error": "setup already completed"}) - return - } - var body struct { - OrgName string `json:"org_name" binding:"required"` - Email string `json:"email" binding:"required"` - Password string `json:"password" binding:"required"` - } - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - org, err := services.CreateOrg(body.OrgName) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - user, err := services.CreateUser(org.OrgID, body.Email, body.Password, "owner") - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - token, err := CreateSession(c.Request.Context(), &Session{ - UserID: user.UserID, OrgID: org.OrgID, Email: user.Email, Role: user.Role, - }) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - setSessionCookie(c, token) - c.JSON(http.StatusCreated, gin.H{"org": org, "user": user}) -} - -func HandleLocalLogin(c *gin.Context) { - var body struct { - Email string `json:"email" binding:"required"` - Password string `json:"password" binding:"required"` - } - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - 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 - } - token, err := CreateSession(c.Request.Context(), &Session{ - UserID: u.UserID, OrgID: u.OrgID, Email: u.Email, Role: u.Role, - }) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - services.TouchLastLogin(u.UserID) - setSessionCookie(c, token) - c.JSON(http.StatusOK, gin.H{"user": u}) -} - -func HandleMe(c *gin.Context) { - s := GetSessionFromContext(c) - if s == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"}) - return - } - org, _ := services.GetOrg(s.OrgID) - c.JSON(http.StatusOK, gin.H{ - "user": gin.H{"user_id": s.UserID, "email": s.Email, "role": s.Role}, - "org": org, - }) -} -``` - -Note: if `HandleLogout` already exists in `oidc.go` and clears the cookie/session generically, reuse it; otherwise add one clearing `sessionCookieName` and deleting the Redis key. - -- [ ] **Step 2: Verify build** - -Run: `cd server && go build ./...` -Expected: success (aside from any not-yet-updated OIDC references from T3, addressed in T5). - -- [ ] **Step 3: Commit** - -```bash -git add server/internal/auth/local.go -git commit -m "feat(auth): local login, bootstrap, and me handlers" -``` - ---- - -## Task 5: Per-org OIDC resolver (replace global) - -**Files:** -- Modify: `server/internal/auth/oidc.go` -- Create: `server/internal/services/org_oidc.go` - -**Interfaces:** -- Consumes: `models.OrgOIDC`, crypto (`encryptString`/`decryptString`), session (T3), `ProvisionOIDCUser` (T2). -- Produces: `services.GetOrgOIDC(orgID) (*models.OrgOIDC, error)`, `services.SaveOrgOIDC(orgID string, in models.OrgOIDC, secret string) error`; handlers `HandleOIDCStart`, `HandleOIDCCallback`. - -- [ ] **Step 1: Write org_oidc.go service** - -```go -package services - -import ( - "context" - "fmt" - "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" - "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 == mongo.ErrNoDocuments { - return nil, fmt.Errorf("no oidc config") - } - return &o, err -} - -// GetOrgOIDCSecret returns the decrypted client secret. -func GetOrgOIDCSecret(o *models.OrgOIDC) (string, error) { - return decryptString(o.ClientSecretEnc) -} - -// SaveOrgOIDC upserts config; if secret != "" it is encrypted and stored. -func SaveOrgOIDC(orgID string, in models.OrgOIDC, secret string) error { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - set := bson.M{ - "issuer": in.Issuer, "client_id": in.ClientID, "redirect_url": in.RedirectURL, - "enabled": in.Enabled, "updated_at": time.Now(), - } - if secret != "" { - enc, err := encryptString(secret) - 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 2: Rewrite oidc.go around the per-org resolver** - -Replace the env-based `InitOIDC`/global `oidcProvider`/`oauth2Cfg`/`authEnabled` with an on-demand, per-org resolver. Keep `randomHex`, `SaveState`/state handling. Reference the current callback logic for the token-exchange + claims parsing, but bind to the org from state. - -```go -package auth - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "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 - cfg *oauth2.Config -} - -var ( - provCache = map[string]*orgProvider{} - provMu sync.Mutex -) - -func providerForOrg(ctx context.Context, orgID string) (*orgProvider, error) { - cfg, err := services.GetOrgOIDC(orgID) - if err != nil || !cfg.Enabled { - return nil, fmt.Errorf("org sso not configured") - } - provMu.Lock() - defer provMu.Unlock() - if p, ok := provCache[orgID]; ok { - return p, nil - } - provider, err := oidc.NewProvider(ctx, cfg.Issuer) - if err != nil { - return nil, err - } - secret, _ := services.GetOrgOIDCSecret(cfg) - op := &orgProvider{ - provider: provider, - cfg: &oauth2.Config{ - ClientID: cfg.ClientID, ClientSecret: secret, RedirectURL: cfg.RedirectURL, - Endpoint: provider.Endpoint(), Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, - }, - } - provCache[orgID] = op - return op, nil -} - -// InvalidateOrgProvider drops the cache after config changes. -func InvalidateOrgProvider(orgID string) { - provMu.Lock() - delete(provCache, orgID) - provMu.Unlock() -} - -// HandleOIDCStart resolves the org, stores state carrying org_id, redirects. -func HandleOIDCStart(c *gin.Context) { - orgID := c.Query("org") - if orgID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "org required"}) - return - } - op, err := providerForOrg(c.Request.Context(), orgID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - state, _ := randomHex(16) - // State value encodes org so the callback can rebuild the provider. - if err := SaveStateValue(c.Request.Context(), state, orgID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "state save failed"}) - return - } - c.Redirect(http.StatusFound, op.cfg.AuthCodeURL(state)) -} - -func HandleOIDCCallback(c *gin.Context) { - ctx := c.Request.Context() - state := c.Query("state") - orgID, err := ConsumeStateValue(ctx, state) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state"}) - return - } - op, err := providerForOrg(ctx, orgID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - oauth2Token, err := op.cfg.Exchange(ctx, c.Query("code")) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "token exchange failed"}) - return - } - rawID, _ := oauth2Token.Extra("id_token").(string) - idToken, err := op.provider.Verifier(&oidc.Config{ClientID: op.cfg.ClientID}).Verify(ctx, rawID) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "id token verify failed"}) - return - } - var claims struct { - Email string `json:"email"` - } - if err := idToken.Claims(&claims); err != nil || claims.Email == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "no email claim"}) - return - } - user, err := services.ProvisionOIDCUser(orgID, claims.Email) - if err != nil { - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) - return - } - token, err := CreateSession(ctx, &Session{UserID: user.UserID, OrgID: user.OrgID, Email: user.Email, Role: user.Role}) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - services.TouchLastLogin(user.UserID) - setSessionCookie(c, token) - c.Redirect(http.StatusFound, "/") - _ = json.Marshal // keep import if unused elsewhere; remove if lint complains -} -``` - -- [ ] **Step 3: Add state-with-value helpers** - -The current code has `SaveState(ctx, state)`. Add value-carrying variants in `session.go` (Redis, `statePrefix+state` → orgID, short TTL ~10 min): - -```go -func SaveStateValue(ctx context.Context, state, value string) error { - return rdb.Set(ctx, statePrefix+state, value, 10*time.Minute).Err() -} -func ConsumeStateValue(ctx context.Context, state string) (string, error) { - v, err := rdb.GetDel(ctx, statePrefix+state).Result() - if err != nil { - return "", err - } - return v, nil -} -``` - -Match the actual Redis client variable name used in `session.go` (shown as `rdb`). - -- [ ] **Step 4: Remove global-auth bypass** - -In `middleware.go`, delete the `if !authEnabled { c.Next(); return }` bypass so auth is always enforced (login/register/bootstrap routes stay outside the protected group — Task 7). Remove references to the deleted `authEnabled`/`InitOIDC` from `server/cmd/main.go` (drop the `InitOIDC` call). - -- [ ] **Step 5: Verify build** - -Run: `cd server && go build ./... && go vet ./...` -Expected: success. Resolve any leftover references to removed globals. - -- [ ] **Step 6: Commit** - -```bash -git add server/internal/auth/oidc.go server/internal/auth/session.go server/internal/auth/middleware.go server/internal/services/org_oidc.go server/cmd/main.go -git commit -m "feat(auth): per-org OIDC resolver, remove global Authentik" -``` - ---- - -## Task 6: Org-scope existing services - -**Files:** -- Modify: `server/internal/services/servers.go`, `keys.go`, `secrets.go`, `sync.go`, `audit.go`, `workflows.go`, `workflow_runner.go` (workflows only if the Workflows plan has already been executed — otherwise skip those two and note it). - -**Interfaces:** -- Produces: each scoped read/list/create/get/delete function gains a leading `orgID string` param and includes `"org_id": orgID` in filters and inserts. - -This is the isolation boundary. Work one collection at a time. - -- [ ] **Step 1: Scope `servers.go`** - -Add `org_id` to inserts in `CreateServer` (accept `orgID string`), and `"org_id": orgID` to filters in `ListServers`, `GetServer`, `DeleteServer`, `GetAssignmentsWithKeysForServer`, etc. Example transform: - -```go -func ListServers(orgID string) ([]models.Server, error) { - // ... - cur, err := db.Col("servers").Find(ctx, bson.M{"org_id": orgID}, /* sort */) - // ... -} -``` - -For agent-facing lookups keyed by `server_id` (e.g. `ValidateAgentToken`, `UpdateServerLastSeen`): these do NOT get an `orgID` param — they resolve by `server_id` alone. Instead, ensure `CreateServer` stamps `org_id` so the server record carries it; downstream org queries then work. - -- [ ] **Step 2: Scope `keys.go`, `secrets.go`, `audit.go`, `sync.go`** - -Same treatment. `sync.go` builds desired state per server: it can read the server's own `org_id` (from the server doc) and query keys/assignments within that org. `LogEvent` gains an `orgID` first param and stamps `org_id` on the audit doc; `ListAuditEvents(orgID, limit)` filters by it. - -- [ ] **Step 3: Scope workflow services (only if Workflows plan already merged)** - -If `services/workflows.go` exists, add `orgID` to all list/get/create/update/delete and to run queries. If not yet implemented, add a note in the Workflows plan to include `org_id` when it is built, and skip here. - -- [ ] **Step 4: Verify build** - -Run: `cd server && go build ./...` -Expected: FAILS at call sites in `api/` (handlers not yet passing orgID). That is expected and fixed in Task 7. Confirm the only errors are missing-argument at handler call sites. - -- [ ] **Step 5: Commit** - -```bash -git add server/internal/services/ -git commit -m "feat(server): org-scope domain services" -``` - ---- - -## Task 7: Wire handlers + routes to org + auth - -**Files:** -- Modify: `server/internal/api/handlers.go` (+ `secrets.go`, `console.go`, `workflows.go` handlers as needed) - -**Interfaces:** -- Consumes: `auth.OrgID(c)`, scoped services (T6), local/oidc/bootstrap handlers (T4, T5). - -- [ ] **Step 1: Update route registration** - -In `RegisterRoutes`, replace the auth endpoints block: - -```go - // Unauthenticated auth endpoints - r.GET("/auth/bootstrap-status", auth.HandleBootstrapStatus) - r.POST("/auth/bootstrap", auth.HandleBootstrap) - r.POST("/auth/login", auth.HandleLocalLogin) - r.GET("/auth/logout", auth.HandleLogout) - r.GET("/auth/oidc/start", auth.HandleOIDCStart) - r.GET("/auth/oidc/callback", auth.HandleOIDCCallback) -``` - -Inside the session-protected `apiGroup`, add org-admin routes: - -```go - orgAdmin := apiGroup.Group("/org") - orgAdmin.GET("/users", listOrgUsers) - orgAdmin.POST("/users", auth.RequireRole("owner", "admin"), createOrgUser) - orgAdmin.PUT("/users/:id/role", auth.RequireRole("owner", "admin"), updateOrgUserRole) - orgAdmin.DELETE("/users/:id", auth.RequireRole("owner", "admin"), deleteOrgUser) - orgAdmin.GET("/oidc", auth.RequireRole("owner", "admin"), getOrgOIDC) - orgAdmin.PUT("/oidc", auth.RequireRole("owner", "admin"), putOrgOIDC) - apiGroup.GET("/me", auth.HandleMe) -``` - -- [ ] **Step 2: Pass `auth.OrgID(c)` into every scoped service call** - -Update each existing handler (`listServers`, `createServer`, `getServer`, `deleteServer`, `listKeys`, `createKey`, secrets handlers, `listAuditEvents`, etc.) to pass `auth.OrgID(c)` as the new first argument. Also update every `services.LogEvent(...)` call to pass `auth.OrgID(c)` first. Example: - -```go -func listServers(c *gin.Context) { - servers, err := services.ListServers(auth.OrgID(c)) - // ... -} -``` - -- [ ] **Step 3: Add org-user + org-oidc handlers** - -Create `server/internal/api/org.go`: - -```go -package api - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "github.com/mrhid6/vantage/server/internal/auth" - "github.com/mrhid6/vantage/server/internal/models" - "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" binding:"required"` - Password string `json:"password" binding:"required"` - Role string `json:"role"` - } - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if body.Role == "" { - body.Role = "member" - } - u, err := services.CreateUser(auth.OrgID(c), body.Email, body.Password, body.Role) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - services.LogEvent(auth.OrgID(c), "org.user_created", actorFromCtx(c), "", u.UserID, "user "+u.Email+" created") - c.JSON(http.StatusCreated, u) -} - -func updateOrgUserRole(c *gin.Context) { - var body struct{ Role string `json:"role" binding:"required"` } - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - 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{"updated": 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{}) // no config yet - return - } - c.JSON(http.StatusOK, cfg) -} - -func putOrgOIDC(c *gin.Context) { - var body struct { - Issuer string `json:"issuer"` - ClientID string `json:"client_id"` - Secret string `json:"client_secret"` - RedirectURL string `json:"redirect_url"` - Enabled bool `json:"enabled"` - } - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - orgID := auth.OrgID(c) - if err := services.SaveOrgOIDC(orgID, models.OrgOIDC{ - Issuer: body.Issuer, ClientID: body.ClientID, RedirectURL: body.RedirectURL, Enabled: body.Enabled, - }, body.Secret); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - auth.InvalidateOrgProvider(orgID) - c.JSON(http.StatusOK, gin.H{"saved": true}) -} -``` - -- [ ] **Step 4: Verify build** - -Run: `cd server && go build ./... && go vet ./...` -Expected: success once every scoped call passes `auth.OrgID(c)`. Fix remaining arity errors. - -- [ ] **Step 5: Commit** - -```bash -git add server/internal/api/ -git commit -m "feat(api): org-scoped handlers, org admin routes, auth wiring" -``` - ---- - -## Task 8: One-shot migration (backfill org_id) - -**Files:** -- Create: `server/internal/services/migrate.go` - -**Interfaces:** -- Produces: `MigrateToOrgs() error` — idempotent; run once at startup. - -- [ ] **Step 1: Write the migration** - -```go -package services - -import ( - "context" - "time" - - "github.com/mrhid6/vantage/server/internal/db" - "go.mongodb.org/mongo-driver/v2/bson" -) - -var scopedCollections = []string{ - "servers", "keys", "assignments", "secrets", - "workflows", "workflow_steps", "workflow_runs", "audit", -} - -// MigrateToOrgs backfills a default org onto legacy documents lacking org_id. -// Idempotent via a marker in the `migrations` collection. -func MigrateToOrgs() error { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - marker := db.Col("migrations").FindOne(ctx, bson.M{"_id": "orgs-backfill"}) - if marker.Err() == nil { - return nil // already done - } - - // Only create a default org if there is legacy data but no org yet. - orgCount, _ := db.Col("orgs").CountDocuments(ctx, bson.M{}) - if orgCount > 0 { - _, _ = db.Col("migrations").InsertOne(ctx, bson.M{"_id": "orgs-backfill", "at": time.Now()}) - return nil - } - - hasLegacy := false - for _, col := range scopedCollections { - n, _ := db.Col(col).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": false}}) - if n > 0 { - hasLegacy = true - break - } - } - if !hasLegacy { - _, _ = db.Col("migrations").InsertOne(ctx, bson.M{"_id": "orgs-backfill", "at": time.Now()}) - return nil - } - - org, err := CreateOrg("Default") - if 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": "orgs-backfill", "org_id": org.OrgID, "at": time.Now()}) - return err -} -``` - -- [ ] **Step 2: Call at startup** - -In `server/cmd/main.go`, after DB connect + `EnsureAuthIndexes()`, call `services.MigrateToOrgs()` with error logging. - -- [ ] **Step 3: Verify build** - -Run: `cd server && go build ./... && go vet ./...` -Expected: success. - -- [ ] **Step 4: Commit** - -```bash -git add server/internal/services/migrate.go server/cmd/main.go -git commit -m "feat(server): idempotent org_id backfill migration" -``` - ---- - -## Task 9: Frontend — login, setup, auth gating - -**Files:** -- Create: `web/app/login/page.tsx` -- Create: `web/app/setup/page.tsx` -- Modify: `web/lib/api.ts` (auth methods + types) -- Modify: `web/components/AuthProvider.tsx` (gate on `/auth/me`, redirect to `/login` or `/setup`) - -**Interfaces:** -- Consumes: `api.bootstrapStatus`, `api.bootstrap`, `api.login`, `api.me`, `api.oidcStartUrl`. - -- [ ] **Step 1: Add auth API methods/types** - -In `web/lib/api.ts`: - -```ts -export interface Me { user: { user_id: string; email: string; role: string }; org: { org_id: string; name: string } | null; } - -// add to api object: - bootstrapStatus: () => req<{ needs_setup: boolean }>("/auth/bootstrap-status"), - bootstrap: (org_name: string, email: string, password: string) => - req("/auth/bootstrap", { method: "POST", body: JSON.stringify({ org_name, email, password }) }), - login: (email: string, password: string) => - req("/auth/login", { method: "POST", body: JSON.stringify({ email, password }) }), - me: () => req("/api/me"), -``` - -- [ ] **Step 2: Setup page** - -`web/app/setup/page.tsx` — client form (org name, email, password) → `api.bootstrap` → on success `router.push("/")`. Use `inputClass` + `Button`/`Card` styling from secrets page. - -- [ ] **Step 3: Login page** - -`web/app/login/page.tsx` — email/password form → `api.login` → `router.push("/")`. Plus an "Organization SSO" section: an org-id input and a "Sign in with SSO" button that navigates to `/auth/oidc/start?org=`. On mount, call `api.bootstrapStatus()`; if `needs_setup`, redirect to `/setup`. - -- [ ] **Step 4: Gate the app** - -In `web/components/AuthProvider.tsx`, query `api.me()`; on 401 redirect to `/login` (or `/setup` when bootstrap needed). Show a loader while resolving. Leave `/login` and `/setup` routes ungated. Show current org name + user email in the sidebar/header (pass via context). - -- [ ] **Step 5: Verify build** - -Run: `cd web && npm run build` -Expected: success. - -- [ ] **Step 6: Commit** - -```bash -git add web/app/login/page.tsx web/app/setup/page.tsx web/lib/api.ts web/components/AuthProvider.tsx -git commit -m "feat(web): login, first-run setup, and auth gating" -``` - ---- - -## Task 10: Frontend — org settings (members + OIDC) - -**Files:** -- Create: `web/app/settings/org/page.tsx` - -**Interfaces:** -- Consumes: `api.listOrgUsers`, `api.createOrgUser`, `api.updateOrgUserRole`, `api.deleteOrgUser`, `api.getOrgOIDC`, `api.putOrgOIDC` (add these to `web/lib/api.ts` following the same pattern). - -- [ ] **Step 1: Add org API methods** - -```ts - listOrgUsers: () => req("/api/org/users"), - createOrgUser: (email: string, password: string, role: string) => - req("/api/org/users", { method: "POST", body: JSON.stringify({ email, password, role }) }), - updateOrgUserRole: (id: string, role: string) => - req(`/api/org/users/${id}/role`, { method: "PUT", body: JSON.stringify({ role }) }), - deleteOrgUser: (id: string) => req(`/api/org/users/${id}`, { method: "DELETE" }), - getOrgOIDC: () => req("/api/org/oidc"), - putOrgOIDC: (cfg: { issuer: string; client_id: string; client_secret?: string; redirect_url: string; enabled: boolean }) => - req("/api/org/oidc", { method: "PUT", body: JSON.stringify(cfg) }), -``` - -- [ ] **Step 2: Build the page** - -Two cards: **Members** (table of users with role select + remove, an "Add user" inline form: email/password/role) and **Organization SSO** (form: issuer, client_id, client_secret, redirect_url, enabled toggle → `api.putOrgOIDC`). Admin-only actions; hide mutations if `me.user.role === "member"`. Reuse secrets-page styling. - -- [ ] **Step 3: Verify build** - -Run: `cd web && npm run build` -Expected: success. - -- [ ] **Step 4: Commit** - -```bash -git add web/app/settings/org/page.tsx web/lib/api.ts -git commit -m "feat(web): org settings — members and SSO config" -``` - ---- - -## Task 11: End-to-end manual verification - -- [ ] **Step 1: Build all** - -Run: `cd server && go build ./... && cd ../web && npm run build` -Expected: success. - -- [ ] **Step 2: Smoke (if environment available)** - -1. Fresh DB → visiting the app redirects to `/setup`; create org + owner → land logged in. -2. Existing DB with legacy servers/keys → migration stamps them into "Default" org; owner (created via setup) sees them. -3. Create a `member` user in org settings; log in as them in a private window; confirm they see the same org's resources and cannot access org-admin mutations (403). -4. Configure org OIDC (issuer/client id/secret/redirect, enabled); from `/login` enter the org id, click SSO; complete provider login; confirm a user is provisioned in that org and logged in. -5. Confirm a user cannot see another org's data (create a second org via a second setup only possible on empty DB — verify at the query level or by inspecting that all queries carry `org_id`). - -- [ ] **Step 3: Commit fixes** - -```bash -git add -A -git commit -m "fix: saas auth/org verification fixes" -``` - ---- - -## Self-Review Notes - -- **Spec coverage:** §3 models → T1; §4 flows (local login/bootstrap/me → T4; org user mgmt → T7; per-org OIDC → T5) ; §5 scoping → T6/T7 (`auth.OrgID`, `RequireRole`); §6 remove Authentik → T5 step 4; §7 migration → T8; §8 frontend → T9/T10; §9 security (bcrypt T2, secret encryption T5, org derived from session T7, state binds org T5). Tests omitted per Global Constraints. -- **Isolation invariant:** every scoped handler passes `auth.OrgID(c)`; agent-facing lookups resolve by `server_id` and rely on `CreateServer` stamping `org_id`. -- **Ordering caveat:** Task 6 intentionally leaves the build broken at API call sites until Task 7 — the two must land together (or as one review unit) for a green build. Workflows services are scoped only if that plan already merged (T6 step 3). -- **Follow-ups (out of scope):** billing/plan limits, email-based invites, org switching, SAML/SCIM, second-org creation UX (currently only via empty-DB bootstrap). diff --git a/docs/superpowers/plans/2026-07-21-saas-auth-orgs.md b/docs/superpowers/plans/2026-07-21-saas-auth-orgs.md deleted file mode 100644 index 5cd392d..0000000 --- a/docs/superpowers/plans/2026-07-21-saas-auth-orgs.md +++ /dev/null @@ -1,1435 +0,0 @@ -# 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). diff --git a/docs/superpowers/specs/2026-07-20-saas-auth-orgs-design.md b/docs/superpowers/specs/2026-07-20-saas-auth-orgs-design.md deleted file mode 100644 index 71c86d3..0000000 --- a/docs/superpowers/specs/2026-07-20-saas-auth-orgs-design.md +++ /dev/null @@ -1,158 +0,0 @@ -# SaaS: Auth + Organizations — Design - -**Date:** 2026-07-20 -**Status:** Approved (design) — ready for implementation planning -**Scope:** Local auth + organizations + per-org OIDC, and org-scoping of existing data. Billing/plan-limits explicitly deferred. Fleet Inventory and Server Workflows are separate sub-projects. - ---- - -## 1. Summary - -Turn Vantage from a single-admin, single global-OIDC tool into a multi-tenant app: - -1. **Replace** the global Authentik/env-based OIDC with **local email/password accounts** as the primary login. -2. **Organizations** — every user belongs to an org; every domain object (servers, keys, secrets, assignments, workflows, steps, runs, audit logs, monitors, notification channels, console sessions, incidents, uptime rollups) carries an `org_id` and all queries are scoped to the caller's org. -3. **Per-org OpenID** — an org admin can configure their own OIDC provider (issuer/client id/secret); users in that org can then sign in through it. - -No billing, no seat/server limits this iteration (schema leaves room). - ---- - -## 2. Locked decisions - -| Topic | Decision | -|-------|----------| -| Primary auth | Local email + password (bcrypt). Replaces global Authentik. | -| Org SSO | Per-org OIDC provider, configured by org admin, resolved dynamically at login. | -| Isolation | `org_id` on every collection; every service query filtered by org. Enforced in the request layer via session→org. | -| Roles | `owner`, `admin`, `member` (v1: owner/admin can manage users + org OIDC + all resources; member can use resources). Keep minimal. | -| Bootstrapping | First-run creates the initial org + owner account (setup flow) when no users exist. | -| Sessions | Keep existing Redis session store; session now carries `user_id`, `org_id`, `role`, `email`. | -| Agent auth | Unchanged (per-server agent tokens). Servers gain `org_id`; agent RPCs resolve org from the server record. | -| gRPC endpoint | Single shared `grpc-vantage.hostxtra.co.uk` — no per-org subdomain. Org resolved from `server_id`/token, never from host. Agent configs unchanged. | - ---- - -## 3. Data model - -### `orgs` -```json -{ "_id":"ObjectId", "org_id":"uuid", "name":"Doms Org", "slug":"doms-org", "created_at":"ISODate" } -``` -- `slug` derived from `name` at creation: lowercase, spaces/underscores → `-`, strip non `[a-z0-9-]`, collapse repeat `-`, trim leading/trailing `-`. `Doms Org` → `doms-org`. -- **Unique index on `slug`** (global). On collision append `-2`, `-3`, … or reject and ask user to pick. -- Length 3–40. Reserved slugs blocked: `www`, `api`, `app`, `admin`, `auth`, `install`, `static`, `_next`, plus the bare apex. -- Slug is the DNS label → `doms-org.vantage.hostxtra.co.uk`. Treat as **immutable in v1** (rename breaks bookmarks, cookies, OIDC redirect URLs). Renaming deferred. - -### `users` -```json -{ - "_id":"ObjectId", "user_id":"uuid", "org_id":"uuid", - "email":"a@b.com", "password_hash":"bcrypt...", "role":"owner|admin|member", - "auth_source":"local|oidc", "created_at":"ISODate", "last_login":"ISODate|null" -} -``` -Unique index on `email` (global — email identifies the account and its org). - -### `org_oidc` (per-org provider config) -```json -{ - "_id":"ObjectId", "org_id":"uuid", - "issuer":"https://id.acme.com", "client_id":"...", - "client_secret_enc":"AES...", // encrypted with existing crypto.go - "enabled": true, "updated_at":"ISODate" -} -``` - -### Existing collections — add `org_id` -`servers`, `keys`, `assignments`, `secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `audit_logs`, `monitors`, `notification_channels`, `console_sessions`, `incidents`, `monitor_rollups` each gain `org_id string`. A **migration** backfills all existing documents into a default org (see §7). - -> The audit collection is named `audit_logs` and the notification channel collection `notification_channels`. Earlier drafts of this document called them `audit` and `channels`; those names were copied verbatim into the migration's scoped-collection list and silently skipped both collections. Use the real names. - ---- - -## 4. Auth flows - -### Local -- `POST /auth/register` — only allowed during first-run bootstrap (creates org + owner) OR by an org admin inviting a user (see below). Not open self-serve. -- `POST /auth/login` — email + password → verify bcrypt → create session with `{user_id, org_id, role, email}`. -- `POST /auth/logout` — destroy session. -- `GET /auth/me` — returns current user + org. - -### Org-admin user management -- `GET /api/org/users` / `POST /api/org/users` (create local user in caller's org) / `PUT /api/org/users/:id/role` / `DELETE /api/org/users/:id`. - -### Per-org OIDC -- `GET/PUT /api/org/oidc` — read/save the caller org's provider config (admin only). Secret stored encrypted. UI shows the exact redirect URL the admin must register with their provider: `https://.vantage.hostxtra.co.uk/auth/oidc/callback`. -- `GET /auth/oidc/start` — org resolved from host (subdomain slug). Look up org's `org_oidc`, build provider on demand (cache per org). Redirect URL **derived from host** (`https:///auth/oidc/callback`), not stored. State carries `org_id`. The per-org provider cache is evicted when the org's OIDC config is saved, so a rotated issuer takes effect without a restart; the oauth2 config is built per request (its redirect URL is host-derived) and never cached. -- `GET /auth/oidc/callback` — exchange code, match/provision the user by email **within that org**, create session. - - If the email exists in the org → log in. If not → provision a `member` with `auth_source=oidc` (org admin can promote). Reject if email belongs to a different org. - -### First-run bootstrap -- `GET /auth/bootstrap-status` → `{ needs_setup: bool }` (true when `users` is empty). -- Setup page collects org name + owner email/password → creates org + owner → session. - ---- - -## 5. Request scoping - -- `auth.Middleware` already loads the session; extend `Session` to include `OrgID`, `UserID`, `Role`. Add helper `auth.OrgID(c) string`. -- **Every service function that reads/writes a scoped collection takes an `orgID` argument** and adds `"org_id": orgID` to its filter and on insert. Handlers pass `auth.OrgID(c)`. -- Add a `requireRole(role)` gin middleware for admin-only routes (org user mgmt, org OIDC). -- Agent-facing gRPC: resolve `org_id` from the `servers` record (already tied to `server_id`); inventory/keys/sync operate on that org implicitly. - -### Host-based org resolution (per-org subdomain) -- Wildcard DNS `*.vantage.hostxtra.co.uk` + wildcard TLS cert (Let's Encrypt DNS-01). One record, one cert, no per-org ops. -- Middleware extracts subdomain label from `Host` header → look up `orgs.slug` → org. Cache slug→org_id (hits only; misses are never cached so a freshly bootstrapped org resolves immediately). The app root label (the `vantage` in `.vantage.`) is read from `APP_ROOT_LABEL`, defaulting to `vantage` — deployments on another root must set it or no host resolves to an org. -- **Hostname is a routing/UX hint, NOT an authorization boundary.** Authorization stays session `org_id` (spec §9). If session org ≠ host org → reject (or redirect to correct host). Never trust `Host` to grant access. -- `/auth/oidc/start` reads org from host — drops the "type your org" box. -- Session cookie set on the **exact host** (`doms-org.vantage...`), not parent `.vantage...`, so cookies don't leak across orgs. -- Apex `vantage.hostxtra.co.uk` (no subdomain): serves bootstrap + login-by-email fallback; after login redirect to the user's org host. - ---- - -## 6. Removing global Authentik - -- Delete/retire env-driven `InitOIDC` global provider (`OIDC_ISSUER` etc.). Keep the `go-oidc`/`oauth2` machinery but move it behind the per-org resolver. -- `authEnabled` global replaced by "auth always on" (there is always local auth). Update `middleware.go` accordingly (no more `if !authEnabled { next }` bypass — except the bootstrap endpoints and login/register which are unauthenticated). -- Login page (`web/app/login` or existing) offers: email/password form + "Sign in with your organization's SSO" (enter org, redirect to `/auth/oidc/start`). - ---- - -## 7. Migration - -One-shot migration run at startup (idempotent): -1. If any scoped collection contains documents without `org_id`: reuse the org with slug `default`, creating it ("Default") if absent. Note this is looser than "`orgs` is empty AND ..." — the implementation is the authoritative and safer form, since an instance can have an org already (created by first-run bootstrap) while legacy documents still lack `org_id`; the stricter condition would skip the backfill and strand that data. -2. Set `org_id = ` on all existing `servers`, `keys`, `assignments`, `secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `audit_logs`, `monitors`, `notification_channels` documents missing it. - - `console_sessions`, `incidents` and `monitor_rollups` are also scoped, but their org is derived from the owning `servers`/`monitors` record rather than defaulted (migration `0003`), since defaulting them would mix one org's console history and incident timeline into another's. - - Migration `0003` also re-runs step 2 for `audit_logs` and `notification_channels`: the original `0001` listed them under the wrong names (`audit`, `channels`) and wrote its marker regardless, so those documents need a second, separately-markered pass to converge. -3. If `OIDC_ISSUER` env was set previously and an admin email is known, optionally seed an owner user (documented manual step) — otherwise first-run bootstrap handles owner creation. -Guard with a marker (e.g. a `migrations` collection entry) so it runs once. - ---- - -## 8. Frontend - -- **Login/Setup:** `web/app/login/page.tsx` (email/password + org SSO entry) and `web/app/setup/page.tsx` (first-run). Redirect logic based on `bootstrap-status` and `auth/me`. -- **Org settings:** `web/app/settings/org/` — members list + invite/create user + role management; OIDC provider form (issuer/client id/secret/enabled). -- Existing pages unchanged functionally but now implicitly org-scoped by the backend. Show current org + user in the sidebar/header. - ---- - -## 9. Security - -- Passwords: bcrypt (cost ≥ 12). Never returned. -- Org OIDC client secret encrypted at rest (reuse `services/crypto.go` AES). -- Cross-org access prevented at the service layer (org_id in every filter) — the primary isolation boundary. Handlers must never accept an `org_id` from the client; always derive from session. -- OIDC callback must bind the returned identity to the org that initiated the flow (state carries org_id) to prevent org-mixing. -- Role checks on all org-admin mutations. - ---- - -## 10. Out of scope - -- Billing, plans, seat/server limits. -- Cross-org resource sharing, org switching for a single user (one user = one org in v1). -- SCIM / directory sync, SAML. -- Email delivery for invites (create-user sets a password or invite token; email sending deferred — document as manual/console output). -- Tests (skipped, consistent with prior iterations).