diff --git a/docs/superpowers/plans/2026-07-21-saas-auth-orgs.md b/docs/superpowers/plans/2026-07-21-saas-auth-orgs.md new file mode 100644 index 0000000..5cd392d --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-saas-auth-orgs.md @@ -0,0 +1,1435 @@ +# SaaS Auth + Organizations Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn Vantage from single-admin global-OIDC into a multi-tenant app with local email/password auth, organizations, per-org OIDC, org-scoped data, and per-org subdomains. + +**Architecture:** One shared deployment, one shared MongoDB. Isolation is `org_id` on every domain collection, filtered in the service layer; org derived from the Redis session (never from client input). Per-org subdomain (`.vantage.hostxtra.co.uk`) is a routing/UX hint via wildcard DNS+TLS — not an auth boundary. gRPC endpoint stays single and shared; agent RPCs resolve org from the `servers` record. + +**Tech Stack:** Go (gin, mongo-driver v2, go-oidc/v3, oauth2, golang.org/x/crypto/bcrypt), Redis sessions, Next.js (app router, React). + +## Global Constraints + +- **No Go test files.** Tests are out of scope (spec §10). Each task verifies with `go build ./...` (server) / `npm run build` (web) plus the stated manual check. Do NOT create `*_test.go`. +- Isolation boundary is the **service layer**: every scoped query filters `org_id`; every insert sets `org_id`. Handlers derive org via `auth.OrgID(c)` — **never** accept `org_id` from the client body/query. +- Hostname/subdomain is a routing hint only; it MUST NOT grant access. Authorization always uses session `org_id`. +- bcrypt cost ≥ 12. Passwords never returned in any response. +- Org OIDC client secret encrypted at rest with existing `services` AES (`encryptString`/`decryptString` in `crypto.go`). +- Commit after every task. Work stays on branch `feat/saas-auth-orgs`. **No push to main, no deploy, no agent tag.** +- Scoped collections (each gains `org_id string`): `servers`, `keys`, `assignments`, `secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `audit`, `monitors`, `channels`. New collections: `orgs`, `users`, `org_oidc`, `migrations`. + +--- + +### Task 1: Models — orgs, users, org_oidc, and org_id on existing models + +**Files:** +- Create: `server/internal/models/org.go` +- Create: `server/internal/models/user.go` +- Create: `server/internal/models/org_oidc.go` +- Modify: `server/internal/models/server.go`, `key.go`, `assignment.go`, `secret.go`, `workflow.go`, `monitor.go`, `channel.go`, `audit.go` + +**Interfaces:** +- Produces: `models.Org{ID, OrgID, Name, Slug, CreatedAt}`, `models.User{ID, UserID, OrgID, Email, PasswordHash, Role, AuthSource, CreatedAt, LastLogin}`, `models.OrgOIDC{ID, OrgID, Issuer, ClientID, ClientSecretEnc, Enabled, UpdatedAt}`. Each existing scoped model gains `OrgID string` with tag `bson:"org_id" json:"org_id"`. + +- [ ] **Step 1: Create `models/org.go`** + +```go +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +type Org struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + OrgID string `bson:"org_id" json:"org_id"` + Name string `bson:"name" json:"name"` + Slug string `bson:"slug" json:"slug"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` +} +``` + +- [ ] **Step 2: Create `models/user.go`** + +```go +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +type User struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + UserID string `bson:"user_id" json:"user_id"` + OrgID string `bson:"org_id" json:"org_id"` + Email string `bson:"email" json:"email"` + PasswordHash string `bson:"password_hash,omitempty" json:"-"` + Role string `bson:"role" json:"role"` // owner|admin|member + AuthSource string `bson:"auth_source" json:"auth_source"` // local|oidc + CreatedAt time.Time `bson:"created_at" json:"created_at"` + LastLogin *time.Time `bson:"last_login,omitempty" json:"last_login,omitempty"` +} +``` + +- [ ] **Step 3: Create `models/org_oidc.go`** + +```go +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +type OrgOIDC struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + OrgID string `bson:"org_id" json:"org_id"` + Issuer string `bson:"issuer" json:"issuer"` + ClientID string `bson:"client_id" json:"client_id"` + ClientSecretEnc string `bson:"client_secret_enc,omitempty" json:"-"` + Enabled bool `bson:"enabled" json:"enabled"` + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` +} +``` + +- [ ] **Step 4: Add `OrgID` to each existing scoped model.** In `server.go`, `key.go`, `assignment.go`, `secret.go`, `workflow.go` (the `Workflow`, `WorkflowStep`, `WorkflowRun` structs), `monitor.go`, `channel.go`, `audit.go` — add this field right after the leading `ID`/primary-id field: + +```go + OrgID string `bson:"org_id" json:"org_id"` +``` + +- [ ] **Step 5: Verify build** + +Run: `cd server && go build ./...` +Expected: compiles (new fields unused yet is fine). + +- [ ] **Step 6: Commit** + +```bash +git add server/internal/models +git commit -m "feat(models): add org/user/org_oidc models and org_id on scoped collections" +``` + +--- + +### Task 2: DB indexes + one-shot migration (backfill default org) + +**Files:** +- Create: `server/internal/services/orgs.go` (org CRUD + slug logic — see Task 6 for slug; here just create-with-slug helper stub used by migration) +- Create: `server/internal/services/migrate.go` +- Modify: `server/cmd/main.go:26-51` (call migration + index ensure after `db.Connect`) + +**Interfaces:** +- Produces: `services.RunMigrations() error`, `services.EnsureAuthIndexes() error`. Consumes: `db.Col`, slugify from Task 6 is NOT yet available — migration uses fixed slug `"default"`. + +- [ ] **Step 1: Create `services/migrate.go`** + +```go +package services + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/mrhid6/vantage/server/internal/db" + "github.com/mrhid6/vantage/server/internal/models" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +var scopedCollections = []string{ + "servers", "keys", "assignments", "secrets", + "workflows", "workflow_steps", "workflow_runs", + "audit", "monitors", "channels", +} + +// EnsureAuthIndexes creates unique indexes for the new auth collections. +func EnsureAuthIndexes() error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if _, err := db.Col("users").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "email", Value: 1}}, + Options: options.Index().SetUnique(true), + }); err != nil { + return err + } + if _, err := db.Col("orgs").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "slug", Value: 1}}, + Options: options.Index().SetUnique(true), + }); err != nil { + return err + } + if _, err := db.Col("org_oidc").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "org_id", Value: 1}}, + Options: options.Index().SetUnique(true), + }); err != nil { + return err + } + return nil +} + +// RunMigrations backfills a default org onto pre-existing documents. Idempotent +// via a marker in the migrations collection. +func RunMigrations() error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + const marker = "0001_default_org_backfill" + if n, _ := db.Col("migrations").CountDocuments(ctx, bson.M{"_id": marker}); n > 0 { + return nil + } + + // Only backfill if there is legacy data lacking org_id. + needs := false + for _, col := range scopedCollections { + n, _ := db.Col(col).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": false}}) + if n > 0 { + needs = true + break + } + } + + if needs { + var org models.Org + err := db.Col("orgs").FindOne(ctx, bson.M{"slug": "default"}).Decode(&org) + if err != nil { + org = models.Org{OrgID: uuid.NewString(), Name: "Default", Slug: "default", CreatedAt: time.Now()} + if _, err := db.Col("orgs").InsertOne(ctx, org); err != nil { + return err + } + } + for _, col := range scopedCollections { + if _, err := db.Col(col).UpdateMany(ctx, + bson.M{"org_id": bson.M{"$exists": false}}, + bson.M{"$set": bson.M{"org_id": org.OrgID}}, + ); err != nil { + return err + } + } + } + + _, err := db.Col("migrations").InsertOne(ctx, bson.M{"_id": marker, "applied_at": time.Now()}) + return err +} +``` + +- [ ] **Step 2: Wire into `main.go`** — after the `db.Connect` success log (line ~25), before `EnsureSecretIndexes`: + +```go + if err := services.EnsureAuthIndexes(); err != nil { + log.Printf("warning: failed to ensure auth indexes: %v", err) + } + if err := services.RunMigrations(); err != nil { + log.Fatalf("migration failed: %v", err) + } +``` + +- [ ] **Step 3: Verify build** + +Run: `cd server && go build ./...` +Expected: compiles. + +- [ ] **Step 4: Commit** + +```bash +git add server/internal/services/migrate.go server/cmd/main.go +git commit -m "feat(server): auth indexes + default-org backfill migration" +``` + +--- + +### Task 3: Session carries org — struct + helpers + +**Files:** +- Modify: `server/internal/auth/session.go:18-22` (Session struct) +- Modify: `server/internal/auth/middleware.go` (add `OrgID`/`Role`/`UserID` helpers; keep loading session) + +**Interfaces:** +- Produces: `Session{UserID, OrgID, Role, Email, Name}`; `auth.OrgID(c *gin.Context) string`, `auth.Role(c *gin.Context) string`, `auth.UserID(c *gin.Context) string`. + +- [ ] **Step 1: Extend `Session` in `session.go`** + +```go +type Session struct { + UserID string `json:"user_id"` + OrgID string `json:"org_id"` + Role string `json:"role"` + Email string `json:"email"` + Name string `json:"name"` +} +``` + +- [ ] **Step 2: Add helpers to `middleware.go`** (below `GetSessionFromContext`): + +```go +func OrgID(c *gin.Context) string { + if s := GetSessionFromContext(c); s != nil { + return s.OrgID + } + return "" +} + +func Role(c *gin.Context) string { + if s := GetSessionFromContext(c); s != nil { + return s.Role + } + return "" +} + +func UserID(c *gin.Context) string { + if s := GetSessionFromContext(c); s != nil { + return s.UserID + } + return "" +} +``` + +- [ ] **Step 3: Verify build** + +Run: `cd server && go build ./...` +Expected: compiles. + +- [ ] **Step 4: Commit** + +```bash +git add server/internal/auth/session.go server/internal/auth/middleware.go +git commit -m "feat(auth): session carries org_id/role; add context helpers" +``` + +--- + +### Task 4: Org + user services with slug + bcrypt + +**Files:** +- Modify/expand: `server/internal/services/orgs.go` +- Create: `server/internal/services/users.go` + +**Interfaces:** +- Produces: + - `services.Slugify(name string) string` + - `services.CreateOrg(name string) (*models.Org, error)` (generates unique slug, rejects reserved) + - `services.GetOrgBySlug(slug string) (*models.Org, error)`, `services.GetOrg(orgID string) (*models.Org, error)` + - `services.CreateUser(orgID, email, password, role, authSource string) (*models.User, error)` (bcrypt when password non-empty) + - `services.GetUserByEmail(email string) (*models.User, error)` + - `services.VerifyPassword(u *models.User, password string) bool` + - `services.ListUsers(orgID string) ([]models.User, error)`, `services.UpdateUserRole(orgID, userID, role string) error`, `services.DeleteUser(orgID, userID string) error` + - `services.CountUsers() (int64, error)`, `services.TouchLastLogin(userID string) error` + +- [ ] **Step 1: Create `services/orgs.go`** with slug logic and reserved list + +```go +package services + +import ( + "context" + "fmt" + "regexp" + "strings" + "time" + + "github.com/google/uuid" + "github.com/mrhid6/vantage/server/internal/db" + "github.com/mrhid6/vantage/server/internal/models" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +var reservedSlugs = map[string]bool{ + "www": true, "api": true, "app": true, "admin": true, "auth": true, + "install": true, "static": true, "_next": true, "default": true, +} + +var slugStrip = regexp.MustCompile(`[^a-z0-9-]+`) +var slugDashes = regexp.MustCompile(`-+`) + +func Slugify(name string) string { + s := strings.ToLower(strings.TrimSpace(name)) + s = strings.ReplaceAll(s, "_", "-") + s = strings.ReplaceAll(s, " ", "-") + s = slugStrip.ReplaceAllString(s, "") + s = slugDashes.ReplaceAllString(s, "-") + return strings.Trim(s, "-") +} + +func GetOrg(orgID string) (*models.Org, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var o models.Org + err := db.Col("orgs").FindOne(ctx, bson.M{"org_id": orgID}).Decode(&o) + if err != nil { + return nil, err + } + return &o, nil +} + +func GetOrgBySlug(slug string) (*models.Org, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var o models.Org + err := db.Col("orgs").FindOne(ctx, bson.M{"slug": slug}).Decode(&o) + if err != nil { + return nil, err + } + return &o, nil +} + +func CreateOrg(name string) (*models.Org, error) { + base := Slugify(name) + if len(base) < 3 { + return nil, fmt.Errorf("organization name too short (slug must be >= 3 chars)") + } + if len(base) > 40 { + base = base[:40] + } + if reservedSlugs[base] { + return nil, fmt.Errorf("organization name is reserved") + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Resolve slug collision by suffixing -2, -3, ... + slug := base + for i := 2; ; i++ { + n, err := db.Col("orgs").CountDocuments(ctx, bson.M{"slug": slug}) + if err != nil { + return nil, err + } + if n == 0 { + break + } + slug = fmt.Sprintf("%s-%d", base, i) + } + + o := &models.Org{OrgID: uuid.NewString(), Name: name, Slug: slug, CreatedAt: time.Now()} + if _, err := db.Col("orgs").InsertOne(ctx, o); err != nil { + if mongo.IsDuplicateKeyError(err) { + return nil, fmt.Errorf("organization slug already taken") + } + return nil, err + } + return o, nil +} +``` + +- [ ] **Step 2: Create `services/users.go`** + +```go +package services + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/mrhid6/vantage/server/internal/db" + "github.com/mrhid6/vantage/server/internal/models" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "golang.org/x/crypto/bcrypt" +) + +func CountUsers() (int64, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return db.Col("users").CountDocuments(ctx, bson.M{}) +} + +func CreateUser(orgID, email, password, role, authSource string) (*models.User, error) { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return nil, fmt.Errorf("email required") + } + u := &models.User{ + UserID: uuid.NewString(), + OrgID: orgID, + Email: email, + Role: role, + AuthSource: authSource, + CreatedAt: time.Now(), + } + if password != "" { + hash, err := bcrypt.GenerateFromPassword([]byte(password), 12) + if err != nil { + return nil, err + } + u.PasswordHash = string(hash) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := db.Col("users").InsertOne(ctx, u); err != nil { + if mongo.IsDuplicateKeyError(err) { + return nil, fmt.Errorf("email already registered") + } + return nil, err + } + return u, nil +} + +func GetUserByEmail(email string) (*models.User, error) { + email = strings.ToLower(strings.TrimSpace(email)) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var u models.User + err := db.Col("users").FindOne(ctx, bson.M{"email": email}).Decode(&u) + if err != nil { + return nil, err + } + return &u, nil +} + +func VerifyPassword(u *models.User, password string) bool { + if u == nil || u.PasswordHash == "" { + return false + } + return bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil +} + +func TouchLastLogin(userID string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + now := time.Now() + _, err := db.Col("users").UpdateOne(ctx, bson.M{"user_id": userID}, + bson.M{"$set": bson.M{"last_login": now}}) + return err +} + +func ListUsers(orgID string) ([]models.User, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cursor, err := db.Col("users").Find(ctx, bson.M{"org_id": orgID}) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var users []models.User + if err := cursor.All(ctx, &users); err != nil { + return nil, err + } + return users, nil +} + +func UpdateUserRole(orgID, userID, role string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := db.Col("users").UpdateOne(ctx, + bson.M{"user_id": userID, "org_id": orgID}, + bson.M{"$set": bson.M{"role": role}}) + return err +} + +func DeleteUser(orgID, userID string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := db.Col("users").DeleteOne(ctx, bson.M{"user_id": userID, "org_id": orgID}) + return err +} +``` + +- [ ] **Step 3: Verify build** + +Run: `cd server && go build ./...` +Expected: compiles. + +- [ ] **Step 4: Commit** + +```bash +git add server/internal/services/orgs.go server/internal/services/users.go +git commit -m "feat(services): org create+slug and user service with bcrypt" +``` + +--- + +### Task 5: Local auth handlers + bootstrap + +**Files:** +- Create: `server/internal/auth/local.go` (login/register/logout/me/bootstrap handlers + cookie helper) +- Modify: `server/internal/auth/oidc.go` (extract shared `setSessionCookie`; keep existing OIDC handlers until Task 7) + +**Interfaces:** +- Consumes: `services.GetUserByEmail`, `services.VerifyPassword`, `services.CreateUser`, `services.CreateOrg`, `services.CountUsers`, `services.TouchLastLogin`, `SaveSession`, `Session`. +- Produces: `auth.HandleLocalLogin`, `auth.HandleLogout` (reuse existing), `auth.HandleMe` (rewritten), `auth.HandleBootstrapStatus`, `auth.HandleBootstrap`, `auth.SetSessionCookie(c, sessionID)`. + +- [ ] **Step 1: Add `SetSessionCookie` helper in `local.go`** (extract the cookie block currently inline in `oidc.go:106-115`) + +```go +package auth + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/server/internal/services" +) + +func SetSessionCookie(c *gin.Context, sessionID string) { + secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" + http.SetCookie(c.Writer, &http.Cookie{ + Name: sessionCookieName, + Value: sessionID, + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + MaxAge: int(sessionTTL.Seconds()), + }) +} +``` + +- [ ] **Step 2: Add local login handler** to `local.go` + +```go +func HandleLocalLogin(c *gin.Context) { + var body struct { + Email string `json:"email"` + Password string `json:"password"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "email and password required"}) + return + } + u, err := services.GetUserByEmail(body.Email) + if err != nil || !services.VerifyPassword(u, body.Password) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + return + } + sessionID, err := SaveSession(c.Request.Context(), &Session{ + UserID: u.UserID, OrgID: u.OrgID, Role: u.Role, Email: u.Email, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"}) + return + } + _ = services.TouchLastLogin(u.UserID) + SetSessionCookie(c, sessionID) + c.JSON(http.StatusOK, gin.H{"ok": true}) +} +``` + +- [ ] **Step 3: Add bootstrap handlers** to `local.go` + +```go +func HandleBootstrapStatus(c *gin.Context) { + n, err := services.CountUsers() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"needs_setup": n == 0}) +} + +func HandleBootstrap(c *gin.Context) { + n, err := services.CountUsers() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if n > 0 { + c.JSON(http.StatusConflict, gin.H{"error": "setup already complete"}) + return + } + var body struct { + OrgName string `json:"org_name"` + Email string `json:"email"` + Password string `json:"password"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.OrgName == "" || body.Email == "" || len(body.Password) < 8 { + c.JSON(http.StatusBadRequest, gin.H{"error": "org_name, email, and password (>=8 chars) required"}) + return + } + org, err := services.CreateOrg(body.OrgName) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + u, err := services.CreateUser(org.OrgID, body.Email, body.Password, "owner", "local") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + sessionID, err := SaveSession(c.Request.Context(), &Session{ + UserID: u.UserID, OrgID: u.OrgID, Role: u.Role, Email: u.Email, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"}) + return + } + SetSessionCookie(c, sessionID) + c.JSON(http.StatusCreated, gin.H{"org": org, "slug": org.Slug}) +} +``` + +- [ ] **Step 4: Rewrite `HandleMe`** (move out of `oidc.go` into `local.go`; delete the old one in `oidc.go`) so it returns the session + org: + +```go +func HandleMe(c *gin.Context) { + cookie, err := c.Request.Cookie(sessionCookieName) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"}) + return + } + sess, err := GetSession(c.Request.Context(), cookie.Value) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "session expired"}) + return + } + org, _ := services.GetOrg(sess.OrgID) + c.JSON(http.StatusOK, gin.H{"user": sess, "org": org}) +} +``` + +- [ ] **Step 5: Delete the old `HandleMe` from `oidc.go`** and the now-duplicated cookie block (Task 7 rewrites OIDC fully; for now just make it use `SetSessionCookie`). Verify build. + +Run: `cd server && go build ./...` +Expected: compiles (no duplicate `HandleMe`). + +- [ ] **Step 6: Commit** + +```bash +git add server/internal/auth +git commit -m "feat(auth): local email/password login + first-run bootstrap" +``` + +--- + +### Task 6: Host-based org resolution middleware + +**Files:** +- Create: `server/internal/auth/orghost.go` +- Modify: `server/internal/auth/middleware.go` (in `Middleware`, after loading session, reject when host org ≠ session org) + +**Interfaces:** +- Produces: `auth.OrgFromHost(c *gin.Context) (*models.Org, bool)` (parses subdomain label, looks up `services.GetOrgBySlug`, cached). `Middleware` now enforces host/session org match when a subdomain org is present. +- Consumes: `services.GetOrgBySlug`. + +- [ ] **Step 1: Create `orghost.go`** with a slug→org cache and host parsing + +```go +package auth + +import ( + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/server/internal/models" + "github.com/mrhid6/vantage/server/internal/services" +) + +type cachedOrg struct { + org *models.Org + at time.Time +} + +var ( + orgCacheMu sync.Mutex + orgCache = map[string]cachedOrg{} +) + +const orgCacheTTL = 60 * time.Second + +// hostSlug extracts the leftmost DNS label if the host is a subdomain of the +// app root. Returns "" for the apex or an unknown host shape. +func hostSlug(host string) string { + host = strings.ToLower(host) + if i := strings.IndexByte(host, ':'); i >= 0 { + host = host[:i] + } + // Expect .vantage.<...>; apex is vantage.<...> + parts := strings.Split(host, ".") + if len(parts) < 3 { + return "" + } + if parts[1] != "vantage" { + return "" + } + if parts[0] == "vantage" || parts[0] == "www" { + return "" + } + return parts[0] +} + +func OrgFromHost(c *gin.Context) (*models.Org, bool) { + slug := hostSlug(c.Request.Host) + if slug == "" { + return nil, false + } + orgCacheMu.Lock() + if e, ok := orgCache[slug]; ok && time.Since(e.at) < orgCacheTTL { + orgCacheMu.Unlock() + return e.org, e.org != nil + } + orgCacheMu.Unlock() + + org, err := services.GetOrgBySlug(slug) + if err != nil { + org = nil + } + orgCacheMu.Lock() + orgCache[slug] = cachedOrg{org: org, at: time.Now()} + orgCacheMu.Unlock() + return org, org != nil +} +``` + +- [ ] **Step 2: Enforce host/session match in `Middleware`** — replace the body after `c.Set(ctxSessionKey, sess)` so a mismatched subdomain is rejected (defense in depth; session stays the authority): + +```go + c.Set(ctxSessionKey, sess) + + if hostOrg, ok := OrgFromHost(c); ok && hostOrg.OrgID != sess.OrgID { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "org host mismatch"}) + return + } + + c.Next() +``` + +- [ ] **Step 3: Verify build** + +Run: `cd server && go build ./...` +Expected: compiles. + +- [ ] **Step 4: Commit** + +```bash +git add server/internal/auth +git commit -m "feat(auth): host-based org resolution + session/host match guard" +``` + +--- + +### Task 7: Per-org OIDC resolver (replace global OIDC) + +**Files:** +- Rewrite: `server/internal/auth/oidc.go` +- Create: `server/internal/services/org_oidc.go` (config CRUD, secret encrypt/decrypt) + +**Interfaces:** +- Produces: + - `services.GetOrgOIDC(orgID string) (*models.OrgOIDC, error)`, `services.SaveOrgOIDC(orgID, issuer, clientID, clientSecret string, enabled bool) error` (encrypts secret; empty clientSecret keeps existing), `services.GetOrgOIDCSecret(orgID string) (string, error)`. + - `auth.HandleOIDCStart(c)` (org from host), `auth.HandleOIDCCallback(c)` (state carries org_id; provision/login within org). Redirect URL derived from `https:///auth/oidc/callback`. +- Consumes: `SaveState`/`ConsumeState` extended to carry org_id (store `org_id` as the Redis value instead of `"1"`). + +- [ ] **Step 1: Extend state storage** in `session.go` to carry org_id + +```go +func SaveStateOrg(ctx context.Context, state, orgID string) error { + return rdb.Set(ctx, statePrefix+state, orgID, 10*time.Minute).Err() +} + +func ConsumeStateOrg(ctx context.Context, state string) (string, bool) { + orgID, err := rdb.GetDel(ctx, statePrefix+state).Result() + if err != nil || orgID == "" { + return "", false + } + return orgID, true +} +``` + +- [ ] **Step 2: Create `services/org_oidc.go`** + +```go +package services + +import ( + "context" + "time" + + "github.com/mrhid6/vantage/server/internal/db" + "github.com/mrhid6/vantage/server/internal/models" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +func GetOrgOIDC(orgID string) (*models.OrgOIDC, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var o models.OrgOIDC + err := db.Col("org_oidc").FindOne(ctx, bson.M{"org_id": orgID}).Decode(&o) + if err != nil { + return nil, err + } + return &o, nil +} + +func GetOrgOIDCSecret(orgID string) (string, error) { + o, err := GetOrgOIDC(orgID) + if err != nil { + return "", err + } + return decryptString(o.ClientSecretEnc) +} + +// SaveOrgOIDC upserts the org's provider config. An empty clientSecret keeps the +// stored secret (so the UI need not resend it). +func SaveOrgOIDC(orgID, issuer, clientID, clientSecret string, enabled bool) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + set := bson.M{ + "org_id": orgID, "issuer": issuer, "client_id": clientID, + "enabled": enabled, "updated_at": time.Now(), + } + if clientSecret != "" { + enc, err := encryptString(clientSecret) + if err != nil { + return err + } + set["client_secret_enc"] = enc + } + _, err := db.Col("org_oidc").UpdateOne(ctx, + bson.M{"org_id": orgID}, bson.M{"$set": set}, + options.UpdateOne().SetUpsert(true)) + return err +} +``` + +- [ ] **Step 3: Rewrite `oidc.go`** — drop global `InitOIDC`/`authEnabled`; build provider per org on demand with a cache + +```go +package auth + +import ( + "context" + "fmt" + "net/http" + "strings" + "sync" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/server/internal/services" + "golang.org/x/oauth2" +) + +type orgProvider struct { + provider *oidc.Provider + oauth *oauth2.Config +} + +var ( + provMu sync.Mutex + provCache = map[string]*orgProvider{} +) + +func redirectURL(c *gin.Context) string { + scheme := "https" + if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" { + scheme = "http" + } + return fmt.Sprintf("%s://%s/auth/oidc/callback", scheme, c.Request.Host) +} + +func providerForOrg(ctx context.Context, c *gin.Context, orgID string) (*orgProvider, error) { + cfg, err := services.GetOrgOIDC(orgID) + if err != nil || !cfg.Enabled { + return nil, fmt.Errorf("org SSO not configured") + } + secret, err := services.GetOrgOIDCSecret(orgID) + if err != nil { + return nil, err + } + provMu.Lock() + op := provCache[orgID] + provMu.Unlock() + if op == nil || op.provider == nil { + p, err := oidc.NewProvider(ctx, cfg.Issuer) + if err != nil { + return nil, err + } + op = &orgProvider{provider: p} + provMu.Lock() + provCache[orgID] = op + provMu.Unlock() + } + op.oauth = &oauth2.Config{ + ClientID: cfg.ClientID, ClientSecret: secret, + RedirectURL: redirectURL(c), Endpoint: op.provider.Endpoint(), + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + } + return op, nil +} + +func HandleOIDCStart(c *gin.Context) { + org, ok := OrgFromHost(c) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "unknown organization host"}) + return + } + ctx := c.Request.Context() + op, err := providerForOrg(ctx, c, org.OrgID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + state, err := randomHex(16) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "state gen failed"}) + return + } + if err := SaveStateOrg(ctx, state, org.OrgID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "state save failed"}) + return + } + c.Redirect(http.StatusFound, op.oauth.AuthCodeURL(state)) +} + +func HandleOIDCCallback(c *gin.Context) { + ctx := c.Request.Context() + orgID, ok := ConsumeStateOrg(ctx, c.Query("state")) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state"}) + return + } + op, err := providerForOrg(ctx, c, orgID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + token, err := op.oauth.Exchange(ctx, c.Query("code")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "token exchange failed"}) + return + } + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + c.JSON(http.StatusInternalServerError, gin.H{"error": "missing id_token"}) + return + } + idToken, err := op.provider.Verifier(&oidc.Config{ClientID: op.oauth.ClientID}).Verify(ctx, rawIDToken) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "token verification failed"}) + return + } + var claims struct { + Email string `json:"email"` + Name string `json:"name"` + } + if err := idToken.Claims(&claims); err != nil || claims.Email == "" { + c.JSON(http.StatusInternalServerError, gin.H{"error": "claims extraction failed"}) + return + } + + email := strings.ToLower(claims.Email) + u, err := services.GetUserByEmail(email) + if err != nil { + // provision new member in THIS org + u, err = services.CreateUser(orgID, email, "", "member", "oidc") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "provisioning failed"}) + return + } + } else if u.OrgID != orgID { + c.JSON(http.StatusForbidden, gin.H{"error": "email belongs to a different organization"}) + return + } + + sessionID, err := SaveSession(ctx, &Session{ + UserID: u.UserID, OrgID: u.OrgID, Role: u.Role, Email: u.Email, Name: claims.Name, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"}) + return + } + _ = services.TouchLastLogin(u.UserID) + SetSessionCookie(c, sessionID) + c.Redirect(http.StatusFound, "/") +} +``` + +- [ ] **Step 4: Remove `InitOIDC` call** from `main.go:49-51`. Verify build. + +Run: `cd server && go build ./...` +Expected: compiles; no references to `InitOIDC`, `authEnabled`, `oidcProvider`, `oauth2Cfg` remain. + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/auth server/internal/services/org_oidc.go server/cmd/main.go +git commit -m "feat(auth): per-org OIDC resolver replaces global provider" +``` + +--- + +### Task 8: Org-scope the service layer (servers, keys, assignments, sync, inventory) + +**Files:** +- Modify: `server/internal/services/servers.go`, `keys.go`, `sync.go`, `inventory.go` + +**Interfaces:** +- Produces (new signatures — every scoped read/write gains a leading `orgID string` param and adds `"org_id": orgID` to its filter; inserts set `OrgID`): + - `ListServers(orgID string)`, `GetServer(orgID, serverID string)`, `CreateServer(orgID string)`, `DeleteServer(orgID, serverID string)`, `StoreAvailableUpdates(orgID, serverID string, ...)`. + - Agent-path functions **keep their non-org signatures** but resolve/verify org internally: `RegisterServer`, `ValidateAgentToken` (returns `*models.Server`, caller reads `.OrgID`), `UpdateServerLastSeen`, `BackfillConsoleConfig`, `GetServerByPreRegToken`, `MarkOfflineServers` (unchanged — cross-org sweep). + - `keys.go`: `ListKeys(orgID)`, `GetKey(orgID, keyID)`, `CreateKey(orgID, label, publicKey, source, generatedBy, privateKey, passphrase string)`, `DeleteKey(orgID, keyID)`, `AssignKey(orgID, keyID, serverID)`, `RevokeAssignment(orgID, keyID, serverID)`, `GetAssignmentsWithKeysForServer(orgID, serverID)`, `GetAssignmentsWithServers(orgID, keyID)`, `GetPrivateKey(orgID, keyID)`. + - `sync.go`: `BuildDesiredState(serverID)` resolves org from the server record internally (agent path) — no signature change, but the assignment/key lookups it performs filter by that server's `org_id`. + +- [ ] **Step 1: `servers.go` — add org to admin-facing funcs.** For `CreateServer`, add `orgID` param and set `s.OrgID = orgID` before insert. For `GetServer`, `DeleteServer`, `ListServers`, `StoreAvailableUpdates`, add `orgID` param and add `"org_id": orgID` to every `bson.M{...}` filter. Example — `GetServer`: + +```go +func GetServer(orgID, serverID string) (*models.Server, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var s models.Server + err := db.Col("servers").FindOne(ctx, bson.M{"server_id": serverID, "org_id": orgID}).Decode(&s) + if err != nil { + return nil, err + } + return &s, nil +} +``` + +And `DeleteServer` also scopes the assignments cleanup: `bson.M{"server_id": serverID, "org_id": orgID}`. + +- [ ] **Step 2: `servers.go` — agent path unchanged in signature but org travels via the record.** `RegisterServer` sets no new org (server row already has `org_id` from `CreateServer`). `ValidateAgentToken` already returns `*models.Server` — callers get `.OrgID` from it. Leave `MarkOfflineServers` cross-org (it sweeps all orgs; audit/alerts already per-server). + +- [ ] **Step 3: `keys.go` — add `orgID` to every listed func**, adding `"org_id": orgID` to filters and setting `OrgID` on the `CreateKey`/`AssignKey` inserts. For `AssignKey`, also verify the target server is in the same org before inserting the assignment: + +```go +func AssignKey(orgID, keyID, serverID string) (*models.Assignment, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + // ensure server belongs to org + if err := db.Col("servers").FindOne(ctx, bson.M{"server_id": serverID, "org_id": orgID}).Err(); err != nil { + return nil, fmt.Errorf("server not found in organization") + } + // ... existing upsert logic, adding org_id to filter + insert ... +} +``` + +- [ ] **Step 4: `sync.go` — resolve org from the server record.** `BuildDesiredState(serverID)` first loads the server (`GetServerByID` internal, no org filter — this is the agent path keyed by unique `server_id`), then filters assignments/keys by that server's `org_id`. Add `"org_id": srv.OrgID` to the assignment and key queries. + +- [ ] **Step 5: `inventory.go` — scope the read.** If inventory has an admin-facing getter, add `orgID` and filter; the agent write path keys by `server_id` (unique) and needs no org param. + +- [ ] **Step 6: Verify build** (handlers not yet updated — expect compile errors ONLY in `api/*.go`; services + this file must be internally consistent). Build just services: + +Run: `cd server && go build ./internal/services/... ./internal/models/... ./internal/auth/...` +Expected: services/models/auth compile. (api package updated in Task 9.) + +- [ ] **Step 7: Commit** + +```bash +git add server/internal/services +git commit -m "feat(services): org-scope servers/keys/assignments/sync/inventory" +``` + +--- + +### Task 9: Org-scope remaining services + all handlers + admin routes + +**Files:** +- Modify: `server/internal/services/secrets.go`, `workflows.go`, `workflow_runner.go`, `monitors.go`, `channels.go`, `audit.go`, `console.go`, `steplogs.go`, `stepresults.go`, `defaults.go` +- Modify: `server/internal/api/handlers.go`, `secrets.go`, `workflows.go`, `monitors.go`, `channels.go`, `console.go`, `install_ps1.go` +- Create: `server/internal/api/org.go` (org user mgmt + org OIDC endpoints) +- Modify: `server/internal/auth/middleware.go` (add `RequireRole`) + +**Interfaces:** +- Produces: same org-scoping rule for every scoped read/write in the remaining service files (leading `orgID string`, `"org_id": orgID` in filters, set on insert). `LogEvent`/`ListAuditEvents` gain `orgID`. Handlers pass `auth.OrgID(c)`. `auth.RequireRole(roles ...string) gin.HandlerFunc`. New endpoints under `/api/org`. +- **Console/step services that run inside a workflow run** resolve org from the parent run/server record, not a handler. + +- [ ] **Step 1: Scope remaining service files** using the identical pattern from Task 8: add `orgID` param to every admin-facing function, add `"org_id": orgID` to filters, set `OrgID` on inserts. Audit: `LogEvent(orgID, eventType, actor, serverID, keyID, message string)` and `ListAuditEvents(orgID string, limit int64)`. Workflow-runner internal calls resolve org from the run document. + +- [ ] **Step 2: Add `RequireRole` to `middleware.go`** + +```go +func RequireRole(roles ...string) gin.HandlerFunc { + return func(c *gin.Context) { + r := Role(c) + for _, want := range roles { + if r == want { + c.Next() + return + } + } + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "insufficient role"}) + } +} +``` + +- [ ] **Step 3: Create `api/org.go`** — user management + OIDC config handlers + +```go +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/server/internal/auth" + "github.com/mrhid6/vantage/server/internal/services" +) + +func listOrgUsers(c *gin.Context) { + users, err := services.ListUsers(auth.OrgID(c)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, users) +} + +func createOrgUser(c *gin.Context) { + var body struct { + Email string `json:"email"` + Password string `json:"password"` + Role string `json:"role"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.Email == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "email required"}) + return + } + if body.Role == "" { + body.Role = "member" + } + u, err := services.CreateUser(auth.OrgID(c), body.Email, body.Password, body.Role, "local") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, u) +} + +func updateOrgUserRole(c *gin.Context) { + var body struct { + Role string `json:"role"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.Role == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "role required"}) + return + } + if err := services.UpdateUserRole(auth.OrgID(c), c.Param("id"), body.Role); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func deleteOrgUser(c *gin.Context) { + if err := services.DeleteUser(auth.OrgID(c), c.Param("id")); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +func getOrgOIDC(c *gin.Context) { + cfg, err := services.GetOrgOIDC(auth.OrgID(c)) + if err != nil { + c.JSON(http.StatusOK, gin.H{"enabled": false}) + return + } + c.JSON(http.StatusOK, cfg) +} + +func putOrgOIDC(c *gin.Context) { + var body struct { + Issuer string `json:"issuer"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + Enabled bool `json:"enabled"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := services.SaveOrgOIDC(auth.OrgID(c), body.Issuer, body.ClientID, body.ClientSecret, body.Enabled); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"saved": true}) +} +``` + +- [ ] **Step 4: Update `handlers.go` route registration.** Replace the auth-endpoint block and add org routes: + +```go + // Unauthenticated auth endpoints + r.GET("/auth/bootstrap-status", auth.HandleBootstrapStatus) + r.POST("/auth/bootstrap", auth.HandleBootstrap) + r.POST("/auth/login", auth.HandleLocalLogin) + r.POST("/auth/logout", auth.HandleLogout) + r.GET("/auth/me", auth.HandleMe) + r.GET("/auth/oidc/start", auth.HandleOIDCStart) + r.GET("/auth/oidc/callback", auth.HandleOIDCCallback) +``` + +And inside the `apiGroup`: + +```go + org := apiGroup.Group("/org") + org.Use(auth.RequireRole("owner", "admin")) + { + org.GET("/users", listOrgUsers) + org.POST("/users", createOrgUser) + org.PUT("/users/:id/role", updateOrgUserRole) + org.DELETE("/users/:id", deleteOrgUser) + org.GET("/oidc", getOrgOIDC) + org.PUT("/oidc", putOrgOIDC) + } +``` + +- [ ] **Step 5: Thread `auth.OrgID(c)` through every existing handler** in `handlers.go`, `secrets.go`, `workflows.go`, `monitors.go`, `channels.go`, `console.go` — pass it as the new leading arg to the scoped service calls (e.g. `services.ListServers(auth.OrgID(c))`, `services.GetServer(auth.OrgID(c), id)`, `services.CreateKey(auth.OrgID(c), ...)`, `services.LogEvent(auth.OrgID(c), ...)`). `actorFromCtx` unchanged. + +- [ ] **Step 6: Verify full build** + +Run: `cd server && go build ./...` +Expected: whole server compiles. + +- [ ] **Step 7: Commit** + +```bash +git add server/internal +git commit -m "feat(server): org-scope remaining services + handlers + org admin API" +``` + +--- + +### Task 10: gRPC — resolve org from server record; install script + +**Files:** +- Modify: `server/internal/grpc/server.go` +- Modify: `server/internal/api/handlers.go` (install script gRPC host unchanged — confirm single `GRPC_HOST`) + +**Interfaces:** +- Consumes: `services.ValidateAgentToken` (returns `*models.Server` with `.OrgID`), `services.BuildDesiredState(serverID)`, `services.RegisterServer`, `services.UploadGeneratedKey` (add `orgID` internally resolved from the server). + +- [ ] **Step 1: In each gRPC handler** (`SyncKeys`, `UploadGeneratedKey`, inventory RPCs), after validating the agent token, use `srv.OrgID` when writing org-scoped documents (e.g. an uploaded generated key gets `srv.OrgID`). `Register` needs no change (row pre-exists with org). Sync already resolves org via `BuildDesiredState`. + +- [ ] **Step 2: Confirm install script uses single `GRPC_HOST`** — no per-org host. No change expected; verify `handleInstallScript` still emits `grpc-vantage...` from `GRPC_HOST`. + +- [ ] **Step 3: Verify build** + +Run: `cd server && go build ./...` +Expected: compiles. + +- [ ] **Step 4: Commit** + +```bash +git add server/internal/grpc +git commit -m "feat(grpc): resolve org from server record for agent RPCs" +``` + +--- + +### Task 11: Frontend — login, setup, org settings, AuthProvider + +**Files:** +- Create: `web/app/login/page.tsx`, `web/app/setup/page.tsx`, `web/app/settings/org/page.tsx` +- Modify: `web/components/AuthProvider.tsx`, `web/components/Sidebar.tsx` + +**Interfaces:** +- Consumes REST: `GET /auth/bootstrap-status`, `POST /auth/bootstrap`, `POST /auth/login`, `GET /auth/me` (now `{user, org}`), `GET/POST/PUT/DELETE /api/org/users`, `GET/PUT /api/org/oidc`, `GET /auth/oidc/start`. + +- [ ] **Step 1: Rewrite `AuthProvider.tsx`** — on mount, call `/auth/bootstrap-status`; if `needs_setup` → redirect `/setup`. Else `/auth/me`; 401 → redirect `/login`. Store `{user, org}` in context. Update the `User`/context types to include `org_id`, `role`, and `org`. + +```tsx +"use client"; + +import { createContext, useContext, useEffect, useState, ReactNode } from "react"; + +export interface SessionUser { + user_id: string; + org_id: string; + role: string; + email: string; + name: string; +} +export interface Org { org_id: string; name: string; slug: string; } + +interface AuthContextType { user: SessionUser | null; org: Org | null; } +const AuthContext = createContext({ user: null, org: null }); +export function useAuth() { return useContext(AuthContext); } + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [org, setOrg] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const path = window.location.pathname; + if (path === "/login" || path === "/setup") { setLoading(false); return; } + fetch("/auth/bootstrap-status", { credentials: "include" }) + .then((r) => r.json()) + .then((b) => { + if (b.needs_setup) { window.location.href = "/setup"; return Promise.reject("setup"); } + return fetch("/auth/me", { credentials: "include" }); + }) + .then(async (res) => { + if (!res) return; + if (res.status === 401) { window.location.href = "/login"; return; } + const data = await res.json(); + setUser(data.user); setOrg(data.org); setLoading(false); + }) + .catch(() => setLoading(false)); + }, []); + + if (loading) { + return ( +
+
+
+ ); + } + return {children}; +} +``` + +- [ ] **Step 2: Create `web/app/login/page.tsx`** — email/password form POSTing `/auth/login` (on success `window.location.href = "/"`), plus a "Sign in with your organization's SSO" button that sends the browser to `/auth/oidc/start` (org comes from the current subdomain host). + +- [ ] **Step 3: Create `web/app/setup/page.tsx`** — first-run form (org name, owner email, password) POSTing `/auth/bootstrap`; on success redirect to `https://.vantage.hostxtra.co.uk/` (use returned `slug`) or `/` in dev. + +- [ ] **Step 4: Create `web/app/settings/org/page.tsx`** — members table (list/create/role/delete via `/api/org/users`), and OIDC provider form (`/api/org/oidc`) showing the exact redirect URL `https:///auth/oidc/callback` for the admin to register. Guard UI by `role` from `useAuth()`. + +- [ ] **Step 5: Update `Sidebar.tsx`** — show current org name + user email; add an "Organization" settings link (visible to owner/admin). + +- [ ] **Step 6: Verify build** + +Run: `cd web && npm run build` +Expected: builds without type errors. + +- [ ] **Step 7: Commit** + +```bash +git add web +git commit -m "feat(web): login, first-run setup, org settings; org-aware AuthProvider" +``` + +--- + +### Task 12: Manual end-to-end verification + +**Files:** none (verification only). + +- [ ] **Step 1: Build everything** + +Run: `cd server && go build ./... && cd ../web && npm run build` +Expected: both succeed. + +- [ ] **Step 2: Document the manual smoke checklist** in the PR description (no automated tests per constraints): fresh DB → `/setup` creates org+owner → login → each existing page (servers/keys/secrets/workflows/monitors) loads scoped data → create a second org via a second owner (separate email) and confirm data isolation → configure org OIDC and confirm `/auth/oidc/start` redirects. Migration: point at a DB with legacy data, confirm default-org backfill and marker. + +- [ ] **Step 3: Final commit (if any doc edits)** + +```bash +git add -A +git commit -m "docs: manual verification checklist for saas auth" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** local auth (T4-5), orgs+slug (T4), per-org OIDC (T7), org_id on all collections (T1), request scoping (T8-9), remove global Authentik (T7), migration (T2), host resolution (T6), gRPC org (T10), frontend (T11), roles/RequireRole (T9). Billing/tests correctly omitted. +- **Deferred/immutable:** slug rename deferred (spec); `redirect_url` derived from host, not stored (Task 7 matches updated spec §3/§4). +- **Ordering risk:** Task 8 leaves `api/*` temporarily non-compiling; that's why Task 8 builds only sub-packages and Task 9 restores a full build. Reviewer should treat T8+T9 as a pair. +- **Cross-org guard:** enforced in service filters (primary) + host/session match (T6, defense-in-depth) + OIDC org-binding via state (T7). 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 index 8c774b8..71c86d3 100644 --- a/docs/superpowers/specs/2026-07-20-saas-auth-orgs-design.md +++ b/docs/superpowers/specs/2026-07-20-saas-auth-orgs-design.md @@ -11,7 +11,7 @@ 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) carries an `org_id` and all queries are scoped to the caller's org. +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). @@ -29,6 +29,7 @@ No billing, no seat/server limits this iteration (schema leaves room). | 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. | --- @@ -36,8 +37,12 @@ No billing, no seat/server limits this iteration (schema leaves room). ### `orgs` ```json -{ "_id":"ObjectId", "org_id":"uuid", "name":"Acme", "created_at":"ISODate" } +{ "_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 @@ -55,13 +60,14 @@ Unique index on `email` (global — email identifies the account and its org). "_id":"ObjectId", "org_id":"uuid", "issuer":"https://id.acme.com", "client_id":"...", "client_secret_enc":"AES...", // encrypted with existing crypto.go - "redirect_url":"https://vantage.../auth/oidc/callback", "enabled": true, "updated_at":"ISODate" } ``` ### Existing collections — add `org_id` -`servers`, `keys`, `assignments`, `secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `audit` each gain `org_id string`. A **migration** backfills all existing documents into a default org (see §7). +`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. --- @@ -77,8 +83,8 @@ Unique index on `email` (global — email identifies the account and its org). - `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. -- `GET /auth/oidc/start?org=` — look up org's `org_oidc`, build the OIDC provider on demand (cache per org), redirect to authorize. +- `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. @@ -95,6 +101,14 @@ Unique index on `email` (global — email identifies the account and its org). - 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 @@ -108,8 +122,10 @@ Unique index on `email` (global — email identifies the account and its org). ## 7. Migration One-shot migration run at startup (idempotent): -1. If `orgs` is empty AND `servers`/`keys`/etc. contain documents without `org_id`: create a **default org** ("Default"). -2. Set `org_id = ` on all existing `servers`, `keys`, `assignments`, `secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `audit` documents missing it. +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. diff --git a/server/cmd/main.go b/server/cmd/main.go index cec38b2..4e203bd 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -24,18 +24,51 @@ func main() { } log.Println("connected to MongoDB") + // The unique indexes are a security property: GetUserByEmail does an + // unscoped FindOne, so duplicate (or blank) emails let the OIDC callback's + // cross-org guard compare against an arbitrary user, and duplicate org slugs + // make host-based org resolution pick one at random. + if err := services.EnsureAuthIndexes(); err != nil { + log.Fatalf("failed to ensure auth indexes: %v", err) + } + if err := services.RunMigrations(); err != nil { + log.Fatalf("migration failed: %v", err) + } + // Must run before the unique settings indexes are built, and before 0003: + // 0003 can create a "default" org, which would push 0002 into its ambiguous + // multi-org branch and leave the settings doc unstamped. + if err := services.MigrateSettingsOrg(); err != nil { + log.Fatalf("settings org migration failed: %v", err) + } + if err := services.MigrateMissedOrgScopes(); err != nil { + log.Fatalf("missed org scope migration failed: %v", err) + } + if err := services.EnsureSecretIndexes(); err != nil { log.Printf("warning: failed to ensure secret indexes: %v", err) } + // The unique indexes are a security property: duplicate settings docs make + // GetSettings return an arbitrary one, and duplicate ESO token hashes make + // ResolveSecretsReadToken pick an arbitrary org. + if err := services.EnsureSettingsIndexes(); err != nil { + log.Fatalf("failed to ensure settings indexes: %v", err) + } + if err := services.EnsureWorkflowIndexes(); err != nil { log.Printf("warning: failed to ensure workflow indexes: %v", err) } - if created, updated, err := services.SeedDefaultSteps(); err != nil { - log.Printf("warning: failed to seed default steps: %v", err) + if orgIDs, err := services.ListOrgIDs(); err != nil { + log.Printf("warning: failed to list orgs for default step seeding: %v", err) } else { - log.Printf("default steps seeded: %d created, %d updated", created, updated) + for _, orgID := range orgIDs { + if created, updated, err := services.SeedDefaultSteps(orgID); err != nil { + log.Printf("warning: failed to seed default steps for org %s: %v", orgID, err) + } else { + log.Printf("default steps seeded for org %s: %d created, %d updated", orgID, created, updated) + } + } } services.StartLogSweeper() @@ -46,10 +79,6 @@ func main() { } log.Println("connected to Redis") - if err := auth.InitOIDC(context.Background()); err != nil { - log.Fatalf("failed to initialise OIDC: %v", err) - } - // Background goroutine to mark offline servers go func() { ticker := time.NewTicker(2 * time.Minute) diff --git a/server/internal/api/channels.go b/server/internal/api/channels.go index f707904..790edc5 100644 --- a/server/internal/api/channels.go +++ b/server/internal/api/channels.go @@ -4,6 +4,7 @@ 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" "go.mongodb.org/mongo-driver/v2/bson" @@ -18,7 +19,7 @@ func registerChannelRoutes(g *gin.RouterGroup) { } func listChannels(c *gin.Context) { - channels, err := services.ListChannels() + channels, err := services.ListChannels(auth.OrgID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -36,7 +37,7 @@ func createChannel(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "name and type are required"}) return } - created, err := services.CreateChannel(&ch) + created, err := services.CreateChannel(auth.OrgID(c), &ch) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -72,7 +73,7 @@ func updateChannel(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) return } - if err := services.UpdateChannel(c.Param("id"), upd); err != nil { + if err := services.UpdateChannel(auth.OrgID(c), c.Param("id"), upd); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -80,7 +81,7 @@ func updateChannel(c *gin.Context) { } func deleteChannel(c *gin.Context) { - if err := services.DeleteChannel(c.Param("id")); err != nil { + if err := services.DeleteChannel(auth.OrgID(c), c.Param("id")); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -88,7 +89,7 @@ func deleteChannel(c *gin.Context) { } func testChannel(c *gin.Context) { - if err := services.TestChannel(c.Param("id")); err != nil { + if err := services.TestChannel(auth.OrgID(c), c.Param("id")); err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } diff --git a/server/internal/api/console.go b/server/internal/api/console.go index bcc390d..9948913 100644 --- a/server/internal/api/console.go +++ b/server/internal/api/console.go @@ -8,6 +8,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/server/internal/auth" "github.com/mrhid6/vantage/server/internal/services" "github.com/wwt/guac" ) @@ -29,13 +30,13 @@ func consoleConnect(c *gin.Context) { return } - srv, err := services.GetServer(body.ServerID) + srv, err := services.GetServer(auth.OrgID(c), body.ServerID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) return } - sess, err := services.CreateConsoleSession(body.ServerID, body.Protocol, body.KeyID, actorFromCtx(c), c.ClientIP()) + sess, err := services.CreateConsoleSession(auth.OrgID(c), body.ServerID, body.Protocol, body.KeyID, actorFromCtx(c), c.ClientIP()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -47,20 +48,20 @@ func consoleConnect(c *gin.Context) { } if (body.Protocol == "rdp" || body.Protocol == "vnc") && (body.RDPUsername != "" || body.RDPPassword != "") { - if err := services.StashConsoleRDPCreds(sess.SessionID, body.RDPUsername, body.RDPPassword); err != nil { + if err := services.StashConsoleRDPCreds(auth.OrgID(c), sess.SessionID, body.RDPUsername, body.RDPPassword); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } } if body.Protocol == "ssh" { - if err := services.SetConsoleSSHUser(sess.SessionID, body.SSHUsername); err != nil { + if err := services.SetConsoleSSHUser(auth.OrgID(c), sess.SessionID, body.SSHUsername); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } } - services.LogEvent("console.opened", actorFromCtx(c), srv.ServerID, "", + services.LogEvent(auth.OrgID(c), "console.opened", actorFromCtx(c), srv.ServerID, "", "console session opened ("+body.Protocol+")") c.JSON(http.StatusOK, gin.H{ @@ -88,7 +89,8 @@ func consoleTunnel(c *gin.Context) { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) return } - sess, err := services.GetConsoleSession(sessionID) + orgID := auth.OrgID(c) + sess, err := services.GetConsoleSession(orgID, sessionID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) return @@ -102,12 +104,12 @@ func consoleTunnel(c *gin.Context) { } // Single-use: atomically spend the token so a replay within its TTL is rejected. - if err := services.ConsumeSessionToken(sessionID); err != nil { + if err := services.ConsumeSessionToken(orgID, sessionID); err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "token already used"}) return } - srv, err := services.GetServer(sess.ServerID) + srv, err := services.GetServer(auth.OrgID(c), sess.ServerID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) return @@ -116,7 +118,7 @@ func consoleTunnel(c *gin.Context) { // Decrypt private key + passphrase in-memory only (ssh). var privKey, passphrase string if sess.Protocol == "ssh" && sess.KeyID != "" { - privKey, err = services.GetPrivateKey(sess.KeyID) + privKey, err = services.GetPrivateKey(auth.OrgID(c), sess.KeyID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "selected key has no private material"}) return @@ -126,7 +128,7 @@ func consoleTunnel(c *gin.Context) { var rdpUser, rdpPass string if sess.Protocol == "rdp" || sess.Protocol == "vnc" { - rdpUser, rdpPass, err = services.ConsumeConsoleRDPCreds(sessionID) + rdpUser, rdpPass, err = services.ConsumeConsoleRDPCreds(orgID, sessionID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "could not load credentials"}) return @@ -171,7 +173,7 @@ func consoleTunnel(c *gin.Context) { wsServer := guac.NewWebsocketServer(connect) wsServer.OnDisconnect = func(id string, r *http.Request, t guac.Tunnel) { - _ = services.EndConsoleSession(sessionID) + _ = services.EndConsoleSession(orgID, sessionID) } wsServer.ServeHTTP(c.Writer, c.Request) } diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 7132c12..a49ba40 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -32,11 +32,14 @@ func RegisterRoutes(r *gin.Engine) { // group as flat JSON. r.GET("/api/secrets/:group/values", secretsReadAuth(), esoGetGroup) - // Auth endpoints (no session required) - r.GET("/auth/login", auth.HandleLogin) - r.GET("/auth/callback", auth.HandleCallback) - r.GET("/auth/logout", auth.HandleLogout) + // 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) // API endpoints protected by session middleware apiGroup := r.Group("/api") @@ -56,9 +59,13 @@ func RegisterRoutes(r *gin.Engine) { apiGroup.GET("/audit", listAuditEvents) - apiGroup.GET("/settings", getSettings) - apiGroup.PUT("/settings", saveSettings) - apiGroup.POST("/settings/secrets-token", rotateSecretsToken) + settings := apiGroup.Group("/settings") + settings.Use(auth.RequireRole("owner", "admin")) + { + settings.GET("", getSettings) + settings.PUT("", saveSettings) + settings.POST("/secrets-token", rotateSecretsToken) + } apiGroup.GET("/secrets", listSecretGroups) apiGroup.POST("/secrets", createSecretGroup) @@ -82,11 +89,22 @@ func RegisterRoutes(r *gin.Engine) { registerWorkflowRoutes(apiGroup) registerMonitorRoutes(apiGroup) registerChannelRoutes(apiGroup) + + 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) + } } } func listServers(c *gin.Context) { - servers, err := services.ListServers() + servers, err := services.ListServers(auth.OrgID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -95,7 +113,7 @@ func listServers(c *gin.Context) { } func createServer(c *gin.Context) { - s, token, err := services.CreateServer() + s, token, err := services.CreateServer(auth.OrgID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -108,12 +126,12 @@ func createServer(c *gin.Context) { } func newServer(c *gin.Context) { - s, token, err := services.CreateServer() + s, token, err := services.CreateServer(auth.OrgID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("server.created", actorFromCtx(c), s.ServerID, "", "pre-registration token issued") + services.LogEvent(auth.OrgID(c), "server.created", actorFromCtx(c), s.ServerID, "", "pre-registration token issued") giteaHost := os.Getenv("GITEA_HOST") if giteaHost == "" { @@ -144,13 +162,13 @@ func newServer(c *gin.Context) { func getServer(c *gin.Context) { id := c.Param("id") - s, err := services.GetServer(id) + s, err := services.GetServer(auth.OrgID(c), id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) return } - assignments, _ := services.GetAssignmentsWithKeysForServer(id) + assignments, _ := services.GetAssignmentsWithKeysForServer(auth.OrgID(c), id) // Build response matching ServerWithKeys shape expected by frontend type serverResponse struct { @@ -165,8 +183,8 @@ func getServer(c *gin.Context) { func deleteServer(c *gin.Context) { id := c.Param("id") - s, _ := services.GetServer(id) - if err := services.DeleteServer(id); err != nil { + s, _ := services.GetServer(auth.OrgID(c), id) + if err := services.DeleteServer(auth.OrgID(c), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -174,7 +192,7 @@ func deleteServer(c *gin.Context) { if s != nil { hostname = s.Hostname } - services.LogEvent("server.deleted", actorFromCtx(c), id, "", fmt.Sprintf("server %s deleted", hostname)) + services.LogEvent(auth.OrgID(c), "server.deleted", actorFromCtx(c), id, "", fmt.Sprintf("server %s deleted", hostname)) c.JSON(http.StatusOK, gin.H{"deleted": true}) } @@ -193,7 +211,7 @@ func generateKey(c *gin.Context) { body.Label = "generated" } - s, err := services.GetServer(id) + s, err := services.GetServer(auth.OrgID(c), id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) return @@ -211,7 +229,7 @@ func generateKey(c *gin.Context) { return } - services.LogEvent("key.generation_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("key generation dispatched (label=%s type=%s)", body.Label, body.KeyType)) + services.LogEvent(auth.OrgID(c), "key.generation_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("key generation dispatched (label=%s type=%s)", body.Label, body.KeyType)) c.JSON(http.StatusAccepted, gin.H{ "message": "key generation command sent to agent", "command_id": cmdID, @@ -220,7 +238,7 @@ func generateKey(c *gin.Context) { } func listKeys(c *gin.Context) { - keys, err := services.ListKeys() + keys, err := services.ListKeys(auth.OrgID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -240,18 +258,18 @@ func createKey(c *gin.Context) { return } - key, err := services.CreateKey(body.Label, body.PublicKey, "uploaded", "", body.PrivateKey, body.Passphrase) + key, err := services.CreateKey(auth.OrgID(c), body.Label, body.PublicKey, "uploaded", "", body.PrivateKey, body.Passphrase) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("key.uploaded", actorFromCtx(c), "", key.KeyID, fmt.Sprintf("key '%s' uploaded", key.Label)) + services.LogEvent(auth.OrgID(c), "key.uploaded", actorFromCtx(c), "", key.KeyID, fmt.Sprintf("key '%s' uploaded", key.Label)) c.JSON(http.StatusCreated, key) } func getPrivateKey(c *gin.Context) { id := c.Param("id") - plaintext, err := services.GetPrivateKey(id) + plaintext, err := services.GetPrivateKey(auth.OrgID(c), id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return @@ -261,13 +279,13 @@ func getPrivateKey(c *gin.Context) { func getKey(c *gin.Context) { id := c.Param("id") - key, err := services.GetKey(id) + key, err := services.GetKey(auth.OrgID(c), id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "key not found"}) return } - assignments, _ := services.GetAssignmentsWithServers(id) + assignments, _ := services.GetAssignmentsWithServers(auth.OrgID(c), id) type keyResponse struct { *models.Key @@ -281,8 +299,8 @@ func getKey(c *gin.Context) { func deleteKey(c *gin.Context) { id := c.Param("id") - k, _ := services.GetKey(id) - if err := services.DeleteKey(id); err != nil { + k, _ := services.GetKey(auth.OrgID(c), id) + if err := services.DeleteKey(auth.OrgID(c), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -290,7 +308,7 @@ func deleteKey(c *gin.Context) { if k != nil { label = k.Label } - services.LogEvent("key.deleted", actorFromCtx(c), "", id, fmt.Sprintf("key '%s' deleted", label)) + services.LogEvent(auth.OrgID(c), "key.deleted", actorFromCtx(c), "", id, fmt.Sprintf("key '%s' deleted", label)) c.JSON(http.StatusOK, gin.H{"deleted": true}) } @@ -304,12 +322,12 @@ func assignKey(c *gin.Context) { return } - a, err := services.AssignKey(keyID, body.ServerID) + a, err := services.AssignKey(auth.OrgID(c), keyID, body.ServerID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("key.assigned", actorFromCtx(c), body.ServerID, keyID, fmt.Sprintf("key %s assigned to server %s", keyID, body.ServerID)) + services.LogEvent(auth.OrgID(c), "key.assigned", actorFromCtx(c), body.ServerID, keyID, fmt.Sprintf("key %s assigned to server %s", keyID, body.ServerID)) c.JSON(http.StatusCreated, a) } @@ -317,11 +335,11 @@ func revokeAssignment(c *gin.Context) { keyID := c.Param("id") serverID := c.Param("serverId") - if err := services.RevokeAssignment(keyID, serverID); err != nil { + if err := services.RevokeAssignment(auth.OrgID(c), keyID, serverID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("key.revoked", actorFromCtx(c), serverID, keyID, fmt.Sprintf("key %s revoked from server %s", keyID, serverID)) + services.LogEvent(auth.OrgID(c), "key.revoked", actorFromCtx(c), serverID, keyID, fmt.Sprintf("key %s revoked from server %s", keyID, serverID)) c.JSON(http.StatusOK, gin.H{"revoked": true}) } @@ -336,7 +354,7 @@ func getLatestAgentVersion(c *gin.Context) { func updateAgent(c *gin.Context) { id := c.Param("id") - s, err := services.GetServer(id) + s, err := services.GetServer(auth.OrgID(c), id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) return @@ -347,7 +365,7 @@ func updateAgent(c *gin.Context) { c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) return } - services.LogEvent("agent.update_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("agent update dispatched to %s (version %s)", s.Hostname, version)) + services.LogEvent(auth.OrgID(c), "agent.update_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("agent update dispatched to %s (version %s)", s.Hostname, version)) c.JSON(http.StatusAccepted, gin.H{ "message": "update command sent to agent", "version": version, @@ -356,7 +374,7 @@ func updateAgent(c *gin.Context) { func applyUpdates(c *gin.Context) { id := c.Param("id") - s, err := services.GetServer(id) + s, err := services.GetServer(auth.OrgID(c), id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) return @@ -366,7 +384,7 @@ func applyUpdates(c *gin.Context) { c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) return } - services.LogEvent("updates.applied", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("package update command dispatched to %s", s.Hostname)) + services.LogEvent(auth.OrgID(c), "updates.applied", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("package update command dispatched to %s", s.Hostname)) c.JSON(http.StatusAccepted, gin.H{"message": "apply updates command sent to agent"}) } @@ -433,7 +451,7 @@ func listAuditEvents(c *gin.Context) { limit = n } } - events, err := services.ListAuditEvents(limit) + events, err := services.ListAuditEvents(auth.OrgID(c), limit) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -442,7 +460,7 @@ func listAuditEvents(c *gin.Context) { } func getSettings(c *gin.Context) { - s, err := services.GetSettings() + s, err := services.GetSettings(auth.OrgID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -460,11 +478,11 @@ func saveSettings(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := services.SaveSettings(body.Alerts, body.Email, body.WorkflowLogRetentionDays); err != nil { + if err := services.SaveSettings(auth.OrgID(c), body.Alerts, body.Email, body.WorkflowLogRetentionDays); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("settings.updated", actorFromCtx(c), "", "", "alert settings updated") + services.LogEvent(auth.OrgID(c), "settings.updated", actorFromCtx(c), "", "", "alert settings updated") c.JSON(http.StatusOK, gin.H{"saved": true}) } diff --git a/server/internal/api/monitors.go b/server/internal/api/monitors.go index 2e5f614..0a63c6d 100644 --- a/server/internal/api/monitors.go +++ b/server/internal/api/monitors.go @@ -5,6 +5,7 @@ import ( "time" "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" "go.mongodb.org/mongo-driver/v2/bson" @@ -21,7 +22,7 @@ func registerMonitorRoutes(g *gin.RouterGroup) { } func listMonitors(c *gin.Context) { - monitors, err := services.ListMonitors() + monitors, err := services.ListMonitors(auth.OrgID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -39,7 +40,7 @@ func createMonitor(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "name and type are required"}) return } - created, err := services.CreateMonitor(&m) + created, err := services.CreateMonitor(auth.OrgID(c), &m) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -48,7 +49,7 @@ func createMonitor(c *gin.Context) { } func getMonitor(c *gin.Context) { - m, err := services.GetMonitor(c.Param("id")) + m, err := services.GetMonitor(auth.OrgID(c), c.Param("id")) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -104,7 +105,7 @@ func updateMonitor(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) return } - if err := services.UpdateMonitor(c.Param("id"), upd); err != nil { + if err := services.UpdateMonitor(auth.OrgID(c), c.Param("id"), upd); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -112,7 +113,7 @@ func updateMonitor(c *gin.Context) { } func deleteMonitor(c *gin.Context) { - if err := services.DeleteMonitor(c.Param("id")); err != nil { + if err := services.DeleteMonitor(auth.OrgID(c), c.Param("id")); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -120,7 +121,16 @@ func deleteMonitor(c *gin.Context) { } func getMonitorIncidents(c *gin.Context) { - incidents, err := services.ListIncidents(c.Param("id"), 50) + m, err := services.GetMonitor(auth.OrgID(c), c.Param("id")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if m == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "monitor not found"}) + return + } + incidents, err := services.ListIncidents(auth.OrgID(c), c.Param("id"), 50) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -129,8 +139,17 @@ func getMonitorIncidents(c *gin.Context) { } func getMonitorUptime(c *gin.Context) { + m, err := services.GetMonitor(auth.OrgID(c), c.Param("id")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if m == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "monitor not found"}) + return + } since := time.Now().Add(-30 * 24 * time.Hour) - rollups, err := services.UptimeRollups(c.Param("id"), since) + rollups, err := services.UptimeRollups(auth.OrgID(c), c.Param("id"), since) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/server/internal/api/org.go b/server/internal/api/org.go new file mode 100644 index 0000000..a736ac7 --- /dev/null +++ b/server/internal/api/org.go @@ -0,0 +1,162 @@ +package api + +import ( + "errors" + "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) +} + +// Granting or removing the owner role is reserved to owners: an admin must +// never be able to mint an owner (and log in as it) or strip the owners above +// them. Everything below derives the actor from the session, never the body. +func actorMayGrantOwner(c *gin.Context) bool { + return auth.Role(c) == models.RoleOwner +} + +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 = models.RoleMember + } + if !models.ValidRole(body.Role) { + c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"}) + return + } + if body.Role == models.RoleOwner && !actorMayGrantOwner(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can create another owner"}) + return + } + 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 !models.ValidRole(body.Role) { + c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"}) + return + } + + orgID, targetID := auth.OrgID(c), c.Param("id") + if targetID == auth.UserID(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "you cannot change your own role"}) + return + } + target, err := services.GetUserInOrg(orgID, targetID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + if (body.Role == models.RoleOwner || target.Role == models.RoleOwner) && !actorMayGrantOwner(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can change owner roles"}) + return + } + + if err := services.UpdateUserRole(orgID, targetID, body.Role); err != nil { + c.JSON(orgUserErrStatus(err), gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func deleteOrgUser(c *gin.Context) { + orgID, targetID := auth.OrgID(c), c.Param("id") + if targetID == auth.UserID(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "you cannot remove your own account"}) + return + } + target, err := services.GetUserInOrg(orgID, targetID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + if target.Role == models.RoleOwner && !actorMayGrantOwner(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can remove another owner"}) + return + } + + if err := services.DeleteUser(orgID, targetID); err != nil { + c.JSON(orgUserErrStatus(err), gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +// The last-owner guard is a rejected request, not a server fault — surface it +// as 409 so the UI shows the message rather than a generic failure. +func orgUserErrStatus(err error) int { + if errors.Is(err, services.ErrLastOwner) { + return http.StatusConflict + } + return http.StatusInternalServerError +} + +func getOrgOIDC(c *gin.Context) { + cfg, err := services.GetOrgOIDC(auth.OrgID(c)) + if err != nil { + c.JSON(http.StatusOK, gin.H{"enabled": false, "client_secret_set": false}) + return + } + // The client secret itself is write-only (never serialized); expose only + // whether one is stored so the UI can say so without leaking it. + c.JSON(http.StatusOK, gin.H{ + "org_id": cfg.OrgID, + "issuer": cfg.Issuer, + "client_id": cfg.ClientID, + "enabled": cfg.Enabled, + "updated_at": cfg.UpdatedAt, + "client_secret_set": cfg.ClientSecretEnc != "", + }) +} + +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 + } + // Drop the cached provider so a rotated issuer takes effect immediately — + // an admin moving off a compromised IdP must not keep authenticating there. + auth.EvictOIDCProvider(auth.OrgID(c)) + c.JSON(http.StatusOK, gin.H{"saved": true}) +} diff --git a/server/internal/api/secrets.go b/server/internal/api/secrets.go index 9dacd28..01e17ed 100644 --- a/server/internal/api/secrets.go +++ b/server/internal/api/secrets.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/server/internal/auth" "github.com/mrhid6/vantage/server/internal/services" ) @@ -18,19 +19,29 @@ func validName(s string) bool { return s != "" && len(s) <= 128 && groupNamePattern.MatchString(s) } -// secretsReadAuth validates the ESO bearer token on the public read endpoint. +// ctxSecretsOrgKey carries the org resolved from the ESO bearer token. +const ctxSecretsOrgKey = "km_secrets_org" + +// secretsReadAuth validates the ESO bearer token on the public read endpoint +// and stashes the org the token belongs to. +// +// This is the one endpoint whose org does NOT come from the session or the +// host: External Secrets Operator calls it machine-to-machine with no session, +// so the token itself is the org-bearing credential. func secretsReadAuth() gin.HandlerFunc { return func(c *gin.Context) { const prefix = "Bearer " - auth := c.GetHeader("Authorization") - if len(auth) <= len(prefix) || !strings.EqualFold(auth[:len(prefix)], prefix) { + authHeader := c.GetHeader("Authorization") + if len(authHeader) <= len(prefix) || !strings.EqualFold(authHeader[:len(prefix)], prefix) { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"}) return } - if !services.VerifySecretsReadToken(auth[len(prefix):]) { + orgID, ok := services.ResolveSecretsReadToken(authHeader[len(prefix):]) + if !ok { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) return } + c.Set(ctxSecretsOrgKey, orgID) c.Next() } } @@ -40,7 +51,14 @@ func secretsReadAuth() gin.HandlerFunc { // (ESO treats 404 as "deleted"). func esoGetGroup(c *gin.Context) { group := c.Param("group") - values, err := services.GetSecretGroupDecrypted(group) + // Org comes from the bearer token (set by secretsReadAuth), not a session. + orgID := c.GetString(ctxSecretsOrgKey) + if orgID == "" { + // Defence in depth: never query the store unscoped. + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + values, err := services.GetSecretGroupDecrypted(orgID, group) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "store error"}) return @@ -53,7 +71,7 @@ func esoGetGroup(c *gin.Context) { } func listSecretGroups(c *gin.Context) { - groups, err := services.ListSecretGroups() + groups, err := services.ListSecretGroups(auth.OrgID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -86,17 +104,17 @@ func createSecretGroup(c *gin.Context) { return } } - if err := services.UpsertSecrets(body.Group, body.Values); err != nil { + if err := services.UpsertSecrets(auth.OrgID(c), body.Group, body.Values); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' created with keys: %s", body.Group, strings.Join(services.SortedKeys(body.Values), ", "))) + services.LogEvent(auth.OrgID(c), "secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' created with keys: %s", body.Group, strings.Join(services.SortedKeys(body.Values), ", "))) c.JSON(http.StatusCreated, gin.H{"group": body.Group}) } func getSecretGroup(c *gin.Context) { group := c.Param("group") - secrets, err := services.GetSecretGroup(group) + secrets, err := services.GetSecretGroup(auth.OrgID(c), group) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -130,11 +148,11 @@ func putSecretGroup(c *gin.Context) { return } } - if err := services.UpsertSecrets(group, values); err != nil { + if err := services.UpsertSecrets(auth.OrgID(c), group, values); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' keys updated: %s", group, strings.Join(services.SortedKeys(values), ", "))) + services.LogEvent(auth.OrgID(c), "secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' keys updated: %s", group, strings.Join(services.SortedKeys(values), ", "))) c.JSON(http.StatusOK, gin.H{"saved": true}) } @@ -147,42 +165,42 @@ func revealSecret(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - value, err := services.RevealSecret(group, body.Key) + value, err := services.RevealSecret(auth.OrgID(c), group, body.Key) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return } - services.LogEvent("secret.revealed", actorFromCtx(c), "", "", fmt.Sprintf("value of '%s/%s' revealed", group, body.Key)) + services.LogEvent(auth.OrgID(c), "secret.revealed", actorFromCtx(c), "", "", fmt.Sprintf("value of '%s/%s' revealed", group, body.Key)) c.JSON(http.StatusOK, gin.H{"value": value}) } func deleteSecretKey(c *gin.Context) { group := c.Param("group") key := c.Param("key") - if err := services.DeleteSecret(group, key); err != nil { + if err := services.DeleteSecret(auth.OrgID(c), group, key); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("secret.deleted", actorFromCtx(c), "", "", fmt.Sprintf("key '%s' deleted from group '%s'", key, group)) + services.LogEvent(auth.OrgID(c), "secret.deleted", actorFromCtx(c), "", "", fmt.Sprintf("key '%s' deleted from group '%s'", key, group)) c.JSON(http.StatusOK, gin.H{"deleted": true}) } func deleteSecretGroup(c *gin.Context) { group := c.Param("group") - if err := services.DeleteSecretGroup(group); err != nil { + if err := services.DeleteSecretGroup(auth.OrgID(c), group); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("secretgroup.deleted", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' deleted", group)) + services.LogEvent(auth.OrgID(c), "secretgroup.deleted", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' deleted", group)) c.JSON(http.StatusOK, gin.H{"deleted": true}) } func rotateSecretsToken(c *gin.Context) { - token, err := services.RotateSecretsReadToken() + token, err := services.RotateSecretsReadToken(auth.OrgID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("secrets.token_rotated", actorFromCtx(c), "", "", "ESO read token rotated") + services.LogEvent(auth.OrgID(c), "secrets.token_rotated", actorFromCtx(c), "", "", "ESO read token rotated") c.JSON(http.StatusOK, gin.H{"token": token}) } diff --git a/server/internal/api/workflows.go b/server/internal/api/workflows.go index 8ca8248..02b3dee 100644 --- a/server/internal/api/workflows.go +++ b/server/internal/api/workflows.go @@ -11,6 +11,7 @@ import ( "time" "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" ) @@ -105,9 +106,10 @@ func streamServerRunLog(c *gin.Context) { ctx := c.Request.Context() ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() + orgID := auth.OrgID(c) for { sendNew() - if serverRunTerminal(runID, serverID) { + if serverRunTerminal(orgID, runID, serverID) { sendNew() // final drain _, _ = c.Writer.WriteString("event: done\ndata: end\n\n") flusher.Flush() @@ -122,8 +124,8 @@ func streamServerRunLog(c *gin.Context) { } // serverRunTerminal reports whether the given server-run has reached a terminal status. -func serverRunTerminal(runID, serverID string) bool { - r, err := services.GetRun(runID) +func serverRunTerminal(orgID, runID, serverID string) bool { + r, err := services.GetRun(orgID, runID) if err != nil { return true } @@ -147,7 +149,7 @@ func splitSSE(b []byte) []string { } func listSteps(c *gin.Context) { - steps, err := services.ListSteps() + steps, err := services.ListSteps(auth.OrgID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -156,7 +158,7 @@ func listSteps(c *gin.Context) { } func stepUsage(c *gin.Context) { - counts, err := services.StepUsageCounts() + counts, err := services.StepUsageCounts(auth.OrgID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -170,12 +172,12 @@ func createStep(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - out, err := services.CreateStep(s) + out, err := services.CreateStep(auth.OrgID(c), s) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("workflow.step_created", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' created", out.Name)) + services.LogEvent(auth.OrgID(c), "workflow.step_created", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' created", out.Name)) c.JSON(http.StatusCreated, out) } @@ -185,25 +187,25 @@ func updateStep(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := services.UpdateStep(c.Param("id"), s); err != nil { + if err := services.UpdateStep(auth.OrgID(c), c.Param("id"), s); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("workflow.step_updated", actorFromCtx(c), "", c.Param("id"), "step updated") + services.LogEvent(auth.OrgID(c), "workflow.step_updated", actorFromCtx(c), "", c.Param("id"), "step updated") c.JSON(http.StatusOK, gin.H{"updated": true}) } func deleteStep(c *gin.Context) { - if err := services.DeleteStep(c.Param("id")); err != nil { + if err := services.DeleteStep(auth.OrgID(c), c.Param("id")); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("workflow.step_deleted", actorFromCtx(c), "", c.Param("id"), "step deleted") + services.LogEvent(auth.OrgID(c), "workflow.step_deleted", actorFromCtx(c), "", c.Param("id"), "step deleted") c.JSON(http.StatusOK, gin.H{"deleted": true}) } func exportStep(c *gin.Context) { - b, err := services.ExportStep(c.Param("id")) + b, err := services.ExportStep(auth.OrgID(c), c.Param("id")) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return @@ -213,12 +215,12 @@ func exportStep(c *gin.Context) { } func seedDefaults(c *gin.Context) { - created, updated, err := services.SeedDefaultSteps() + created, updated, err := services.SeedDefaultSteps(auth.OrgID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("workflow.defaults_synced", actorFromCtx(c), "", "", fmt.Sprintf("default steps synced: %d created, %d updated", created, updated)) + services.LogEvent(auth.OrgID(c), "workflow.defaults_synced", actorFromCtx(c), "", "", fmt.Sprintf("default steps synced: %d created, %d updated", created, updated)) c.JSON(http.StatusOK, gin.H{"created": created, "updated": updated}) } @@ -231,12 +233,12 @@ func importStep(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - out, err := services.ImportStepToLibrary(body) + out, err := services.ImportStepToLibrary(auth.OrgID(c), body) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - services.LogEvent("workflow.step_imported", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' imported", out.Name)) + services.LogEvent(auth.OrgID(c), "workflow.step_imported", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' imported", out.Name)) c.JSON(http.StatusCreated, out) } @@ -258,7 +260,7 @@ func parseStep(c *gin.Context) { } func listWorkflows(c *gin.Context) { - wfs, err := services.ListWorkflows() + wfs, err := services.ListWorkflows(auth.OrgID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -272,17 +274,17 @@ func createWorkflow(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - out, err := services.CreateWorkflow(w) + out, err := services.CreateWorkflow(auth.OrgID(c), w) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("workflow.created", actorFromCtx(c), "", out.WorkflowID, fmt.Sprintf("workflow '%s' created", out.Name)) + services.LogEvent(auth.OrgID(c), "workflow.created", actorFromCtx(c), "", out.WorkflowID, fmt.Sprintf("workflow '%s' created", out.Name)) c.JSON(http.StatusCreated, out) } func getWorkflow(c *gin.Context) { - w, err := services.GetWorkflow(c.Param("id")) + w, err := services.GetWorkflow(auth.OrgID(c), c.Param("id")) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return @@ -296,12 +298,12 @@ func updateWorkflow(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := services.UpdateWorkflow(c.Param("id"), w); err != nil { + if err := services.UpdateWorkflow(auth.OrgID(c), c.Param("id"), w); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("workflow.updated", actorFromCtx(c), "", c.Param("id"), "workflow updated") - updated, err := services.GetWorkflow(c.Param("id")) + services.LogEvent(auth.OrgID(c), "workflow.updated", actorFromCtx(c), "", c.Param("id"), "workflow updated") + updated, err := services.GetWorkflow(auth.OrgID(c), c.Param("id")) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -310,21 +312,21 @@ func updateWorkflow(c *gin.Context) { } func deleteWorkflow(c *gin.Context) { - if err := services.DeleteWorkflow(c.Param("id")); err != nil { + if err := services.DeleteWorkflow(auth.OrgID(c), c.Param("id")); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("workflow.deleted", actorFromCtx(c), "", c.Param("id"), "workflow deleted") + services.LogEvent(auth.OrgID(c), "workflow.deleted", actorFromCtx(c), "", c.Param("id"), "workflow deleted") c.JSON(http.StatusOK, gin.H{"deleted": true}) } func runWorkflow(c *gin.Context) { - runID, err := services.TriggerWorkflow(c.Param("id"), actorFromCtx(c)) + runID, err := services.TriggerWorkflow(auth.OrgID(c), c.Param("id"), actorFromCtx(c)) if err != nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) return } - services.LogEvent("workflow.run_triggered", actorFromCtx(c), "", c.Param("id"), fmt.Sprintf("run %s triggered", runID)) + services.LogEvent(auth.OrgID(c), "workflow.run_triggered", actorFromCtx(c), "", c.Param("id"), fmt.Sprintf("run %s triggered", runID)) c.JSON(http.StatusAccepted, gin.H{"run_id": runID}) } @@ -335,7 +337,7 @@ func listWorkflowRuns(c *gin.Context) { limit = n } } - runs, err := services.ListRuns(c.Param("id"), limit) + runs, err := services.ListRuns(auth.OrgID(c), c.Param("id"), limit) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -344,7 +346,7 @@ func listWorkflowRuns(c *gin.Context) { } func getRun(c *gin.Context) { - r, err := services.GetRun(c.Param("runId")) + r, err := services.GetRun(auth.OrgID(c), c.Param("runId")) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return @@ -353,10 +355,10 @@ func getRun(c *gin.Context) { } func cancelRun(c *gin.Context) { - if err := services.CancelRun(c.Param("runId")); err != nil { + if err := services.CancelRun(auth.OrgID(c), c.Param("runId")); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("workflow.run_cancelled", actorFromCtx(c), "", c.Param("runId"), "run cancelled") + services.LogEvent(auth.OrgID(c), "workflow.run_cancelled", actorFromCtx(c), "", c.Param("runId"), "run cancelled") c.JSON(http.StatusOK, gin.H{"cancelled": true}) } diff --git a/server/internal/auth/local.go b/server/internal/auth/local.go new file mode 100644 index 0000000..5cc94f0 --- /dev/null +++ b/server/internal/auth/local.go @@ -0,0 +1,165 @@ +package auth + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/server/internal/models" + "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()), + }) +} + +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}) +} + +// HandleBootstrapStatus answers "does the caller need to run setup". It is +// unauthenticated, so it must not report instance-wide state to whoever asks: +// on an org host the answer is scoped to that org, and only the apex — the +// genuine first-run entry point — gets the global "no users anywhere" answer. +func HandleBootstrapStatus(c *gin.Context) { + var ( + n int64 + err error + ) + if org, ok := OrgFromHost(c); ok { + n, err = services.CountOrgUsers(org.OrgID) + } else { + 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 very first org and its owner, so its guard stays +// deliberately global: it may run once on an empty instance and never again, +// regardless of which host it is called on. +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 + } + // An upgrade from single-tenant arrives here with no users but with the org + // the migrations created and stamped onto every legacy document. Creating a + // second org would put the owner somewhere else entirely, and since every + // org-scoped read filters on org_id, the operator would land in an empty + // Vantage with all their real data still under the migrated org — silent, + // total-looking data loss. So adopt the existing org instead, and only + // create when there genuinely is none. Same `switch orgCount` shape as + // migration 0002. + orgCount, err := services.CountOrgs() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var org *models.Org + switch orgCount { + case 0: + org, err = services.CreateOrg(body.OrgName) + case 1: + var existing *models.Org + existing, err = services.FirstOrg() + if err == nil { + org, err = services.AdoptOrg(existing.OrgID, body.OrgName) + } + default: + c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf( + "cannot bootstrap: %d organizations already exist but no users do; "+ + "create the owner against the intended org rather than through setup, "+ + "or remove the unintended orgs and retry", orgCount)}) + return + } + 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}) +} + +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 + } + // /auth/me is registered outside Middleware, so it repeats the middleware's + // host/org match itself. Without this, a session for org A presented on org + // B's host would render the shell while every /api call 403s. + if hostOrg, ok := OrgFromHost(c); ok && hostOrg.OrgID != sess.OrgID { + c.JSON(http.StatusForbidden, gin.H{"error": "org host mismatch"}) + return + } + + org, _ := services.GetOrg(sess.OrgID) + c.JSON(http.StatusOK, gin.H{"user": sess, "org": org}) +} diff --git a/server/internal/auth/middleware.go b/server/internal/auth/middleware.go index 40e2c9d..4ae437d 100644 --- a/server/internal/auth/middleware.go +++ b/server/internal/auth/middleware.go @@ -14,13 +14,42 @@ func GetSessionFromContext(c *gin.Context) *Session { return sess } +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 "" +} + +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"}) + } +} + func Middleware() gin.HandlerFunc { return func(c *gin.Context) { - if !authEnabled { - c.Next() - return - } - cookie, err := c.Request.Cookie(sessionCookieName) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"}) @@ -33,7 +62,20 @@ func Middleware() gin.HandlerFunc { return } + // An org-less session would turn every downstream scope into + // {"org_id": ""} — fail closed rather than query across tenants. + if sess.OrgID == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session has no organization"}) + return + } + 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() } } diff --git a/server/internal/auth/oidc.go b/server/internal/auth/oidc.go index 5387ae7..7779d1e 100644 --- a/server/internal/auth/oidc.go +++ b/server/internal/auth/oidc.go @@ -2,123 +2,155 @@ package auth import ( "context" - "log" + "fmt" "net/http" - "os" + "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" ) var ( - oidcProvider *oidc.Provider - oauth2Cfg *oauth2.Config - authEnabled bool + provMu sync.Mutex + provCache = map[string]*oidc.Provider{} ) -func InitOIDC(ctx context.Context) error { - issuer := os.Getenv("OIDC_ISSUER") - if issuer == "" { - log.Println("OIDC_ISSUER not set; authentication disabled") - return nil - } - - p, err := oidc.NewProvider(ctx, issuer) - if err != nil { - return err - } - oidcProvider = p - oauth2Cfg = &oauth2.Config{ - ClientID: os.Getenv("OIDC_CLIENT_ID"), - ClientSecret: os.Getenv("OIDC_CLIENT_SECRET"), - RedirectURL: os.Getenv("OIDC_REDIRECT_URL"), - Endpoint: p.Endpoint(), - Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, - } - authEnabled = true - log.Println("OIDC authentication enabled") - return nil +// EvictOIDCProvider drops an org's cached provider so the next login rediscovers +// it from the (possibly changed) issuer. Called by the API layer after the org's +// OIDC config is saved — services cannot import auth, so the handler wires it. +func EvictOIDCProvider(orgID string) { + provMu.Lock() + delete(provCache, orgID) + provMu.Unlock() } -func Enabled() bool { return authEnabled } +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 HandleLogin(c *gin.Context) { - state, err := randomHex(16) +// providerForOrg returns the org's (cached) OIDC provider plus a request-local +// oauth2 config. The config is never stored on the cached entry: RedirectURL is +// derived from this request's Host, so sharing it would let one in-flight login +// overwrite another's redirect URI. +func providerForOrg(ctx context.Context, c *gin.Context, orgID string) (*oidc.Provider, *oauth2.Config, error) { + cfg, err := services.GetOrgOIDC(orgID) + if err != nil || !cfg.Enabled { + return nil, nil, fmt.Errorf("org SSO not configured") + } + secret, err := services.GetOrgOIDCSecret(orgID) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "state generation failed"}) + return nil, nil, err + } + provMu.Lock() + p := provCache[orgID] + provMu.Unlock() + if p == nil { + p, err = oidc.NewProvider(ctx, cfg.Issuer) + if err != nil { + return nil, nil, err + } + provMu.Lock() + provCache[orgID] = p + provMu.Unlock() + } + return p, &oauth2.Config{ + ClientID: cfg.ClientID, ClientSecret: secret, + RedirectURL: redirectURL(c), Endpoint: p.Endpoint(), + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + }, nil +} + +func HandleOIDCStart(c *gin.Context) { + org, ok := OrgFromHost(c) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "unknown organization host"}) return } - if err := SaveState(c.Request.Context(), state); err != nil { + ctx := c.Request.Context() + _, oauthCfg, 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, oauth2Cfg.AuthCodeURL(state)) + c.Redirect(http.StatusFound, oauthCfg.AuthCodeURL(state)) } -func HandleCallback(c *gin.Context) { +func HandleOIDCCallback(c *gin.Context) { ctx := c.Request.Context() - - if !ConsumeState(ctx, c.Query("state")) { + orgID, ok := ConsumeStateOrg(ctx, c.Query("state")) + if !ok { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state"}) return } - - token, err := oauth2Cfg.Exchange(ctx, c.Query("code")) + provider, oauthCfg, err := providerForOrg(ctx, c, orgID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + token, err := oauthCfg.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 } - - verifier := oidcProvider.Verifier(&oidc.Config{ClientID: oauth2Cfg.ClientID}) - idToken, err := verifier.Verify(ctx, rawIDToken) + idToken, err := provider.Verifier(&oidc.Config{ClientID: oauthCfg.ClientID}).Verify(ctx, rawIDToken) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "token verification failed"}) return } - var claims struct { - Sub string `json:"sub"` Email string `json:"email"` Name string `json:"name"` } - if err := idToken.Claims(&claims); err != nil { + 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: claims.Sub, - Email: claims.Email, - Name: claims.Name, + 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 } - - 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()), - }) - - frontendURL := os.Getenv("PUBLIC_HOST") - if frontendURL == "" { - frontendURL = "/" - } - c.Redirect(http.StatusFound, frontendURL) + _ = services.TouchLastLogin(u.UserID) + SetSessionCookie(c, sessionID) + c.Redirect(http.StatusFound, "/") } func HandleLogout(c *gin.Context) { @@ -134,21 +166,3 @@ func HandleLogout(c *gin.Context) { }) c.Redirect(http.StatusFound, "/") } - -func HandleMe(c *gin.Context) { - if !authEnabled { - c.JSON(http.StatusOK, gin.H{"auth_enabled": false}) - return - } - 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 - } - c.JSON(http.StatusOK, sess) -} diff --git a/server/internal/auth/orghost.go b/server/internal/auth/orghost.go new file mode 100644 index 0000000..5044752 --- /dev/null +++ b/server/internal/auth/orghost.go @@ -0,0 +1,80 @@ +package auth + +import ( + "os" + "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 + +// appRootLabel is the DNS label the app is deployed under, i.e. the "vantage" +// in .vantage.. Deployments on another root must set APP_ROOT_LABEL +// or every host resolves to no org, disabling the host/session mismatch guard. +func appRootLabel() string { + if v := os.Getenv("APP_ROOT_LABEL"); v != "" { + return strings.ToLower(v) + } + return "vantage" +} + +// 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] + } + root := appRootLabel() + // Expect ..<...>; apex is .<...> + parts := strings.Split(host, ".") + if len(parts) < 3 { + return "" + } + if parts[1] != root { + return "" + } + if parts[0] == root || 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 { + // Never cache a miss: a just-bootstrapped org would otherwise 404 on its + // own subdomain for the rest of the TTL. Misses are cheap and rare. + return nil, false + } + orgCacheMu.Lock() + orgCache[slug] = cachedOrg{org: org, at: time.Now()} + orgCacheMu.Unlock() + return org, true +} diff --git a/server/internal/auth/session.go b/server/internal/auth/session.go index 65b48b7..b72a736 100644 --- a/server/internal/auth/session.go +++ b/server/internal/auth/session.go @@ -17,6 +17,8 @@ const statePrefix = "km:state:" 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"` } @@ -69,11 +71,14 @@ func DeleteSession(ctx context.Context, id string) error { return rdb.Del(ctx, sessionPrefix+id).Err() } -func SaveState(ctx context.Context, state string) error { - return rdb.Set(ctx, statePrefix+state, "1", 10*time.Minute).Err() +func SaveStateOrg(ctx context.Context, state, orgID string) error { + return rdb.Set(ctx, statePrefix+state, orgID, 10*time.Minute).Err() } -func ConsumeState(ctx context.Context, state string) bool { - n, err := rdb.Del(ctx, statePrefix+state).Result() - return err == nil && n > 0 +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 } diff --git a/server/internal/grpc/server.go b/server/internal/grpc/server.go index 3351998..8e7f9e7 100644 --- a/server/internal/grpc/server.go +++ b/server/internal/grpc/server.go @@ -63,13 +63,13 @@ func (s *vantageServer) UploadGeneratedKey(ctx context.Context, req *pb.UploadKe } // Agent-generated keys carry no passphrase over the wire (proto has no field). - key, err := services.CreateKey(req.Label, req.PublicKey, "generated", srv.ServerID, req.PrivateKey, "") + key, err := services.CreateKey(srv.OrgID, req.Label, req.PublicKey, "generated", srv.ServerID, req.PrivateKey, "") if err != nil { return nil, status.Errorf(codes.Internal, "failed to store key: %v", err) } // Auto-assign to the generating server - if _, err := services.AssignKey(key.KeyID, srv.ServerID); err != nil { + if _, err := services.AssignKey(srv.OrgID, key.KeyID, srv.ServerID); err != nil { log.Printf("failed to auto-assign generated key: %v", err) } @@ -112,7 +112,7 @@ func (s *vantageServer) SyncMonitors(ctx context.Context, req *pb.SyncMonitorsRe if err != nil { return nil, status.Errorf(codes.Unauthenticated, "invalid agent token") } - monitors, err := services.ListMonitorsForRunner(srv.ServerID) + monitors, err := services.ListMonitorsForRunner(srv.OrgID, srv.ServerID) if err != nil { return nil, status.Errorf(codes.Internal, "list monitors") } @@ -137,7 +137,8 @@ func (s *vantageServer) SyncMonitors(ctx context.Context, req *pb.SyncMonitorsRe } func (s *vantageServer) ReportChecks(ctx context.Context, req *pb.ReportChecksRequest) (*pb.ReportChecksResponse, error) { - if _, err := services.ValidateAgentToken(req.ServerId, req.AgentToken); err != nil { + srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken) + if err != nil { return nil, status.Errorf(codes.Unauthenticated, "invalid agent token") } for _, r := range req.Results { @@ -146,7 +147,9 @@ func (s *vantageServer) ReportChecks(ctx context.Context, req *pb.ReportChecksRe t := time.Unix(r.CertExpiryUnix, 0) res.CertExpiry = &t } - if err := services.IngestResult(r.MonitorId, res); err != nil { + // A rejected monitor (wrong org, or not run by this agent) is skipped, + // not fatal — the rest of the batch is still legitimate. + if err := services.IngestResult(srv.OrgID, srv.ServerID, r.MonitorId, res); err != nil { log.Printf("ingest check %s: %v", r.MonitorId, err) } } diff --git a/server/internal/models/assignment.go b/server/internal/models/assignment.go index 7320be1..a0c3cd4 100644 --- a/server/internal/models/assignment.go +++ b/server/internal/models/assignment.go @@ -8,8 +8,9 @@ import ( type Assignment struct { ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` - KeyID string `bson:"key_id" json:"key_id"` - ServerID string `bson:"server_id" json:"server_id"` - AssignedAt time.Time `bson:"assigned_at" json:"assigned_at"` - RevokedAt *time.Time `bson:"revoked_at,omitempty" json:"revoked_at,omitempty"` + OrgID string `bson:"org_id" json:"org_id"` + KeyID string `bson:"key_id" json:"key_id"` + ServerID string `bson:"server_id" json:"server_id"` + AssignedAt time.Time `bson:"assigned_at" json:"assigned_at"` + RevokedAt *time.Time `bson:"revoked_at,omitempty" json:"revoked_at,omitempty"` } diff --git a/server/internal/models/audit.go b/server/internal/models/audit.go index 69f0c7e..61c55fe 100644 --- a/server/internal/models/audit.go +++ b/server/internal/models/audit.go @@ -8,6 +8,7 @@ import ( type AuditEvent struct { ID bson.ObjectID `bson:"_id,omitempty" json:"id"` + OrgID string `bson:"org_id" json:"org_id"` EventType string `bson:"event_type" json:"event_type"` Actor string `bson:"actor" json:"actor"` ServerID string `bson:"server_id,omitempty" json:"server_id,omitempty"` diff --git a/server/internal/models/channel.go b/server/internal/models/channel.go index c166cef..b6dcb0b 100644 --- a/server/internal/models/channel.go +++ b/server/internal/models/channel.go @@ -20,6 +20,7 @@ const ( // or telegram token/chat_id). type NotificationChannel struct { ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + OrgID string `bson:"org_id" json:"org_id"` ChannelID string `bson:"channel_id" json:"channel_id"` Name string `bson:"name" json:"name"` Type string `bson:"type" json:"type"` diff --git a/server/internal/models/console_session.go b/server/internal/models/console_session.go index dd0581d..7e2be00 100644 --- a/server/internal/models/console_session.go +++ b/server/internal/models/console_session.go @@ -8,6 +8,7 @@ import ( type ConsoleSession struct { ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + OrgID string `bson:"org_id" json:"org_id"` SessionID string `bson:"session_id" json:"session_id"` ServerID string `bson:"server_id" json:"server_id"` Protocol string `bson:"protocol" json:"protocol"` // ssh | rdp | vnc diff --git a/server/internal/models/key.go b/server/internal/models/key.go index 99ebdee..c2addaa 100644 --- a/server/internal/models/key.go +++ b/server/internal/models/key.go @@ -8,6 +8,7 @@ import ( type Key struct { ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + OrgID string `bson:"org_id" json:"org_id"` KeyID string `bson:"key_id" json:"key_id"` Label string `bson:"label" json:"label"` PublicKey string `bson:"public_key" json:"public_key"` diff --git a/server/internal/models/monitor.go b/server/internal/models/monitor.go index 472df32..1875e0f 100644 --- a/server/internal/models/monitor.go +++ b/server/internal/models/monitor.go @@ -48,12 +48,13 @@ type MonitorState struct { type Monitor struct { ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + OrgID string `bson:"org_id" json:"org_id"` MonitorID string `bson:"monitor_id" json:"monitor_id"` Name string `bson:"name" json:"name"` Type string `bson:"type" json:"type"` // http|tcp|icmp|tls Target MonitorTarget `bson:"target" json:"target"` IntervalSec int `bson:"interval_sec" json:"interval_sec"` - Runner string `bson:"runner" json:"runner"` // "server" or a server_id + Runner string `bson:"runner" json:"runner"` // "server" or a server_id Retries int `bson:"retries" json:"retries"` // consecutive fails before down Enabled bool `bson:"enabled" json:"enabled"` ChannelIDs []string `bson:"channel_ids,omitempty" json:"channel_ids,omitempty"` @@ -62,6 +63,7 @@ type Monitor struct { } type Incident struct { + OrgID string `bson:"org_id" json:"org_id"` IncidentID string `bson:"incident_id" json:"incident_id"` MonitorID string `bson:"monitor_id" json:"monitor_id"` StartedAt time.Time `bson:"started_at" json:"started_at"` @@ -70,6 +72,7 @@ type Incident struct { } type Rollup struct { + OrgID string `bson:"org_id" json:"org_id"` MonitorID string `bson:"monitor_id" json:"monitor_id"` PeriodStart time.Time `bson:"period_start" json:"period_start"` // hour bucket Checks int `bson:"checks" json:"checks"` diff --git a/server/internal/models/org.go b/server/internal/models/org.go new file mode 100644 index 0000000..0a863a6 --- /dev/null +++ b/server/internal/models/org.go @@ -0,0 +1,15 @@ +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"` +} diff --git a/server/internal/models/org_oidc.go b/server/internal/models/org_oidc.go new file mode 100644 index 0000000..2f1d095 --- /dev/null +++ b/server/internal/models/org_oidc.go @@ -0,0 +1,17 @@ +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"` +} diff --git a/server/internal/models/secret.go b/server/internal/models/secret.go index 6557c3f..6af18c1 100644 --- a/server/internal/models/secret.go +++ b/server/internal/models/secret.go @@ -10,6 +10,7 @@ import ( // encrypted (AES-256-GCM) and is never serialized to JSON. type Secret struct { ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + OrgID string `bson:"org_id" json:"org_id"` Group string `bson:"group" json:"group"` Key string `bson:"key" json:"key"` EncryptedValue string `bson:"encrypted_value" json:"-"` diff --git a/server/internal/models/server.go b/server/internal/models/server.go index 98dd7d5..fe685bb 100644 --- a/server/internal/models/server.go +++ b/server/internal/models/server.go @@ -44,23 +44,24 @@ type Inventory struct { } type Server struct { - ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` - ServerID string `bson:"server_id" json:"server_id"` - Hostname string `bson:"hostname" json:"hostname"` - IPAddress string `bson:"ip_address" json:"ip_address"` - OSInfo string `bson:"os_info" json:"os_info"` - OSType string `bson:"os_type,omitempty" json:"os_type,omitempty"` - ConsoleProtocols []string `bson:"console_protocols,omitempty" json:"console_protocols,omitempty"` - SSHPort int `bson:"ssh_port,omitempty" json:"ssh_port,omitempty"` - RDPPort int `bson:"rdp_port,omitempty" json:"rdp_port,omitempty"` - PreRegToken string `bson:"pre_reg_token,omitempty" json:"pre_reg_token,omitempty"` - PreRegExpires *time.Time `bson:"pre_reg_expires,omitempty" json:"pre_reg_expires,omitempty"` - AgentTokenHash string `bson:"agent_token_hash,omitempty" json:"-"` - Status string `bson:"status" json:"status"` - AgentVersion string `bson:"agent_version,omitempty" json:"agent_version,omitempty"` - LastSeen *time.Time `bson:"last_seen,omitempty" json:"last_seen,omitempty"` + ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + OrgID string `bson:"org_id" json:"org_id"` + ServerID string `bson:"server_id" json:"server_id"` + Hostname string `bson:"hostname" json:"hostname"` + IPAddress string `bson:"ip_address" json:"ip_address"` + OSInfo string `bson:"os_info" json:"os_info"` + OSType string `bson:"os_type,omitempty" json:"os_type,omitempty"` + ConsoleProtocols []string `bson:"console_protocols,omitempty" json:"console_protocols,omitempty"` + SSHPort int `bson:"ssh_port,omitempty" json:"ssh_port,omitempty"` + RDPPort int `bson:"rdp_port,omitempty" json:"rdp_port,omitempty"` + PreRegToken string `bson:"pre_reg_token,omitempty" json:"pre_reg_token,omitempty"` + PreRegExpires *time.Time `bson:"pre_reg_expires,omitempty" json:"pre_reg_expires,omitempty"` + AgentTokenHash string `bson:"agent_token_hash,omitempty" json:"-"` + Status string `bson:"status" json:"status"` + AgentVersion string `bson:"agent_version,omitempty" json:"agent_version,omitempty"` + LastSeen *time.Time `bson:"last_seen,omitempty" json:"last_seen,omitempty"` AvailableUpdates []PackageUpdate `bson:"available_updates,omitempty" json:"available_updates,omitempty"` - UpdatesCheckedAt *time.Time `bson:"updates_checked_at,omitempty" json:"updates_checked_at,omitempty"` - Inventory *Inventory `bson:"inventory,omitempty" json:"inventory,omitempty"` - CreatedAt time.Time `bson:"created_at" json:"created_at"` + UpdatesCheckedAt *time.Time `bson:"updates_checked_at,omitempty" json:"updates_checked_at,omitempty"` + Inventory *Inventory `bson:"inventory,omitempty" json:"inventory,omitempty"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` } diff --git a/server/internal/models/settings.go b/server/internal/models/settings.go index 60bdaf2..1c4c975 100644 --- a/server/internal/models/settings.go +++ b/server/internal/models/settings.go @@ -33,6 +33,7 @@ type SecretsSettings struct { type Settings struct { ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + OrgID string `bson:"org_id" json:"org_id"` Alerts AlertSettings `bson:"alerts" json:"alerts"` Email EmailSettings `bson:"email" json:"email"` Secrets SecretsSettings `bson:"secrets" json:"secrets"` diff --git a/server/internal/models/user.go b/server/internal/models/user.go new file mode 100644 index 0000000..e69cefc --- /dev/null +++ b/server/internal/models/user.go @@ -0,0 +1,35 @@ +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// Org membership roles. These are the only values ever written to User.Role; +// anything arriving from a client must be checked with ValidRole first. +const ( + RoleOwner = "owner" + RoleAdmin = "admin" + RoleMember = "member" +) + +func ValidRole(role string) bool { + switch role { + case RoleOwner, RoleAdmin, RoleMember: + return true + } + return false +} + +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"` +} diff --git a/server/internal/models/workflow.go b/server/internal/models/workflow.go index 9880c56..0de6fd6 100644 --- a/server/internal/models/workflow.go +++ b/server/internal/models/workflow.go @@ -14,6 +14,7 @@ type InputParam struct { type WorkflowStep struct { ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + OrgID string `bson:"org_id" json:"org_id"` StepID string `bson:"step_id" json:"step_id"` Name string `bson:"name" json:"name"` Description string `bson:"description" json:"description"` @@ -45,6 +46,7 @@ type StepOverride struct { type Workflow struct { ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + OrgID string `bson:"org_id" json:"org_id"` WorkflowID string `bson:"workflow_id" json:"workflow_id"` Name string `bson:"name" json:"name"` TargetServerIDs []string `bson:"target_server_ids" json:"target_server_ids"` @@ -55,10 +57,10 @@ type Workflow struct { // ResolvedStep is a step frozen into a run snapshot (library step + overrides applied). type ResolvedStep struct { - Order int `bson:"order" json:"order"` - Name string `bson:"name" json:"name"` - Interpreter string `bson:"interpreter" json:"interpreter"` - Script string `bson:"script" json:"script"` + Order int `bson:"order" json:"order"` + Name string `bson:"name" json:"name"` + Interpreter string `bson:"interpreter" json:"interpreter"` + Script string `bson:"script" json:"script"` SecretRefs []string `bson:"secret_refs" json:"secret_refs"` OnFailure string `bson:"on_failure" json:"on_failure"` MaxRetries int `bson:"max_retries" json:"max_retries"` @@ -89,6 +91,7 @@ type ServerRun struct { type WorkflowRun struct { ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + OrgID string `bson:"org_id" json:"org_id"` RunID string `bson:"run_id" json:"run_id"` WorkflowID string `bson:"workflow_id" json:"workflow_id"` Name string `bson:"name" json:"name"` diff --git a/server/internal/monitorsched/scheduler.go b/server/internal/monitorsched/scheduler.go index f3b33a8..884e3ae 100644 --- a/server/internal/monitorsched/scheduler.go +++ b/server/internal/monitorsched/scheduler.go @@ -35,7 +35,7 @@ func loop(ctx context.Context) { var mu sync.Mutex sync := func() { - monitors, err := services.ListMonitorsForRunner(models.RunnerServer) + monitors, err := services.ListServerScheduledMonitors() if err != nil { log.Printf("monitorsched: list monitors: %v", err) return @@ -88,7 +88,7 @@ func runMonitor(ctx context.Context, m models.Monitor) { run := func() { res := checker.Run(ctx, spec) - if err := services.IngestResult(m.MonitorID, res); err != nil { + if err := services.IngestServerScheduledResult(m.MonitorID, res); err != nil { log.Printf("monitorsched: ingest %s: %v", m.MonitorID, err) } } diff --git a/server/internal/services/audit.go b/server/internal/services/audit.go index 9e2c51b..82ac79b 100644 --- a/server/internal/services/audit.go +++ b/server/internal/services/audit.go @@ -11,11 +11,12 @@ import ( "go.mongodb.org/mongo-driver/v2/mongo/options" ) -func LogEvent(eventType, actor, serverID, keyID, details string) { +func LogEvent(orgID, eventType, actor, serverID, keyID, details string) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() event := models.AuditEvent{ + OrgID: orgID, EventType: eventType, Actor: actor, ServerID: serverID, @@ -28,7 +29,7 @@ func LogEvent(eventType, actor, serverID, keyID, details string) { } } -func ListAuditEvents(limit int64) ([]models.AuditEvent, error) { +func ListAuditEvents(orgID string, limit int64) ([]models.AuditEvent, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -36,7 +37,7 @@ func ListAuditEvents(limit int64) ([]models.AuditEvent, error) { SetSort(bson.D{{Key: "created_at", Value: -1}}). SetLimit(limit) - cursor, err := db.Col("audit_logs").Find(ctx, bson.M{}, opts) + cursor, err := db.Col("audit_logs").Find(ctx, bson.M{"org_id": orgID}, opts) if err != nil { return nil, err } diff --git a/server/internal/services/channels.go b/server/internal/services/channels.go index d37a93b..beaaff7 100644 --- a/server/internal/services/channels.go +++ b/server/internal/services/channels.go @@ -13,10 +13,10 @@ import ( "go.mongodb.org/mongo-driver/v2/mongo/options" ) -func ListChannels() ([]models.NotificationChannel, error) { +func ListChannels(orgID string) ([]models.NotificationChannel, error) { ctx, cancel := monCtx() defer cancel() - cur, err := db.Col("notification_channels").Find(ctx, bson.M{}, options.Find().SetSort(bson.M{"created_at": 1})) + cur, err := db.Col("notification_channels").Find(ctx, bson.M{"org_id": orgID}, options.Find().SetSort(bson.M{"created_at": 1})) if err != nil { return nil, err } @@ -27,11 +27,11 @@ func ListChannels() ([]models.NotificationChannel, error) { return out, nil } -func GetChannel(channelID string) (*models.NotificationChannel, error) { +func GetChannel(orgID, channelID string) (*models.NotificationChannel, error) { ctx, cancel := monCtx() defer cancel() var ch models.NotificationChannel - err := db.Col("notification_channels").FindOne(ctx, bson.M{"channel_id": channelID}).Decode(&ch) + err := db.Col("notification_channels").FindOne(ctx, bson.M{"channel_id": channelID, "org_id": orgID}).Decode(&ch) if errors.Is(err, mongo.ErrNoDocuments) { return nil, nil } @@ -41,14 +41,14 @@ func GetChannel(channelID string) (*models.NotificationChannel, error) { return &ch, nil } -// GetChannels loads multiple channels by ID, skipping any not found. -func GetChannels(channelIDs []string) ([]models.NotificationChannel, error) { +// GetChannels loads multiple channels by ID within an org, skipping any not found. +func GetChannels(orgID string, channelIDs []string) ([]models.NotificationChannel, error) { if len(channelIDs) == 0 { return nil, nil } ctx, cancel := monCtx() defer cancel() - cur, err := db.Col("notification_channels").Find(ctx, bson.M{"channel_id": bson.M{"$in": channelIDs}}) + cur, err := db.Col("notification_channels").Find(ctx, bson.M{"org_id": orgID, "channel_id": bson.M{"$in": channelIDs}}) if err != nil { return nil, err } @@ -59,9 +59,25 @@ func GetChannels(channelIDs []string) ([]models.NotificationChannel, error) { return out, nil } -func CreateChannel(ch *models.NotificationChannel) (*models.NotificationChannel, error) { +// validateChannelIDs rejects any channel that does not belong to the org. +// Channel IDs arrive from the client as data on monitor writes. +func validateChannelIDs(orgID string, channelIDs []string) error { + for _, id := range channelIDs { + ch, err := GetChannel(orgID, id) + if err != nil { + return err + } + if ch == nil { + return errors.New("channel " + id + " not found") + } + } + return nil +} + +func CreateChannel(orgID string, ch *models.NotificationChannel) (*models.NotificationChannel, error) { ctx, cancel := monCtx() defer cancel() + ch.OrgID = orgID ch.ChannelID = uuid.NewString() ch.CreatedAt = time.Now() if ch.Config == nil { @@ -73,23 +89,23 @@ func CreateChannel(ch *models.NotificationChannel) (*models.NotificationChannel, return ch, nil } -func UpdateChannel(channelID string, upd bson.M) error { +func UpdateChannel(orgID, channelID string, upd bson.M) error { ctx, cancel := monCtx() defer cancel() - _, err := db.Col("notification_channels").UpdateOne(ctx, bson.M{"channel_id": channelID}, bson.M{"$set": upd}) + _, err := db.Col("notification_channels").UpdateOne(ctx, bson.M{"channel_id": channelID, "org_id": orgID}, bson.M{"$set": upd}) return err } -func DeleteChannel(channelID string) error { +func DeleteChannel(orgID, channelID string) error { ctx, cancel := monCtx() defer cancel() - _, err := db.Col("notification_channels").DeleteOne(ctx, bson.M{"channel_id": channelID}) + _, err := db.Col("notification_channels").DeleteOne(ctx, bson.M{"channel_id": channelID, "org_id": orgID}) return err } // TestChannel sends a synthetic alert to verify configuration. -func TestChannel(channelID string) error { - ch, err := GetChannel(channelID) +func TestChannel(orgID, channelID string) error { + ch, err := GetChannel(orgID, channelID) if err != nil { return err } diff --git a/server/internal/services/console.go b/server/internal/services/console.go index 749e79f..606c149 100644 --- a/server/internal/services/console.go +++ b/server/internal/services/console.go @@ -129,11 +129,12 @@ func BuildGuacParams(srv *models.Server, protocol, sshUser, privateKey, passphra } } -func CreateConsoleSession(serverID, protocol, keyID, user, clientIP string) (*models.ConsoleSession, error) { +func CreateConsoleSession(orgID, serverID, protocol, keyID, user, clientIP string) (*models.ConsoleSession, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() s := &models.ConsoleSession{ + OrgID: orgID, SessionID: uuid.NewString(), ServerID: serverID, Protocol: protocol, @@ -148,11 +149,11 @@ func CreateConsoleSession(serverID, protocol, keyID, user, clientIP string) (*mo return s, nil } -func GetConsoleSession(sessionID string) (*models.ConsoleSession, error) { +func GetConsoleSession(orgID, sessionID string) (*models.ConsoleSession, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var s models.ConsoleSession - if err := db.Col("console_sessions").FindOne(ctx, bson.M{"session_id": sessionID}).Decode(&s); err != nil { + if err := db.Col("console_sessions").FindOne(ctx, bson.M{"session_id": sessionID, "org_id": orgID}).Decode(&s); err != nil { return nil, err } return &s, nil @@ -160,7 +161,7 @@ func GetConsoleSession(sessionID string) (*models.ConsoleSession, error) { // StashConsoleRDPCreds encrypts and stores single-use RDP credentials on the // session document. They are consumed (and cleared) when the tunnel opens. -func StashConsoleRDPCreds(sessionID, username, password string) error { +func StashConsoleRDPCreds(orgID, sessionID, username, password string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() u, err := encryptString(username) @@ -172,7 +173,7 @@ func StashConsoleRDPCreds(sessionID, username, password string) error { return err } _, err = db.Col("console_sessions").UpdateOne(ctx, - bson.M{"session_id": sessionID}, + bson.M{"session_id": sessionID, "org_id": orgID}, bson.M{"$set": bson.M{"rdp_user_enc": u, "rdp_pass_enc": p}}, ) return err @@ -181,8 +182,8 @@ func StashConsoleRDPCreds(sessionID, username, password string) error { // ConsumeConsoleRDPCreds decrypts and returns the stored RDP credentials, then // clears them from the session document (single-use). Returns empty strings if // none were stored. -func ConsumeConsoleRDPCreds(sessionID string) (username, password string, err error) { - s, err := GetConsoleSession(sessionID) +func ConsumeConsoleRDPCreds(orgID, sessionID string) (username, password string, err error) { + s, err := GetConsoleSession(orgID, sessionID) if err != nil { return "", "", err } @@ -202,18 +203,18 @@ func ConsumeConsoleRDPCreds(sessionID string) (username, password string, err er ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _, _ = db.Col("console_sessions").UpdateOne(ctx, - bson.M{"session_id": sessionID}, + bson.M{"session_id": sessionID, "org_id": orgID}, bson.M{"$unset": bson.M{"rdp_user_enc": "", "rdp_pass_enc": ""}}, ) return username, password, nil } // SetConsoleSSHUser persists the SSH username to use on the session doc. -func SetConsoleSSHUser(sessionID, username string) error { +func SetConsoleSSHUser(orgID, sessionID, username string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _, err := db.Col("console_sessions").UpdateOne(ctx, - bson.M{"session_id": sessionID}, + bson.M{"session_id": sessionID, "org_id": orgID}, bson.M{"$set": bson.M{"ssh_username": username}}) return err } @@ -221,12 +222,12 @@ func SetConsoleSSHUser(sessionID, username string) error { // ConsumeSessionToken atomically marks a session's one-time token as spent. // It returns an error if the token was already consumed (replay) or the session // does not exist, so the tunnel can be opened at most once per issued token. -func ConsumeSessionToken(sessionID string) error { +func ConsumeSessionToken(orgID, sessionID string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() now := time.Now() res, err := db.Col("console_sessions").UpdateOne(ctx, - bson.M{"session_id": sessionID, "token_consumed_at": nil}, + bson.M{"session_id": sessionID, "org_id": orgID, "token_consumed_at": nil}, bson.M{"$set": bson.M{"token_consumed_at": now}}, ) if err != nil { @@ -238,12 +239,12 @@ func ConsumeSessionToken(sessionID string) error { return nil } -func EndConsoleSession(sessionID string) error { +func EndConsoleSession(orgID, sessionID string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() now := time.Now() _, err := db.Col("console_sessions").UpdateOne(ctx, - bson.M{"session_id": sessionID, "ended_at": nil}, + bson.M{"session_id": sessionID, "org_id": orgID, "ended_at": nil}, bson.M{"$set": bson.M{"ended_at": now}}, ) return err diff --git a/server/internal/services/console_test.go b/server/internal/services/console_test.go deleted file mode 100644 index 14b54a9..0000000 --- a/server/internal/services/console_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package services - -import ( - "testing" - "time" - - "github.com/mrhid6/vantage/server/internal/models" -) - -func TestSessionTokenRoundTrip(t *testing.T) { - t.Setenv("KEY_ENCRYPTION_KEY", "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff") - - tok, err := SignSessionToken("sess-123", time.Minute) - if err != nil { - t.Fatalf("sign: %v", err) - } - got, err := VerifySessionToken(tok) - if err != nil { - t.Fatalf("verify: %v", err) - } - if got != "sess-123" { - t.Fatalf("got %q want sess-123", got) - } -} - -func TestSessionTokenExpired(t *testing.T) { - t.Setenv("KEY_ENCRYPTION_KEY", "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff") - - tok, err := SignSessionToken("sess-123", -time.Second) - if err != nil { - t.Fatalf("sign: %v", err) - } - if _, err := VerifySessionToken(tok); err == nil { - t.Fatalf("expected expiry error, got nil") - } -} - -func TestSessionTokenTampered(t *testing.T) { - t.Setenv("KEY_ENCRYPTION_KEY", "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff") - - tok, _ := SignSessionToken("sess-123", time.Minute) - if _, err := VerifySessionToken(tok + "x"); err == nil { - t.Fatalf("expected signature error, got nil") - } -} - -func TestBuildGuacParamsSSH(t *testing.T) { - srv := &models.Server{IPAddress: "10.0.0.5", SSHPort: 22} - p, err := BuildGuacParams(srv, "ssh", "", "PRIVATE-KEY-DATA", "", "", "") - if err != nil { - t.Fatalf("err: %v", err) - } - if p.Protocol != "ssh" { - t.Fatalf("protocol %q", p.Protocol) - } - if p.Params["hostname"] != "10.0.0.5" || p.Params["port"] != "22" { - t.Fatalf("bad host/port: %+v", p.Params) - } - if p.Params["private-key"] != "PRIVATE-KEY-DATA" { - t.Fatalf("missing private-key") - } - if p.Params["username"] != "root" { - t.Fatalf("expected default username root, got %q", p.Params["username"]) - } -} - -func TestBuildGuacParamsRDP(t *testing.T) { - srv := &models.Server{IPAddress: "10.0.0.9", RDPPort: 3389} - p, err := BuildGuacParams(srv, "rdp", "", "", "", "administrator", "s3cret") - if err != nil { - t.Fatalf("err: %v", err) - } - if p.Params["port"] != "3389" || p.Params["username"] != "administrator" || p.Params["password"] != "s3cret" { - t.Fatalf("bad rdp params: %+v", p.Params) - } - if p.Params["ignore-cert"] != "true" { - t.Fatalf("expected ignore-cert=true") - } -} - -func TestBuildGuacParamsUnknownProtocol(t *testing.T) { - srv := &models.Server{IPAddress: "10.0.0.9"} - if _, err := BuildGuacParams(srv, "telnet", "", "", "", "", ""); err == nil { - t.Fatalf("expected error for unknown protocol") - } -} - -func TestBuildGuacParamsSSHPassphrase(t *testing.T) { - srv := &models.Server{IPAddress: "10.0.0.5", SSHPort: 22} - p, err := BuildGuacParams(srv, "ssh", "deploy", "PK", "s3cret-phrase", "", "") - if err != nil { - t.Fatalf("err: %v", err) - } - if p.Params["username"] != "deploy" { - t.Fatalf("username %q", p.Params["username"]) - } - if p.Params["passphrase"] != "s3cret-phrase" { - t.Fatalf("missing passphrase: %+v", p.Params) - } -} - -func TestBuildGuacParamsVNC(t *testing.T) { - srv := &models.Server{IPAddress: "10.0.0.7"} - p, err := BuildGuacParams(srv, "vnc", "", "", "", "", "vncpass") - if err != nil { - t.Fatalf("err: %v", err) - } - if p.Protocol != "vnc" || p.Params["hostname"] != "10.0.0.7" || p.Params["port"] != "5900" || p.Params["password"] != "vncpass" { - t.Fatalf("bad vnc params: %+v", p.Params) - } -} diff --git a/server/internal/services/defaults.go b/server/internal/services/defaults.go index 068c335..65856f7 100644 --- a/server/internal/services/defaults.go +++ b/server/internal/services/defaults.go @@ -52,7 +52,7 @@ func readDefaultStepFiles() ([]models.WorkflowStep, error) { // SeedDefaultSteps upserts default steps from disk keyed on {slug, source}. // Re-sync overwrites default-step content; user steps are never touched. -func SeedDefaultSteps() (created, updated int, err error) { +func SeedDefaultSteps(orgID string) (created, updated int, err error) { steps, err := readDefaultStepFiles() if err != nil { return 0, 0, err @@ -61,7 +61,7 @@ func SeedDefaultSteps() (created, updated int, err error) { defer cancel() col := db.Col("workflow_steps") for _, s := range steps { - filter := bson.M{"slug": s.Slug, "source": "default"} + filter := bson.M{"org_id": orgID, "slug": s.Slug, "source": "default"} set := bson.M{ "name": s.Name, "description": s.Description, @@ -75,6 +75,7 @@ func SeedDefaultSteps() (created, updated int, err error) { res, uerr := col.UpdateOne(ctx, filter, bson.M{ "$set": set, "$setOnInsert": bson.M{ + "org_id": orgID, "step_id": uuid.New().String(), "slug": s.Slug, "source": "default", diff --git a/server/internal/services/defaults_test.go b/server/internal/services/defaults_test.go deleted file mode 100644 index 8f6a463..0000000 --- a/server/internal/services/defaults_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package services - -import ( - "os" - "path/filepath" - "testing" -) - -func TestDefaultStepsDirEnv(t *testing.T) { - dir := filepath.Join(t.TempDir(), "ds") - t.Setenv("VANTAGE_DEFAULT_STEPS_DIR", dir) - got := DefaultStepsDir() - if got != dir { - t.Fatalf("got %q want %q", got, dir) - } - if _, err := os.Stat(dir); err != nil { - t.Fatalf("dir not created: %v", err) - } -} - -func TestReadDefaultStepFiles(t *testing.T) { - dir := t.TempDir() - t.Setenv("VANTAGE_DEFAULT_STEPS_DIR", dir) - good := `{"kind":"vantage.step/v1","name":"Ping Host","interpreter":"bash","script":"ping -c1 x=1 >> $WORKFLOW_ENV"}` - os.WriteFile(filepath.Join(dir, "ping.json"), []byte(good), 0600) - os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("ignore me"), 0600) - - steps, err := readDefaultStepFiles() - if err != nil { - t.Fatal(err) - } - if len(steps) != 1 { - t.Fatalf("want 1 step, got %d", len(steps)) - } - if steps[0].Slug != "ping-host" || steps[0].Source != "default" { - t.Fatalf("bad seed step: %+v", steps[0]) - } -} diff --git a/server/internal/services/keys.go b/server/internal/services/keys.go index e8d19c7..9f3900f 100644 --- a/server/internal/services/keys.go +++ b/server/internal/services/keys.go @@ -36,8 +36,9 @@ func setKeyMeta(k *models.Key) { k.HasPassphrase = k.PassphraseEncrypted != "" } -func CreateKey(label, publicKey, source, generatedByServerID, privateKey, passphrase string) (*models.Key, error) { +func CreateKey(orgID, label, publicKey, source, generatedByServerID, privateKey, passphrase string) (*models.Key, error) { key := &models.Key{ + OrgID: orgID, KeyID: uuid.NewString(), Label: label, PublicKey: publicKey, @@ -71,12 +72,12 @@ func CreateKey(label, publicKey, source, generatedByServerID, privateKey, passph return key, nil } -func GetKey(keyID string) (*models.Key, error) { +func GetKey(orgID, keyID string) (*models.Key, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var key models.Key - err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID}).Decode(&key) + err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "org_id": orgID}).Decode(&key) if err != nil { return nil, err } @@ -84,12 +85,12 @@ func GetKey(keyID string) (*models.Key, error) { return &key, nil } -func GetPrivateKey(keyID string) (string, error) { +func GetPrivateKey(orgID, keyID string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var key models.Key - if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID}).Decode(&key); err != nil { + if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "org_id": orgID}).Decode(&key); err != nil { return "", err } if key.PrivateKeyEncrypted == "" { @@ -99,7 +100,8 @@ func GetPrivateKey(keyID string) (string, error) { } // GetPassphrase returns the decrypted passphrase for a key, or an empty string -// if the key has none stored. +// if the key has none stored. Agent-path (keyed by unique key_id from an +// assignment lookup) — no org filter. func GetPassphrase(keyID string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -119,11 +121,11 @@ type KeyWithCount struct { AssignedCount int `bson:"-" json:"assigned_count"` } -func ListKeys() ([]KeyWithCount, error) { +func ListKeys(orgID string) ([]KeyWithCount, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - cursor, err := db.Col("keys").Find(ctx, bson.M{}) + cursor, err := db.Col("keys").Find(ctx, bson.M{"org_id": orgID}) if err != nil { return nil, err } @@ -138,6 +140,7 @@ func ListKeys() ([]KeyWithCount, error) { for _, k := range keys { setKeyMeta(&k) count, _ := db.Col("assignments").CountDocuments(ctx, bson.M{ + "org_id": orgID, "key_id": k.KeyID, "revoked_at": nil, }) @@ -146,19 +149,19 @@ func ListKeys() ([]KeyWithCount, error) { return result, nil } -func DeleteKey(keyID string) error { +func DeleteKey(orgID, keyID string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var key models.Key - if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID}).Decode(&key); err != nil { + if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "org_id": orgID}).Decode(&key); err != nil { return err } - if _, err := db.Col("keys").DeleteOne(ctx, bson.M{"key_id": keyID}); err != nil { + if _, err := db.Col("keys").DeleteOne(ctx, bson.M{"key_id": keyID, "org_id": orgID}); err != nil { return err } - if _, err := db.Col("assignments").DeleteMany(ctx, bson.M{"key_id": keyID}); err != nil { + if _, err := db.Col("assignments").DeleteMany(ctx, bson.M{"key_id": keyID, "org_id": orgID}); err != nil { return err } @@ -168,13 +171,23 @@ func DeleteKey(keyID string) error { return nil } -func AssignKey(keyID, serverID string) (*models.Assignment, error) { +func AssignKey(orgID, keyID, serverID string) (*models.Assignment, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + // Both sides must belong to the caller's org — the IDs arrive from the + // client as data and are consumed by unscoped agent-path queries later. + if _, err := GetKey(orgID, keyID); err != nil { + return nil, fmt.Errorf("key not found") + } + if _, err := GetServer(orgID, serverID); err != nil { + return nil, fmt.Errorf("server not found") + } + // Check if already assigned and active var existing models.Assignment err := db.Col("assignments").FindOne(ctx, bson.M{ + "org_id": orgID, "key_id": keyID, "server_id": serverID, "revoked_at": nil, @@ -184,6 +197,7 @@ func AssignKey(keyID, serverID string) (*models.Assignment, error) { } a := &models.Assignment{ + OrgID: orgID, KeyID: keyID, ServerID: serverID, AssignedAt: time.Now(), @@ -195,23 +209,23 @@ func AssignKey(keyID, serverID string) (*models.Assignment, error) { return a, nil } -func RevokeAssignment(keyID, serverID string) error { +func RevokeAssignment(orgID, keyID, serverID string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() now := time.Now() _, err := db.Col("assignments").UpdateOne(ctx, - bson.M{"key_id": keyID, "server_id": serverID, "revoked_at": nil}, + bson.M{"org_id": orgID, "key_id": keyID, "server_id": serverID, "revoked_at": nil}, bson.M{"$set": bson.M{"revoked_at": now}}, ) return err } -func GetAssignmentsForKey(keyID string) ([]models.Assignment, error) { +func GetAssignmentsForKey(orgID, keyID string) ([]models.Assignment, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - cursor, err := db.Col("assignments").Find(ctx, bson.M{"key_id": keyID, "revoked_at": nil}) + cursor, err := db.Col("assignments").Find(ctx, bson.M{"org_id": orgID, "key_id": keyID, "revoked_at": nil}) if err != nil { return nil, err } @@ -229,11 +243,11 @@ type AssignmentWithServer struct { Server *models.Server `json:"server,omitempty"` } -func GetAssignmentsWithServers(keyID string) ([]AssignmentWithServer, error) { +func GetAssignmentsWithServers(orgID, keyID string) ([]AssignmentWithServer, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - cursor, err := db.Col("assignments").Find(ctx, bson.M{"key_id": keyID}) + cursor, err := db.Col("assignments").Find(ctx, bson.M{"org_id": orgID, "key_id": keyID}) if err != nil { return nil, err } @@ -248,7 +262,7 @@ func GetAssignmentsWithServers(keyID string) ([]AssignmentWithServer, error) { for _, a := range assignments { item := AssignmentWithServer{Assignment: a} var srv models.Server - if err := db.Col("servers").FindOne(ctx, bson.M{"server_id": a.ServerID}).Decode(&srv); err == nil { + if err := db.Col("servers").FindOne(ctx, bson.M{"server_id": a.ServerID, "org_id": orgID}).Decode(&srv); err == nil { item.Server = &srv } result = append(result, item) @@ -261,11 +275,11 @@ type AssignmentWithKey struct { Key *models.Key `json:"key,omitempty"` } -func GetAssignmentsWithKeysForServer(serverID string) ([]AssignmentWithKey, error) { +func GetAssignmentsWithKeysForServer(orgID, serverID string) ([]AssignmentWithKey, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - cursor, err := db.Col("assignments").Find(ctx, bson.M{"server_id": serverID}) + cursor, err := db.Col("assignments").Find(ctx, bson.M{"org_id": orgID, "server_id": serverID}) if err != nil { return nil, err } @@ -279,7 +293,7 @@ func GetAssignmentsWithKeysForServer(serverID string) ([]AssignmentWithKey, erro result := make([]AssignmentWithKey, 0, len(assignments)) for _, a := range assignments { var key models.Key - if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID}).Decode(&key); err != nil { + if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID, "org_id": orgID}).Decode(&key); err != nil { continue } setKeyMeta(&key) diff --git a/server/internal/services/migrate.go b/server/internal/services/migrate.go new file mode 100644 index 0000000..a17916f --- /dev/null +++ b/server/internal/services/migrate.go @@ -0,0 +1,269 @@ +package services + +import ( + "context" + "errors" + "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" +) + +var scopedCollections = []string{ + "servers", "keys", "assignments", "secrets", + "workflows", "workflow_steps", "workflow_runs", + "audit_logs", "monitors", "notification_channels", + "console_sessions", "incidents", "monitor_rollups", +} + +// 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 +} + +// defaultBackfillOrg resolves the org that org-less legacy documents belong to: +// the "default" org, created if absent. Shared by 0001 and 0003 so an instance +// that ran either one converges on the same org. +func defaultBackfillOrg(ctx context.Context) (*models.Org, error) { + var org models.Org + err := db.Col("orgs").FindOne(ctx, bson.M{"slug": "default"}).Decode(&org) + switch { + case err == nil: + case errors.Is(err, mongo.ErrNoDocuments): + // Only a genuine absence justifies an insert. Treating a timeout or a + // decode failure as "absent" would race the fatal unique orgs.slug index + // and turn a transient blip into a boot crash. + org = models.Org{OrgID: uuid.NewString(), Name: "Default", Slug: "default", CreatedAt: time.Now()} + if _, err := db.Col("orgs").InsertOne(ctx, org); err != nil { + return nil, err + } + default: + return nil, err + } + return &org, 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 { + org, err := defaultBackfillOrg(ctx) + 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": marker, "applied_at": time.Now()}) + return err +} + +// MigrateMissedOrgScopes repairs collections that migration 0001 could not +// reach. 0001 originally listed "audit" and "channels", but the real collections +// are audit_logs and notification_channels, so on any instance that ran that +// version those documents were left without org_id — invisible to org-filtered +// reads, and in the channels' case silently non-firing. The 0001 marker is +// already written there, so renaming alone does not repair them; this migration +// converges both the never-migrated and the incorrectly-migrated case. +// +// It also stamps console_sessions, incidents and monitor_rollups, which gained +// an org_id only after 0001 shipped. Those carry an owning monitor/server whose +// org is authoritative, so they are derived rather than defaulted. Idempotent +// via a marker in the migrations collection. +func MigrateMissedOrgScopes() error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + const marker = "0003_missed_org_scopes" + if n, _ := db.Col("migrations").CountDocuments(ctx, bson.M{"_id": marker}); n > 0 { + return nil + } + + // Same org resolution 0001 uses, for the collections it meant to cover. + missed := []string{"audit_logs", "notification_channels"} + needs := false + for _, col := range missed { + n, _ := db.Col(col).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": false}}) + if n > 0 { + needs = true + break + } + } + if needs { + org, err := defaultBackfillOrg(ctx) + if err != nil { + return err + } + for _, col := range missed { + 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 + } + } + } + + // Derived from the owning record — defaulting these would hand one org + // another org's console history and incident timeline. + if err := backfillOrgFromOwner(ctx, "console_sessions", "server_id", "servers", "server_id"); err != nil { + return err + } + if err := backfillOrgFromOwner(ctx, "incidents", "monitor_id", "monitors", "monitor_id"); err != nil { + return err + } + if err := backfillOrgFromOwner(ctx, "monitor_rollups", "monitor_id", "monitors", "monitor_id"); err != nil { + return err + } + + _, err := db.Col("migrations").InsertOne(ctx, bson.M{"_id": marker, "applied_at": time.Now()}) + return err +} + +// backfillOrgFromOwner stamps org_id on every doc in col that lacks one, taking +// the org from the record in ownerCol it points at. Orphans (owner already +// deleted) are left alone; they are unreachable either way. +func backfillOrgFromOwner(ctx context.Context, col, localField, ownerCol, ownerField string) error { + // Decoded loosely: a single null or non-string value in the collection would + // fail a []string decode and abort the migration — and therefore boot — over + // one unusable document. Skip what we cannot use instead. + var raw []bson.RawValue + if err := db.Col(col).Distinct(ctx, localField, + bson.M{"org_id": bson.M{"$exists": false}}).Decode(&raw); err != nil { + return err + } + for _, rv := range raw { + id, ok := rv.StringValueOK() + if !ok || id == "" { + continue + } + var owner struct { + OrgID string `bson:"org_id"` + } + if err := db.Col(ownerCol).FindOne(ctx, bson.M{ownerField: id}).Decode(&owner); err != nil { + continue + } + if owner.OrgID == "" { + continue + } + if _, err := db.Col(col).UpdateMany(ctx, + bson.M{localField: id, "org_id": bson.M{"$exists": false}}, + bson.M{"$set": bson.M{"org_id": owner.OrgID}}, + ); err != nil { + return err + } + } + return nil +} + +// MigrateSettingsOrg stamps the legacy global settings singleton with the +// default org's ID. Without it an upgrade would orphan the existing SMTP +// config, alert config, retention setting, and ESO read token. Idempotent via +// a marker in the migrations collection. +func MigrateSettingsOrg() error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + const marker = "0002_settings_org_backfill" + if n, _ := db.Col("migrations").CountDocuments(ctx, bson.M{"_id": marker}); n > 0 { + return nil + } + + n, _ := db.Col("settings").CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": false}}) + if n > 0 { + // The org-less settings doc belongs to whichever org already exists — + // migration 0001 only creates a "default" org when there was legacy + // data to backfill, so keying off that slug would invent a phantom org + // and move the real org's config onto it. + var org models.Org + orgCount, err := db.Col("orgs").CountDocuments(ctx, bson.M{}) + if err != nil { + return err + } + switch orgCount { + case 1: + if err := db.Col("orgs").FindOne(ctx, bson.M{}).Decode(&org); err != nil { + return err + } + case 0: + 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 + } + default: + // Ambiguous: several orgs but an unstamped settings doc. Guessing + // would hand one org another's SMTP config and ESO token. Continuing + // is not an option either: the unique settings.org_id index built + // straight after this indexes every unstamped doc as null, so two or + // more of them collide and boot fails there instead — with a far less + // useful message. Stop here, where we can name the remedy. + return fmt.Errorf( + "settings org migration: %d settings document(s) have no org_id but %d orgs exist; "+ + "cannot infer the owner. Set org_id manually on each settings document "+ + "(db.settings.updateOne({_id:},{$set:{org_id:\"\"}})), deleting any "+ + "duplicates, then restart", n, orgCount) + } + if org.OrgID != "" { + if _, err := db.Col("settings").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 +} diff --git a/server/internal/services/monitors.go b/server/internal/services/monitors.go index b72d146..be27143 100644 --- a/server/internal/services/monitors.go +++ b/server/internal/services/monitors.go @@ -3,6 +3,7 @@ package services import ( "context" "errors" + "fmt" "log" "time" @@ -36,10 +37,10 @@ func SpecFor(m *models.Monitor) checker.Spec { } } -func ListMonitors() ([]models.Monitor, error) { +func ListMonitors(orgID string) ([]models.Monitor, error) { ctx, cancel := monCtx() defer cancel() - cur, err := db.Col("monitors").Find(ctx, bson.M{}, options.Find().SetSort(bson.M{"created_at": 1})) + cur, err := db.Col("monitors").Find(ctx, bson.M{"org_id": orgID}, options.Find().SetSort(bson.M{"created_at": 1})) if err != nil { return nil, err } @@ -50,11 +51,37 @@ func ListMonitors() ([]models.Monitor, error) { return out, nil } -// ListMonitorsForRunner returns enabled monitors whose Runner matches runner. -func ListMonitorsForRunner(runner string) ([]models.Monitor, error) { +// ListMonitorsForRunner returns enabled monitors whose Runner matches runner, +// scoped to orgID. Runner is client-supplied at write time, so an agent fetching +// its own work must scope by the org of its authenticated server record — +// otherwise another org could point a monitor at that server_id and have it run +// their checks. An empty orgID is rejected: it would silently widen the query to +// every org. +func ListMonitorsForRunner(orgID, runner string) ([]models.Monitor, error) { + if orgID == "" { + return nil, errors.New("org id required") + } + return listMonitorsForRunner(orgID, runner) +} + +// ListServerScheduledMonitors returns every enabled server-run monitor across +// all orgs. This is the in-process scheduler's entry point (mirrors the cross-org +// MarkOfflineServers sweep) and must never be called from a request-driven path — +// it performs no org scoping at all. +func ListServerScheduledMonitors() ([]models.Monitor, error) { + return listMonitorsForRunner("", models.RunnerServer) +} + +// listMonitorsForRunner is the shared query. An empty orgID means no org filter +// and is only reachable via ListServerScheduledMonitors. +func listMonitorsForRunner(orgID, runner string) ([]models.Monitor, error) { ctx, cancel := monCtx() defer cancel() - cur, err := db.Col("monitors").Find(ctx, bson.M{"runner": runner, "enabled": true}) + filter := bson.M{"runner": runner, "enabled": true} + if orgID != "" { + filter["org_id"] = orgID + } + cur, err := db.Col("monitors").Find(ctx, filter) if err != nil { return nil, err } @@ -65,7 +92,24 @@ func ListMonitorsForRunner(runner string) ([]models.Monitor, error) { return out, nil } -func GetMonitor(monitorID string) (*models.Monitor, error) { +// GetMonitor looks up a monitor scoped to an org (handler/session use). +func GetMonitor(orgID, monitorID string) (*models.Monitor, error) { + ctx, cancel := monCtx() + defer cancel() + var m models.Monitor + err := db.Col("monitors").FindOne(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID}).Decode(&m) + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + if err != nil { + return nil, err + } + return &m, nil +} + +// getMonitorByID looks up a monitor by its unique monitor_id with no org +// filter. For agent/scheduler use only (IngestResult), which has no session. +func getMonitorByID(monitorID string) (*models.Monitor, error) { ctx, cancel := monCtx() defer cancel() var m models.Monitor @@ -79,9 +123,30 @@ func GetMonitor(monitorID string) (*models.Monitor, error) { return &m, nil } -func CreateMonitor(m *models.Monitor) (*models.Monitor, error) { +// validateRunner rejects a runner that is neither the reserved server-scheduler +// value nor a server in the org. The value is client-supplied and is later +// consumed by an agent's own monitor fetch, so ownership has to be proven at +// the write boundary. +func validateRunner(orgID, runner string) error { + if runner == "" || runner == models.RunnerServer { + return nil + } + if _, err := GetServer(orgID, runner); err != nil { + return fmt.Errorf("runner server %s not found", runner) + } + return nil +} + +func CreateMonitor(orgID string, m *models.Monitor) (*models.Monitor, error) { ctx, cancel := monCtx() defer cancel() + if err := validateChannelIDs(orgID, m.ChannelIDs); err != nil { + return nil, err + } + if err := validateRunner(orgID, m.Runner); err != nil { + return nil, err + } + m.OrgID = orgID m.MonitorID = uuid.NewString() m.CreatedAt = time.Now() if m.IntervalSec <= 0 { @@ -100,31 +165,62 @@ func CreateMonitor(m *models.Monitor) (*models.Monitor, error) { return m, nil } -func UpdateMonitor(monitorID string, upd bson.M) error { +func UpdateMonitor(orgID, monitorID string, upd bson.M) error { ctx, cancel := monCtx() defer cancel() - _, err := db.Col("monitors").UpdateOne(ctx, bson.M{"monitor_id": monitorID}, bson.M{"$set": upd}) + // A present-but-wrong-type value is a hard error: silently skipping the + // check would still let the unvalidated value through to the $set. + if raw, present := upd["channel_ids"]; present { + ids, ok := raw.([]string) + if !ok { + return fmt.Errorf("channel_ids must be a string array") + } + if err := validateChannelIDs(orgID, ids); err != nil { + return err + } + } + if raw, present := upd["runner"]; present { + runner, ok := raw.(string) + if !ok { + return fmt.Errorf("runner must be a string") + } + if err := validateRunner(orgID, runner); err != nil { + return err + } + // Match CreateMonitor: an empty runner means the server scheduler. + // Storing "" would match no runner at all and silently stop the + // monitor being checked. + if runner == "" { + upd["runner"] = models.RunnerServer + } + } + _, err := db.Col("monitors").UpdateOne(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID}, bson.M{"$set": upd}) return err } -func DeleteMonitor(monitorID string) error { +func DeleteMonitor(orgID, monitorID string) error { ctx, cancel := monCtx() defer cancel() - if _, err := db.Col("monitors").DeleteOne(ctx, bson.M{"monitor_id": monitorID}); err != nil { + res, err := db.Col("monitors").DeleteOne(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID}) + if err != nil { return err } - db.Col("incidents").DeleteMany(ctx, bson.M{"monitor_id": monitorID}) - db.Col("monitor_rollups").DeleteMany(ctx, bson.M{"monitor_id": monitorID}) + // Only cascade when the org-scoped delete actually removed a monitor. + if res.DeletedCount == 0 { + return nil + } + db.Col("incidents").DeleteMany(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID}) + db.Col("monitor_rollups").DeleteMany(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID}) return nil } -func ListIncidents(monitorID string, limit int64) ([]models.Incident, error) { +func ListIncidents(orgID, monitorID string, limit int64) ([]models.Incident, error) { ctx, cancel := monCtx() defer cancel() if limit <= 0 { limit = 50 } - cur, err := db.Col("incidents").Find(ctx, bson.M{"monitor_id": monitorID}, + cur, err := db.Col("incidents").Find(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID}, options.Find().SetSort(bson.M{"started_at": -1}).SetLimit(limit)) if err != nil { return nil, err @@ -137,11 +233,11 @@ func ListIncidents(monitorID string, limit int64) ([]models.Incident, error) { } // UptimeRollups returns hourly rollups for a monitor since the cutoff, oldest first. -func UptimeRollups(monitorID string, since time.Time) ([]models.Rollup, error) { +func UptimeRollups(orgID, monitorID string, since time.Time) ([]models.Rollup, error) { ctx, cancel := monCtx() defer cancel() cur, err := db.Col("monitor_rollups").Find(ctx, - bson.M{"monitor_id": monitorID, "period_start": bson.M{"$gte": since}}, + bson.M{"monitor_id": monitorID, "org_id": orgID, "period_start": bson.M{"$gte": since}}, options.Find().SetSort(bson.M{"period_start": 1})) if err != nil { return nil, err @@ -157,14 +253,49 @@ func UptimeRollups(monitorID string, since time.Time) ([]models.Rollup, error) { // incidents on up<->down transitions, rolls up the hourly bucket, and fires // notifications on transition. Both the server scheduler and agent-reported // results funnel through here. -func IngestResult(monitorID string, res checker.Result) error { +// +// monitorID is client-supplied on the agent path, so the caller passes the org +// and runner it is authenticated as: orgID is the reporting agent's server org +// and runner is its server_id. A result is only applied to a monitor owned by +// that org and assigned to that runner. An empty orgID is rejected — it would +// skip the ownership check entirely. +func IngestResult(orgID, runner, monitorID string, res checker.Result) error { + if orgID == "" { + return errors.New("org id required") + } + return ingestResult(orgID, runner, monitorID, res) +} + +// IngestServerScheduledResult applies a result produced by the in-process server +// scheduler, which has no org context of its own. This is the scheduler's entry +// point and must never be called from a request-driven path — it skips the org +// ownership check. +func IngestServerScheduledResult(monitorID string, res checker.Result) error { + return ingestResult("", models.RunnerServer, monitorID, res) +} + +// ingestResult is the shared implementation. An empty orgID skips the org +// ownership check and is only reachable via IngestServerScheduledResult. +func ingestResult(orgID, runner, monitorID string, res checker.Result) error { ctx, cancel := monCtx() defer cancel() - m, err := GetMonitor(monitorID) - if err != nil || m == nil { + m, err := getMonitorByID(monitorID) + if err != nil { return err } + // Report not-found the same way as a cross-org hit, so probing an unknown + // monitor_id is no quieter than probing a foreign one and stale monitors + // stay visible to operators. + if m == nil { + return fmt.Errorf("monitor %s not found", monitorID) + } + if orgID != "" && m.OrgID != orgID { + return fmt.Errorf("monitor %s belongs to another org", monitorID) + } + if m.Runner != runner { + return fmt.Errorf("monitor %s is not run by %s", monitorID, runner) + } now := time.Now() prev := m.State.Status @@ -207,9 +338,14 @@ func IngestResult(monitorID string, res checker.Result) error { if res.Up { up = 1 } + // org_id via $setOnInsert rather than the filter: a legacy bucket written + // before rollups were tenanted must keep accumulating, not fork in two. db.Col("monitor_rollups").UpdateOne(ctx, bson.M{"monitor_id": monitorID, "period_start": bucket}, - bson.M{"$inc": bson.M{"checks": 1, "up_count": up, "sum_latency": int64(res.LatencyMs)}}, + bson.M{ + "$inc": bson.M{"checks": 1, "up_count": up, "sum_latency": int64(res.LatencyMs)}, + "$setOnInsert": bson.M{"org_id": m.OrgID}, + }, options.UpdateOne().SetUpsert(true)) // Transition handling. @@ -217,6 +353,7 @@ func IngestResult(monitorID string, res checker.Result) error { switch newStatus { case models.StatusDown: inc := models.Incident{ + OrgID: m.OrgID, IncidentID: uuid.NewString(), MonitorID: monitorID, StartedAt: now, @@ -227,7 +364,7 @@ func IngestResult(monitorID string, res checker.Result) error { case models.StatusUp: if prev == models.StatusDown { db.Col("incidents").UpdateOne(ctx, - bson.M{"monitor_id": monitorID, "resolved_at": nil}, + bson.M{"monitor_id": monitorID, "org_id": m.OrgID, "resolved_at": nil}, bson.M{"$set": bson.M{"resolved_at": now}}) notifyTransition(m, newStatus, res.Message) } @@ -243,7 +380,7 @@ func notifyTransition(m *models.Monitor, newStatus, message string) { if len(m.ChannelIDs) == 0 { return } - channels, err := GetChannels(m.ChannelIDs) + channels, err := GetChannels(m.OrgID, m.ChannelIDs) if err != nil { log.Printf("notify: load channels for %s: %v", m.MonitorID, err) return @@ -266,5 +403,5 @@ func notifyTransition(m *models.Monitor, newStatus, message string) { } }(ch) } - _ = UpdateMonitor(m.MonitorID, bson.M{"state.last_notified_at": time.Now()}) + _ = UpdateMonitor(m.OrgID, m.MonitorID, bson.M{"state.last_notified_at": time.Now()}) } diff --git a/server/internal/services/org_oidc.go b/server/internal/services/org_oidc.go new file mode 100644 index 0000000..1b37e03 --- /dev/null +++ b/server/internal/services/org_oidc.go @@ -0,0 +1,52 @@ +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 +} diff --git a/server/internal/services/orgs.go b/server/internal/services/orgs.go new file mode 100644 index 0000000..27f345a --- /dev/null +++ b/server/internal/services/orgs.go @@ -0,0 +1,168 @@ +package services + +import ( + "context" + "fmt" + "log" + "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, +} + +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 +} + +// ListOrgIDs returns the org_id of every organization. Used by startup tasks +// (e.g. seeding default workflow steps) that must run once per org. +func ListOrgIDs() ([]string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cursor, err := db.Col("orgs").Find(ctx, bson.M{}) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var orgs []models.Org + if err := cursor.All(ctx, &orgs); err != nil { + return nil, err + } + ids := make([]string, 0, len(orgs)) + for _, o := range orgs { + ids = append(ids, o.OrgID) + } + return ids, nil +} + +// CountOrgs returns the number of organizations on the instance. Used by +// first-run bootstrap to tell "empty instance" from "upgraded single-tenant +// instance whose data already sits under a migration-created org". +func CountOrgs() (int64, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return db.Col("orgs").CountDocuments(ctx, bson.M{}) +} + +// FirstOrg returns the sole/earliest org. Callers must have established that +// exactly one exists before treating it as authoritative. +func FirstOrg() (*models.Org, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var o models.Org + if err := db.Col("orgs").FindOne(ctx, bson.M{}).Decode(&o); err != nil { + return nil, err + } + return &o, nil +} + +// AdoptOrg renames an existing org to name, re-slugging it when the new slug is +// clean to take. It exists for the upgrade path: migration 0001 stamps every +// legacy document with the "default" org's ID, so bootstrap must claim that org +// rather than mint a second one — otherwise the operator signs in to an empty +// instance while all their servers and keys stay behind under "default". +// +// The slug is only changed when the derived one is usable and free; anything +// else keeps the current slug, including the reserved "default", which stays +// valid because it is pre-existing rather than newly chosen. +func AdoptOrg(orgID, name string) (*models.Org, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + set := bson.M{"name": name} + + slug := Slugify(name) + if len(slug) > 40 { + slug = slug[:40] + } + if len(slug) >= 3 && !reservedSlugs[slug] { + n, err := db.Col("orgs").CountDocuments(ctx, bson.M{"slug": slug, "org_id": bson.M{"$ne": orgID}}) + if err != nil { + return nil, err + } + if n == 0 { + set["slug"] = slug + } + } + + if _, err := db.Col("orgs").UpdateOne(ctx, bson.M{"org_id": orgID}, bson.M{"$set": set}); err != nil { + if mongo.IsDuplicateKeyError(err) { + return nil, fmt.Errorf("organization slug already taken") + } + return nil, err + } + return GetOrg(orgID) +} + +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 + } + + // Boot-time seeding only covers orgs that already existed, so an org created + // at runtime would have an empty step library until the next restart. Not + // fatal: the org is usable without it and seeding is retried on boot. + if created, updated, err := SeedDefaultSteps(o.OrgID); err != nil { + log.Printf("warning: failed to seed default steps for new org %s: %v", o.OrgID, err) + } else { + log.Printf("default steps seeded for new org %s: %d created, %d updated", o.OrgID, created, updated) + } + return o, nil +} diff --git a/server/internal/services/resolve_test.go b/server/internal/services/resolve_test.go deleted file mode 100644 index 3131bfc..0000000 --- a/server/internal/services/resolve_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package services - -import ( - "testing" - - "github.com/mrhid6/vantage/server/internal/models" -) - -func TestResolveInlineStep(t *testing.T) { - ref := models.WorkflowStepRef{ - Order: 2, - OnFailure: "", - Inline: &models.WorkflowStep{ - Name: "adhoc", - Interpreter: "bash", - Script: "echo hi", - SecretRefs: []string{"TOKEN"}, - DeclaredInputs: []models.InputParam{ - {Name: "REGION", Default: "eu"}, - }, - }, - Inputs: map[string]string{"REGION": "us"}, - } - rs := resolveInlineStep(ref) - if rs.Name != "adhoc" || rs.Script != "echo hi" || rs.Order != 2 { - t.Fatalf("bad resolve: %+v", rs) - } - if rs.OnFailure != "stop" { - t.Fatalf("want default on_failure=stop, got %q", rs.OnFailure) - } - if rs.Inputs["REGION"] != "us" { - t.Fatalf("want input override us, got %q", rs.Inputs["REGION"]) - } - if len(rs.SecretRefs) != 1 || rs.SecretRefs[0] != "TOKEN" { - t.Fatalf("bad secret refs: %v", rs.SecretRefs) - } -} diff --git a/server/internal/services/secrets.go b/server/internal/services/secrets.go index de05a69..35a44b4 100644 --- a/server/internal/services/secrets.go +++ b/server/internal/services/secrets.go @@ -2,6 +2,7 @@ package services import ( "context" + "errors" "fmt" "sort" "time" @@ -13,25 +14,47 @@ import ( "go.mongodb.org/mongo-driver/v2/mongo/options" ) -// EnsureSecretIndexes creates the unique compound index on (group, key). +// EnsureSecretIndexes creates the unique compound index on (org_id, group, key). +// The pre-multi-tenant index was on (group, key) alone, which made a second org +// collide on the same group/key — drop it if a live DB still carries it. func EnsureSecretIndexes() error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() + if err := db.Col("secrets").Indexes().DropOne(ctx, "group_1_key_1"); err != nil && !isIndexNotFound(err) { + return err + } + _, err := db.Col("secrets").Indexes().CreateOne(ctx, mongo.IndexModel{ - Keys: bson.D{{Key: "group", Value: 1}, {Key: "key", Value: 1}}, + Keys: bson.D{{Key: "org_id", Value: 1}, {Key: "group", Value: 1}, {Key: "key", Value: 1}}, Options: options.Index().SetUnique(true), }) return err } +// isIndexNotFound reports whether err is Mongo's IndexNotFound (27), returned +// when dropping an index that was never created, or NamespaceNotFound (26), +// returned when the collection itself does not exist yet. Both mean "there is +// no legacy index to drop" — on a fresh install nothing has written to these +// collections, so the drop must be tolerated or the index creation that follows +// it never runs and a brand-new deployment crash-loops at startup. +func isIndexNotFound(err error) bool { + var ce mongo.CommandError + if errors.As(err, &ce) { + return ce.Code == 27 || ce.Name == "IndexNotFound" || + ce.Code == 26 || ce.Name == "NamespaceNotFound" + } + return false +} + // ListSecretGroups returns a summary of every group with its key count and // most recent update time. -func ListSecretGroups() ([]models.GroupSummary, error) { +func ListSecretGroups(orgID string) ([]models.GroupSummary, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() pipeline := mongo.Pipeline{ + {{Key: "$match", Value: bson.D{{Key: "org_id", Value: orgID}}}}, {{Key: "$group", Value: bson.D{ {Key: "_id", Value: "$group"}, {Key: "key_count", Value: bson.D{{Key: "$sum", Value: 1}}}, @@ -68,11 +91,11 @@ func ListSecretGroups() ([]models.GroupSummary, error) { // GetSecretGroup returns the keys within a group, sorted by key name, without // decrypted values. -func GetSecretGroup(group string) ([]models.Secret, error) { +func GetSecretGroup(orgID, group string) ([]models.Secret, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - cursor, err := db.Col("secrets").Find(ctx, bson.M{"group": group}, + cursor, err := db.Col("secrets").Find(ctx, bson.M{"org_id": orgID, "group": group}, options.Find().SetSort(bson.D{{Key: "key", Value: 1}})) if err != nil { return nil, err @@ -87,9 +110,10 @@ func GetSecretGroup(group string) ([]models.Secret, error) { } // GetSecretGroupDecrypted returns a flat map of key → plaintext value for a -// group. Used by the ESO read endpoint. -func GetSecretGroupDecrypted(group string) (map[string]string, error) { - docs, err := GetSecretGroup(group) +// group. Also used by the ESO read endpoint, which resolves its org from the +// per-org bearer token rather than from a session. +func GetSecretGroupDecrypted(orgID, group string) (map[string]string, error) { + docs, err := GetSecretGroup(orgID, group) if err != nil { return nil, err } @@ -105,12 +129,12 @@ func GetSecretGroupDecrypted(group string) (map[string]string, error) { } // RevealSecret returns the decrypted value of a single key. -func RevealSecret(group, key string) (string, error) { +func RevealSecret(orgID, group, key string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var doc models.Secret - err := db.Col("secrets").FindOne(ctx, bson.M{"group": group, "key": key}).Decode(&doc) + err := db.Col("secrets").FindOne(ctx, bson.M{"org_id": orgID, "group": group, "key": key}).Decode(&doc) if err == mongo.ErrNoDocuments { return "", fmt.Errorf("secret not found") } @@ -121,7 +145,7 @@ func RevealSecret(group, key string) (string, error) { } // UpsertSecrets encrypts and writes each key/value pair into the group. -func UpsertSecrets(group string, values map[string]string) error { +func UpsertSecrets(orgID, group string, values map[string]string) error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -131,8 +155,9 @@ func UpsertSecrets(group string, values map[string]string) error { return fmt.Errorf("encrypt %s: %w", key, err) } _, err = db.Col("secrets").UpdateOne(ctx, - bson.M{"group": group, "key": key}, + bson.M{"org_id": orgID, "group": group, "key": key}, bson.M{"$set": bson.M{ + "org_id": orgID, "encrypted_value": encrypted, "updated_at": time.Now(), }}, @@ -156,19 +181,19 @@ func SortedKeys(m map[string]string) []string { } // DeleteSecret removes a single key from a group. -func DeleteSecret(group, key string) error { +func DeleteSecret(orgID, group, key string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - _, err := db.Col("secrets").DeleteOne(ctx, bson.M{"group": group, "key": key}) + _, err := db.Col("secrets").DeleteOne(ctx, bson.M{"org_id": orgID, "group": group, "key": key}) return err } // DeleteSecretGroup removes an entire group and all its keys. -func DeleteSecretGroup(group string) error { +func DeleteSecretGroup(orgID, group string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - _, err := db.Col("secrets").DeleteMany(ctx, bson.M{"group": group}) + _, err := db.Col("secrets").DeleteMany(ctx, bson.M{"org_id": orgID, "group": group}) return err } diff --git a/server/internal/services/servers.go b/server/internal/services/servers.go index a6fff8e..39d0098 100644 --- a/server/internal/services/servers.go +++ b/server/internal/services/servers.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "log" "strings" "time" @@ -29,13 +30,14 @@ func HashToken(token string) string { return hex.EncodeToString(sum[:]) } -func CreateServer() (*models.Server, string, error) { +func CreateServer(orgID string) (*models.Server, string, error) { token, err := generateToken(32) if err != nil { return nil, "", err } expires := time.Now().Add(time.Hour) s := &models.Server{ + OrgID: orgID, ServerID: uuid.NewString(), PreRegToken: token, PreRegExpires: &expires, @@ -52,7 +54,22 @@ func CreateServer() (*models.Server, string, error) { return s, token, nil } -func GetServer(serverID string) (*models.Server, error) { +// GetServer looks up a server scoped to an org (handler/session use). +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 +} + +// getServerByID looks up a server by its unique server_id with no org filter. +// For agent/internal use only (e.g. workflow runner resolving org from a run). +func getServerByID(serverID string) (*models.Server, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -164,6 +181,11 @@ func ValidateAgentToken(serverID, agentToken string) (*models.Server, error) { if err != nil { return nil, fmt.Errorf("invalid agent token") } + // Defence in depth: every agent-path caller scopes its work by this OrgID, + // so a blank one would widen those queries instead of narrowing them. + if s.OrgID == "" { + return nil, fmt.Errorf("server %s has no org", serverID) + } return &s, nil } @@ -214,12 +236,12 @@ func UpdateServerLastSeen(serverID, agentVersion string) error { return err } -func ListServers() ([]models.Server, error) { +func ListServers(orgID string) ([]models.Server, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}) - cursor, err := db.Col("servers").Find(ctx, bson.M{}, opts) + cursor, err := db.Col("servers").Find(ctx, bson.M{"org_id": orgID}, opts) if err != nil { return nil, err } @@ -232,16 +254,16 @@ func ListServers() ([]models.Server, error) { return servers, nil } -func DeleteServer(serverID string) error { +func DeleteServer(orgID, serverID string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - _, err := db.Col("servers").DeleteOne(ctx, bson.M{"server_id": serverID}) + _, err := db.Col("servers").DeleteOne(ctx, bson.M{"server_id": serverID, "org_id": orgID}) if err != nil { return err } // Also remove assignments - _, err = db.Col("assignments").DeleteMany(ctx, bson.M{"server_id": serverID}) + _, err = db.Col("assignments").DeleteMany(ctx, bson.M{"server_id": serverID, "org_id": orgID}) return err } @@ -261,39 +283,73 @@ func StoreAvailableUpdates(serverID string, pkgs []models.PackageUpdate) error { } func MarkOfflineServers() error { - settings, _ := GetSettings() - thresholdMinutes := 5 - if settings != nil && settings.Alerts.OfflineThresholdMinutes > 0 { - thresholdMinutes = settings.Alerts.OfflineThresholdMinutes - } - threshold := time.Duration(thresholdMinutes) * time.Minute - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - cutoff := time.Now().Add(-threshold) - - // Find servers about to transition to offline so we can alert on them. - cursor, err := db.Col("servers").Find(ctx, bson.M{ - "status": "active", - "last_seen": bson.M{"$lt": cutoff}, - }) + orgIDs, err := ListOrgIDs() if err != nil { return err } - defer cursor.Close(ctx) - var goingOffline []models.Server - if err := cursor.All(ctx, &goingOffline); err != nil { - return err + // No session here, so the sweep runs per-org and each org's threshold and + // alert config come from that org's own settings doc. Each org gets its own + // deadline so a slow org can't starve the ones after it, and a failure on + // one org is logged rather than aborting the whole sweep. + for _, orgID := range orgIDs { + if err := markOfflineForFilter(bson.M{"org_id": orgID}, orgID); err != nil { + log.Printf("offline sweep failed for org %s: %v", orgID, err) + } } + // Servers whose org_id matches no existing org (org deleted, or the doc + // predates the backfill) would otherwise never be swept, where the old + // global query caught them. Sweep them with the default threshold; there is + // no org settings doc to read, and no org to alert. + if err := markOfflineForFilter(bson.M{"org_id": bson.M{"$nin": orgIDs}}, ""); err != nil { + log.Printf("offline sweep failed for orphaned servers: %v", err) + } + return nil +} + +// markOfflineForFilter transitions active-but-stale servers matching scope to +// offline. orgID selects whose settings supply the threshold and alert config; +// empty means defaults with no alerting (orphaned servers). +func markOfflineForFilter(scope bson.M, orgID string) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var settings *models.Settings + thresholdMinutes := 5 + if orgID != "" { + settings, _ = GetSettings(orgID) + if settings != nil && settings.Alerts.OfflineThresholdMinutes > 0 { + thresholdMinutes = settings.Alerts.OfflineThresholdMinutes + } + } + cutoff := time.Now().Add(-time.Duration(thresholdMinutes) * time.Minute) + + filter := bson.M{ + "status": "active", + "last_seen": bson.M{"$lt": cutoff}, + } + for k, v := range scope { + filter[k] = v + } + + // Find servers about to transition to offline so we can alert on them. + cursor, err := db.Col("servers").Find(ctx, filter) + if err != nil { + return err + } + var goingOffline []models.Server + err = cursor.All(ctx, &goingOffline) + cursor.Close(ctx) + if err != nil { + return err + } if len(goingOffline) == 0 { return nil } for _, s := range goingOffline { - LogEvent("server.offline", "system", s.ServerID, "", fmt.Sprintf("%s (%s) went offline", s.Hostname, s.IPAddress)) + LogEvent(s.OrgID, "server.offline", "system", s.ServerID, "", fmt.Sprintf("%s (%s) went offline", s.Hostname, s.IPAddress)) if settings != nil && settings.Alerts.Enabled && settings.Alerts.WebhookURL != "" { go SendOfflineWebhook(settings.Alerts.WebhookURL, s.Hostname, s.ServerID, s.IPAddress) } @@ -302,11 +358,7 @@ func MarkOfflineServers() error { } } - _, err = db.Col("servers").UpdateMany(ctx, - bson.M{ - "status": "active", - "last_seen": bson.M{"$lt": cutoff}, - }, + _, err = db.Col("servers").UpdateMany(ctx, filter, bson.M{"$set": bson.M{"status": "offline"}}, ) return err diff --git a/server/internal/services/servers_console_test.go b/server/internal/services/servers_console_test.go deleted file mode 100644 index 1b0e973..0000000 --- a/server/internal/services/servers_console_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package services - -import "testing" - -func TestOSTypeFromInfo(t *testing.T) { - cases := map[string]string{ - "windows amd64": "windows", - "linux amd64": "linux", - "linux arm64": "linux", - "": "linux", - "darwin arm64": "linux", - } - for in, want := range cases { - if got := OSTypeFromInfo(in); got != want { - t.Errorf("OSTypeFromInfo(%q) = %q, want %q", in, got, want) - } - } -} diff --git a/server/internal/services/settings.go b/server/internal/services/settings.go index 9895192..addd39f 100644 --- a/server/internal/services/settings.go +++ b/server/internal/services/settings.go @@ -34,14 +34,47 @@ var defaultSettings = models.Settings{ }, } -func GetSettings() (*models.Settings, error) { +// EnsureSettingsIndexes creates the per-org uniqueness constraints on settings. +// Pre-multi-tenant deployments had a single global settings doc and no indexes; +// drop any legacy index if a live DB still carries one. +func EnsureSettingsIndexes() error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := db.Col("settings").Indexes().DropOne(ctx, "secrets.read_token_hash_1"); err != nil && !isIndexNotFound(err) { + return err + } + + if _, err := db.Col("settings").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "org_id", Value: 1}}, + Options: options.Index().SetUnique(true), + }); err != nil { + return err + } + + // Partial so the many settings docs with no ESO token set don't collide on + // a missing (or empty) field. Explicitly named so it does not share Mongo's + // default name with the legacy index dropped above, which would make every + // restart drop and rebuild the enforcing index. + _, err := db.Col("settings").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "secrets.read_token_hash", Value: 1}}, + Options: options.Index().SetUnique(true).SetName("settings_read_token_hash_unique"). + SetPartialFilterExpression(bson.M{ + "secrets.read_token_hash": bson.M{"$type": "string", "$gt": ""}, + }), + }) + return err +} + +func GetSettings(orgID string) (*models.Settings, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var s models.Settings - err := db.Col("settings").FindOne(ctx, bson.M{}).Decode(&s) + err := db.Col("settings").FindOne(ctx, bson.M{"org_id": orgID}).Decode(&s) if err == mongo.ErrNoDocuments { cp := defaultSettings + cp.OrgID = orgID return &cp, nil } if err != nil { @@ -58,7 +91,7 @@ func hashToken(token string) string { // RotateSecretsReadToken generates a new ESO read token, stores its SHA-256 // hash, and returns the plaintext token exactly once. -func RotateSecretsReadToken() (string, error) { +func RotateSecretsReadToken(orgID string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -69,11 +102,14 @@ func RotateSecretsReadToken() (string, error) { token := hex.EncodeToString(raw) _, err := db.Col("settings").UpdateOne(ctx, - bson.M{}, - bson.M{"$set": bson.M{ - "secrets.read_token_hash": hashToken(token), - "secrets.rotated_at": time.Now(), - }}, + bson.M{"org_id": orgID}, + bson.M{ + "$set": bson.M{ + "secrets.read_token_hash": hashToken(token), + "secrets.rotated_at": time.Now(), + }, + "$setOnInsert": bson.M{"org_id": orgID}, + }, options.UpdateOne().SetUpsert(true), ) if err != nil { @@ -82,25 +118,33 @@ func RotateSecretsReadToken() (string, error) { return token, nil } -// VerifySecretsReadToken reports whether the supplied token matches the stored -// hash, using a constant-time comparison. -func VerifySecretsReadToken(token string) bool { +// ResolveSecretsReadToken looks the presented token's hash up directly and +// returns the owning org. This is the ESO machine-to-machine path: the org is +// carried by the token itself, since there is no session to scope it. +func ResolveSecretsReadToken(token string) (string, bool) { if token == "" { - return false + return "", false } - s, err := GetSettings() - if err != nil || s.Secrets.ReadTokenHash == "" { - return false + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var s models.Settings + err := db.Col("settings").FindOne(ctx, bson.M{"secrets.read_token_hash": hashToken(token)}).Decode(&s) + if err != nil || s.Secrets.ReadTokenHash == "" || s.OrgID == "" { + return "", false } expected, err := hex.DecodeString(s.Secrets.ReadTokenHash) if err != nil { - return false + return "", false } got := sha256.Sum256([]byte(token)) - return subtle.ConstantTimeCompare(expected, got[:]) == 1 + if subtle.ConstantTimeCompare(expected, got[:]) != 1 { + return "", false + } + return s.OrgID, true } -func SaveSettings(alerts models.AlertSettings, email models.EmailSettings, retentionDays *int) error { +func SaveSettings(orgID string, alerts models.AlertSettings, email models.EmailSettings, retentionDays *int) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -116,8 +160,8 @@ func SaveSettings(alerts models.AlertSettings, email models.EmailSettings, reten set["workflow_log_retention_days"] = *retentionDays } _, err := db.Col("settings").UpdateOne(ctx, - bson.M{}, - bson.M{"$set": set}, + bson.M{"org_id": orgID}, + bson.M{"$set": set, "$setOnInsert": bson.M{"org_id": orgID}}, options.UpdateOne().SetUpsert(true), ) return err @@ -125,8 +169,8 @@ func SaveSettings(alerts models.AlertSettings, email models.EmailSettings, reten // GetWorkflowLogRetentionDays returns the log retention in days: 30 when unset, // 0 for keep-forever, or the configured value. -func GetWorkflowLogRetentionDays() (int, error) { - s, err := GetSettings() +func GetWorkflowLogRetentionDays(orgID string) (int, error) { + s, err := GetSettings(orgID) if err != nil { return 30, err } diff --git a/server/internal/services/stepio.go b/server/internal/services/stepio.go index ad6f12e..e8f9ea9 100644 --- a/server/internal/services/stepio.go +++ b/server/internal/services/stepio.go @@ -66,19 +66,19 @@ func ParseStepDoc(b []byte) (models.WorkflowStep, error) { } // ImportStepToLibrary parses a doc and persists it as a new user library step. -func ImportStepToLibrary(b []byte) (*models.WorkflowStep, error) { +func ImportStepToLibrary(orgID string, b []byte) (*models.WorkflowStep, error) { s, err := ParseStepDoc(b) if err != nil { return nil, err } - return CreateStep(s) + return CreateStep(orgID, s) } // ExportStep loads a library step and marshals it to a portable doc. -func ExportStep(stepID string) ([]byte, error) { +func ExportStep(orgID, stepID string) ([]byte, error) { ctx, cancel := wfCtx() defer cancel() - s, err := getStep(ctx, stepID) + s, err := getStep(ctx, orgID, stepID) if err != nil { return nil, err } diff --git a/server/internal/services/stepio_test.go b/server/internal/services/stepio_test.go deleted file mode 100644 index d5499e7..0000000 --- a/server/internal/services/stepio_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package services - -import ( - "encoding/json" - "testing" - - "github.com/mrhid6/vantage/server/internal/models" -) - -func mkStep() models.WorkflowStep { - return models.WorkflowStep{ - StepID: "should-not-export", Source: "default", Name: "Restart", - Interpreter: "bash", Script: "echo x=1 >> $WORKFLOW_ENV", - SecretRefs: []string{"TOK"}, - } -} - -func TestParseStepDocValid(t *testing.T) { - raw := `{"kind":"vantage.step/v1","name":"Restart","interpreter":"bash", - "script":"echo x=1 >> $WORKFLOW_ENV","declared_outputs":["stale"], - "declared_inputs":[{"name":"A","default":"1"}],"secret_refs":["TOK"]}` - s, err := ParseStepDoc([]byte(raw)) - if err != nil { - t.Fatal(err) - } - if s.Name != "Restart" || s.Interpreter != "bash" { - t.Fatalf("bad parse: %+v", s) - } - // declared_outputs recomputed from script, ignoring the file's ["stale"]. - if len(s.DeclaredOutputs) != 1 || s.DeclaredOutputs[0] != "x" { - t.Fatalf("outputs should be derived, got %v", s.DeclaredOutputs) - } - if s.StepID != "" || s.Source != "" { - t.Fatalf("parse must not set id/source") - } -} - -func TestParseStepDocBadKind(t *testing.T) { - if _, err := ParseStepDoc([]byte(`{"kind":"nope","name":"x"}`)); err == nil { - t.Fatal("want error for bad kind") - } -} - -func TestParseStepDocBadJSON(t *testing.T) { - if _, err := ParseStepDoc([]byte(`{`)); err == nil { - t.Fatal("want error for bad json") - } -} - -func TestExportStepDocRoundTrip(t *testing.T) { - doc := ExportStepDoc(mkStep()) - b, _ := json.Marshal(doc) - s, err := ParseStepDoc(b) - if err != nil { - t.Fatal(err) - } - if s.Name != "Restart" || s.Interpreter != "bash" { - t.Fatalf("round trip lost data: %+v", s) - } -} diff --git a/server/internal/services/steplogs.go b/server/internal/services/steplogs.go index e6fcf2b..d6c4e6e 100644 --- a/server/internal/services/steplogs.go +++ b/server/internal/services/steplogs.go @@ -2,6 +2,7 @@ package services import ( "bytes" + "log" "os" "path/filepath" "strings" @@ -10,6 +11,7 @@ import ( "github.com/mrhid6/vantage/server/internal/db" "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" ) // WorkflowLogDir returns the base directory for workflow step logs, creating it. @@ -164,54 +166,84 @@ func StartLogSweeper() { }() } +// sweepLogs walks the run-log dirs on disk. Log dirs are keyed by run ID, not +// by org, and this runs with no session — so retention is resolved per run from +// the owning org of that run's doc, with the per-org values cached for the +// sweep. Runs whose doc is gone fall back to the default retention. func sweepLogs() { - days := retentionDays() - if days <= 0 { - return - } - cutoff := time.Now().AddDate(0, 0, -days) base := WorkflowLogDir() entries, err := os.ReadDir(base) if err != nil { return } + cache := map[string]int{} + now := time.Now() + for _, e := range entries { if !e.IsDir() { continue } runID := e.Name() dir := filepath.Join(base, runID) - if runExpired(runID, dir, cutoff) { + + orgID, finishedAt, found, err := runRetentionInfo(runID) + if err != nil { + // A transient lookup failure is not evidence the run is gone — + // purging at the default retention here would delete logs an org + // had set to keep longer, or forever. + log.Printf("log sweep: retention lookup failed for run %s: %v", runID, err) + continue + } + if found && finishedAt == nil { + continue // still running / never finished — keep + } + + days, ok := cache[orgID] + if !ok { + days = defaultRetentionDays + if orgID != "" { + if v, err := GetWorkflowLogRetentionDays(orgID); err == nil { + days = v + } + } + cache[orgID] = days + } + if days <= 0 { + continue // keep forever + } + cutoff := now.AddDate(0, 0, -days) + + if found { + if finishedAt.Before(cutoff) { + _ = os.RemoveAll(dir) + } + continue + } + // run doc gone: use dir mtime + if fi, e := os.Stat(dir); e == nil && fi.ModTime().Before(cutoff) { _ = os.RemoveAll(dir) } } } -// runExpired is true when the run finished before cutoff (falling back to dir -// mtime when the run doc is gone). -func runExpired(runID, dir string, cutoff time.Time) bool { +const defaultRetentionDays = 30 + +// runRetentionInfo returns the owning org and finish time of a run, and whether +// the run doc still exists. A non-nil error means the lookup itself failed and +// says nothing about whether the run doc exists. +func runRetentionInfo(runID string) (string, *time.Time, bool, error) { ctx, cancel := wfCtx() defer cancel() var run struct { + OrgID string `bson:"org_id"` FinishedAt *time.Time `bson:"finished_at"` } err := db.Col("workflow_runs").FindOne(ctx, bson.M{"run_id": runID}).Decode(&run) - if err == nil { - if run.FinishedAt == nil { - return false // still running / never finished — keep - } - return run.FinishedAt.Before(cutoff) + if err == mongo.ErrNoDocuments { + return "", nil, false, nil } - // run doc gone: use dir mtime - if fi, e := os.Stat(dir); e == nil { - return fi.ModTime().Before(cutoff) + if err != nil { + return "", nil, false, err } - return false -} - -func retentionDays() int { - if v, err := GetWorkflowLogRetentionDays(); err == nil { - return v - } - return 30 + return run.OrgID, run.FinishedAt, true, nil } diff --git a/server/internal/services/stepscan_test.go b/server/internal/services/stepscan_test.go deleted file mode 100644 index c02c032..0000000 --- a/server/internal/services/stepscan_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package services - -import ( - "reflect" - "testing" -) - -func TestDeriveOutputs(t *testing.T) { - script := `#!/bin/bash -echo "test=123" >> $WORKFLOW_ENV -echo "other=hi" >> "$WORKFLOW_ENV" -printf 'third=1\n' >> $WORKFLOW_ENV -echo "test=456" >> $WORKFLOW_ENV -echo "ignored=nope" -NORMAL=assignment -` - got := DeriveOutputs(script) - want := []string{"test", "other", "third"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("got %v want %v", got, want) - } -} - -func TestDeriveOutputsPowershell(t *testing.T) { - script := `"result=ok" >> $env:WORKFLOW_ENV -Add-Content $env:WORKFLOW_ENV "count=5"` - got := DeriveOutputs(script) - want := []string{"result", "count"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("got %v want %v", got, want) - } -} - -func TestDeriveOutputsNone(t *testing.T) { - got := DeriveOutputs("echo hello\nNOPE=1") - if len(got) != 0 { - t.Fatalf("got %v want empty", got) - } -} - -func TestSlugify(t *testing.T) { - if got := Slugify("Restart NGINX Service!"); got != "restart-nginx-service" { - t.Fatalf("got %q", got) - } -} diff --git a/server/internal/services/sync.go b/server/internal/services/sync.go index d3add76..d4bd523 100644 --- a/server/internal/services/sync.go +++ b/server/internal/services/sync.go @@ -13,7 +13,15 @@ func BuildAuthorizedKeys(serverID string) ([]string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + // Agent path — no session, so the org comes from the server record itself + // and both follow-up queries are scoped to it. + srv, err := getServerByID(serverID) + if err != nil { + return nil, err + } + cursor, err := db.Col("assignments").Find(ctx, bson.M{ + "org_id": srv.OrgID, "server_id": serverID, "revoked_at": nil, }) @@ -30,7 +38,7 @@ func BuildAuthorizedKeys(serverID string) ([]string, error) { var lines []string for _, a := range assignments { var key models.Key - err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID}).Decode(&key) + err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID, "org_id": srv.OrgID}).Decode(&key) if err != nil { continue } diff --git a/server/internal/services/users.go b/server/internal/services/users.go new file mode 100644 index 0000000..e6728b4 --- /dev/null +++ b/server/internal/services/users.go @@ -0,0 +1,183 @@ +package services + +import ( + "context" + "errors" + "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" +) + +// ErrLastOwner is returned when an operation would leave an org with no owner, +// which would lock every remaining member out of org administration. +var ErrLastOwner = errors.New("this is the organization's last owner — promote another member to owner first") + +// CountUsers counts users across the whole instance. It answers "is this a +// brand new deployment", so it is deliberately unscoped; anything that asks +// about a single tenant must use CountOrgUsers. +func CountUsers() (int64, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return db.Col("users").CountDocuments(ctx, bson.M{}) +} + +func CountOrgUsers(orgID string) (int64, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return db.Col("users").CountDocuments(ctx, bson.M{"org_id": orgID}) +} + +// countOtherOwners counts owner-role users in the org excluding exceptUserID, +// i.e. how many owners would remain if that user were removed or demoted. +func countOtherOwners(orgID, exceptUserID string) (int64, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return db.Col("users").CountDocuments(ctx, bson.M{ + "org_id": orgID, + "role": models.RoleOwner, + "user_id": bson.M{"$ne": exceptUserID}, + }) +} + +func GetUserInOrg(orgID, userID string) (*models.User, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var u models.User + err := db.Col("users").FindOne(ctx, bson.M{"user_id": userID, "org_id": orgID}).Decode(&u) + if err != nil { + return nil, err + } + return &u, nil +} + +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") + } + if !models.ValidRole(role) { + return nil, fmt.Errorf("invalid role %q", role) + } + 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 { + if !models.ValidRole(role) { + return fmt.Errorf("invalid role %q", role) + } + target, err := GetUserInOrg(orgID, userID) + if err != nil { + return fmt.Errorf("user not found") + } + // Demoting the final owner would leave nobody able to administer the org. + if target.Role == models.RoleOwner && role != models.RoleOwner { + others, err := countOtherOwners(orgID, userID) + if err != nil { + return err + } + if others == 0 { + return ErrLastOwner + } + } + + 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 { + target, err := GetUserInOrg(orgID, userID) + if err != nil { + return fmt.Errorf("user not found") + } + if target.Role == models.RoleOwner { + others, err := countOtherOwners(orgID, userID) + if err != nil { + return err + } + if others == 0 { + return ErrLastOwner + } + } + + 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 +} diff --git a/server/internal/services/validate_test.go b/server/internal/services/validate_test.go deleted file mode 100644 index 8121e16..0000000 --- a/server/internal/services/validate_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package services - -import ( - "testing" - - "github.com/mrhid6/vantage/server/internal/models" -) - -func TestValidateWorkflow(t *testing.T) { - inline := &models.WorkflowStep{Name: "x", Interpreter: "bash", Script: "echo hi"} - cases := []struct { - name string - ref models.WorkflowStepRef - wantErr bool - }{ - {"library only", models.WorkflowStepRef{StepID: "abc"}, false}, - {"inline only", models.WorkflowStepRef{Inline: inline}, false}, - {"both set", models.WorkflowStepRef{StepID: "abc", Inline: inline}, true}, - {"neither set", models.WorkflowStepRef{}, true}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - err := ValidateWorkflow(models.Workflow{Steps: []models.WorkflowStepRef{tc.ref}}) - if (err != nil) != tc.wantErr { - t.Fatalf("got err=%v want wantErr=%v", err, tc.wantErr) - } - }) - } -} diff --git a/server/internal/services/workflow_runner.go b/server/internal/services/workflow_runner.go index 72d5d73..7ab91b0 100644 --- a/server/internal/services/workflow_runner.go +++ b/server/internal/services/workflow_runner.go @@ -19,8 +19,8 @@ const stepDispatchGrace = 15 * time.Second // TriggerWorkflow snapshots the workflow, creates a run doc, and starts a // background goroutine per target server (parallel fan-out). Returns run_id. -func TriggerWorkflow(workflowID, actor string) (string, error) { - wf, err := GetWorkflow(workflowID) +func TriggerWorkflow(orgID, workflowID, actor string) (string, error) { + wf, err := GetWorkflow(orgID, workflowID) if err != nil { return "", err } @@ -30,21 +30,27 @@ func TriggerWorkflow(workflowID, actor string) (string, error) { if len(wf.Steps) == 0 { return "", fmt.Errorf("workflow has no steps") } + // Re-check ownership at trigger time — targets may predate validation or a + // server may have been removed since the workflow was saved. + if err := validateTargetServers(orgID, wf.TargetServerIDs); err != nil { + return "", err + } // Reject a concurrent run of the same workflow. ctx, cancel := wfCtx() - running := db.Col("workflow_runs").FindOne(ctx, bson.M{"workflow_id": workflowID, "status": "running"}) + running := db.Col("workflow_runs").FindOne(ctx, bson.M{"org_id": orgID, "workflow_id": workflowID, "status": "running"}) cancel() if running.Err() == nil { return "", fmt.Errorf("workflow already has a run in progress") } - resolved, err := resolveSteps(wf) + resolved, err := resolveSteps(orgID, wf) if err != nil { return "", err } run := models.WorkflowRun{ + OrgID: orgID, RunID: uuid.New().String(), WorkflowID: workflowID, Name: wf.Name, @@ -56,7 +62,7 @@ func TriggerWorkflow(workflowID, actor string) (string, error) { } for _, sid := range wf.TargetServerIDs { hostname := sid - if s, e := GetServer(sid); e == nil { + if s, e := getServerByID(sid); e == nil { hostname = s.Hostname } sr := models.ServerRun{ServerID: sid, Hostname: hostname, Status: "queued", RunEnv: map[string]string{}} @@ -78,7 +84,7 @@ func TriggerWorkflow(workflowID, actor string) (string, error) { // resolveSteps freezes each workflow step ref into a ResolvedStep by loading the // library step and applying overrides. -func resolveSteps(wf *models.Workflow) ([]models.ResolvedStep, error) { +func resolveSteps(orgID string, wf *models.Workflow) ([]models.ResolvedStep, error) { ctx, cancel := wfCtx() defer cancel() out := make([]models.ResolvedStep, 0, len(wf.Steps)) @@ -87,7 +93,7 @@ func resolveSteps(wf *models.Workflow) ([]models.ResolvedStep, error) { out = append(out, resolveInlineStep(ref)) continue } - lib, err := getStep(ctx, ref.StepID) + lib, err := getStep(ctx, orgID, ref.StepID) if err != nil { return nil, err } @@ -158,14 +164,14 @@ func resolveInlineStep(ref models.WorkflowStepRef) models.ResolvedStep { // executeRun fans out one goroutine per server run and waits for all to finish. func executeRun(runID string) { - run, err := GetRun(runID) + run, err := getRunByID(runID) if err != nil { return } done := make(chan int, len(run.ServerRuns)) for i := range run.ServerRuns { go func(idx int) { - runServer(runID, idx, run.Steps, run.ServerRuns[idx].ServerID) + runServer(run.OrgID, runID, idx, run.Steps, run.ServerRuns[idx].ServerID) done <- idx }(i) } @@ -174,7 +180,7 @@ func executeRun(runID string) { } // Aggregate status. - final, _ := GetRun(runID) + final, _ := getRunByID(runID) status := "success" for _, sr := range final.ServerRuns { if sr.Status == "failed" { @@ -190,7 +196,7 @@ func executeRun(runID string) { // runServer executes the resolved steps sequentially on one server, threading // output env forward and applying per-step failure policy. -func runServer(runID string, srvIdx int, steps []models.ResolvedStep, serverID string) { +func runServer(orgID, runID string, srvIdx int, steps []models.ResolvedStep, serverID string) { now := time.Now() setServerRun(runID, srvIdx, bson.M{"server_runs.$.status": "running", "server_runs.$.started_at": now}) @@ -218,7 +224,7 @@ func runServer(runID string, srvIdx int, steps []models.ResolvedStep, serverID s } // Merge secrets into command env (kept out of persisted logs). - secretVals := resolveSecrets(step.SecretRefs) + secretVals := resolveSecrets(orgID, step.SecretRefs) for k, v := range secretVals { allSecrets[k] = v } @@ -366,7 +372,7 @@ func expandVars(v string, lookup map[string]string) string { }) } -func resolveSecrets(refs []string) map[string]string { +func resolveSecrets(orgID string, refs []string) map[string]string { out := map[string]string{} for _, ref := range refs { // ref format "group/KEY"; resolve via RevealSecret. @@ -374,7 +380,7 @@ func resolveSecrets(refs []string) map[string]string { if len(parts) != 2 { continue } - if v, err := RevealSecret(parts[0], parts[1]); err == nil { + if v, err := RevealSecret(orgID, parts[0], parts[1]); err == nil { out[parts[1]] = v } } @@ -403,7 +409,7 @@ func setServerRun(runID string, srvIdx int, set bson.M) { // serverIDAt returns the server_id at an index (positional operator needs a match). func serverIDAt(runID string, srvIdx int) string { - r, err := GetRun(runID) + r, err := getRunByID(runID) if err != nil || srvIdx >= len(r.ServerRuns) { return "" } @@ -467,7 +473,10 @@ func updateStep(runID, serverID string, order int, set bson.M) { // ---- reads ---- -func GetRun(runID string) (*models.WorkflowRun, error) { +// getRunByID looks up a run by its unique run_id with no org filter. For +// agent/internal run-execution use only (executeRun/runServer, etc.), which +// don't have a session and instead resolve org from the run doc itself. +func getRunByID(runID string) (*models.WorkflowRun, error) { ctx, cancel := wfCtx() defer cancel() var r models.WorkflowRun @@ -478,10 +487,22 @@ func GetRun(runID string) (*models.WorkflowRun, error) { return &r, err } -func ListRuns(workflowID string, limit int64) ([]models.WorkflowRun, error) { +// GetRun looks up a run scoped to an org (handler/session use). +func GetRun(orgID, runID string) (*models.WorkflowRun, error) { ctx, cancel := wfCtx() defer cancel() - cur, err := db.Col("workflow_runs").Find(ctx, bson.M{"workflow_id": workflowID}, + var r models.WorkflowRun + err := db.Col("workflow_runs").FindOne(ctx, bson.M{"run_id": runID, "org_id": orgID}).Decode(&r) + if err == mongo.ErrNoDocuments { + return nil, fmt.Errorf("run not found") + } + return &r, err +} + +func ListRuns(orgID, workflowID string, limit int64) ([]models.WorkflowRun, error) { + ctx, cancel := wfCtx() + defer cancel() + cur, err := db.Col("workflow_runs").Find(ctx, bson.M{"org_id": orgID, "workflow_id": workflowID}, options.Find().SetSort(bson.D{{Key: "started_at", Value: -1}}).SetLimit(limit)) if err != nil { return nil, err @@ -494,12 +515,12 @@ func ListRuns(workflowID string, limit int64) ([]models.WorkflowRun, error) { return runs, nil } -func CancelRun(runID string) error { +func CancelRun(orgID, runID string) error { now := time.Now() ctx, cancel := wfCtx() defer cancel() _, err := db.Col("workflow_runs").UpdateOne(ctx, - bson.M{"run_id": runID, "status": "running"}, + bson.M{"org_id": orgID, "run_id": runID, "status": "running"}, bson.M{"$set": bson.M{"status": "cancelled", "finished_at": now}}) return err } diff --git a/server/internal/services/workflows.go b/server/internal/services/workflows.go index 1a55c1d..01f7c8a 100644 --- a/server/internal/services/workflows.go +++ b/server/internal/services/workflows.go @@ -25,8 +25,13 @@ func EnsureWorkflowIndexes() error { }); err != nil { return err } + // The pre-multi-tenant index was on slug alone, so seeding defaults for a + // second org collided — drop it if a live DB still carries it. + if err := db.Col("workflow_steps").Indexes().DropOne(ctx, "slug_1"); err != nil && !isIndexNotFound(err) { + return err + } if _, err := db.Col("workflow_steps").Indexes().CreateOne(ctx, mongo.IndexModel{ - Keys: bson.D{{Key: "slug", Value: 1}}, + Keys: bson.D{{Key: "org_id", Value: 1}, {Key: "slug", Value: 1}}, Options: options.Index().SetUnique(true). SetPartialFilterExpression(bson.M{"source": "default"}), }); err != nil { @@ -45,10 +50,10 @@ func EnsureWorkflowIndexes() error { // ---- Steps ---- -func ListSteps() ([]models.WorkflowStep, error) { +func ListSteps(orgID string) ([]models.WorkflowStep, error) { ctx, cancel := wfCtx() defer cancel() - cur, err := db.Col("workflow_steps").Find(ctx, bson.M{}, + cur, err := db.Col("workflow_steps").Find(ctx, bson.M{"org_id": orgID}, options.Find().SetSort(bson.D{{Key: "name", Value: 1}})) if err != nil { return nil, err @@ -63,10 +68,10 @@ func ListSteps() ([]models.WorkflowStep, error) { // StepUsageCounts returns, per library step_id, the number of distinct // workflows that reference it. Inline steps have no step_id and are ignored. -func StepUsageCounts() (map[string]int, error) { +func StepUsageCounts(orgID string) (map[string]int, error) { ctx, cancel := wfCtx() defer cancel() - cur, err := db.Col("workflows").Find(ctx, bson.M{}) + cur, err := db.Col("workflows").Find(ctx, bson.M{"org_id": orgID}) if err != nil { return nil, err } @@ -89,9 +94,10 @@ func StepUsageCounts() (map[string]int, error) { return counts, nil } -func CreateStep(s models.WorkflowStep) (*models.WorkflowStep, error) { +func CreateStep(orgID string, s models.WorkflowStep) (*models.WorkflowStep, error) { ctx, cancel := wfCtx() defer cancel() + s.OrgID = orgID s.StepID = uuid.New().String() s.CreatedAt = time.Now() s.UpdatedAt = s.CreatedAt @@ -111,10 +117,10 @@ func CreateStep(s models.WorkflowStep) (*models.WorkflowStep, error) { return &s, nil } -func UpdateStep(stepID string, s models.WorkflowStep) error { +func UpdateStep(orgID, stepID string, s models.WorkflowStep) error { ctx, cancel := wfCtx() defer cancel() - _, err := db.Col("workflow_steps").UpdateOne(ctx, bson.M{"step_id": stepID}, bson.M{"$set": bson.M{ + _, err := db.Col("workflow_steps").UpdateOne(ctx, bson.M{"step_id": stepID, "org_id": orgID}, bson.M{"$set": bson.M{ "name": s.Name, "description": s.Description, "interpreter": s.Interpreter, @@ -127,14 +133,14 @@ func UpdateStep(stepID string, s models.WorkflowStep) error { return err } -func DeleteStep(stepID string) error { +func DeleteStep(orgID, stepID string) error { ctx, cancel := wfCtx() defer cancel() - if _, err := db.Col("workflow_steps").DeleteOne(ctx, bson.M{"step_id": stepID}); err != nil { + if _, err := db.Col("workflow_steps").DeleteOne(ctx, bson.M{"step_id": stepID, "org_id": orgID}); err != nil { return err } // Cascade: remove this step from every workflow that references it, re-sequencing orders. - cur, err := db.Col("workflows").Find(ctx, bson.M{"steps.step_id": stepID}) + cur, err := db.Col("workflows").Find(ctx, bson.M{"steps.step_id": stepID, "org_id": orgID}) if err != nil { return err } @@ -164,9 +170,9 @@ func DeleteStep(stepID string) error { return nil } -func getStep(ctx context.Context, stepID string) (*models.WorkflowStep, error) { +func getStep(ctx context.Context, orgID, stepID string) (*models.WorkflowStep, error) { var s models.WorkflowStep - err := db.Col("workflow_steps").FindOne(ctx, bson.M{"step_id": stepID}).Decode(&s) + err := db.Col("workflow_steps").FindOne(ctx, bson.M{"step_id": stepID, "org_id": orgID}).Decode(&s) if err == mongo.ErrNoDocuments { return nil, fmt.Errorf("step %s not found", stepID) } @@ -175,10 +181,10 @@ func getStep(ctx context.Context, stepID string) (*models.WorkflowStep, error) { // ---- Workflows ---- -func ListWorkflows() ([]models.Workflow, error) { +func ListWorkflows(orgID string) ([]models.Workflow, error) { ctx, cancel := wfCtx() defer cancel() - cur, err := db.Col("workflows").Find(ctx, bson.M{}, + cur, err := db.Col("workflows").Find(ctx, bson.M{"org_id": orgID}, options.Find().SetSort(bson.D{{Key: "name", Value: 1}})) if err != nil { return nil, err @@ -191,20 +197,21 @@ func ListWorkflows() ([]models.Workflow, error) { return wfs, nil } -func GetWorkflow(id string) (*models.Workflow, error) { +func GetWorkflow(orgID, id string) (*models.Workflow, error) { ctx, cancel := wfCtx() defer cancel() var w models.Workflow - err := db.Col("workflows").FindOne(ctx, bson.M{"workflow_id": id}).Decode(&w) + err := db.Col("workflows").FindOne(ctx, bson.M{"workflow_id": id, "org_id": orgID}).Decode(&w) if err == mongo.ErrNoDocuments { return nil, fmt.Errorf("workflow not found") } return &w, err } -func CreateWorkflow(w models.Workflow) (*models.Workflow, error) { +func CreateWorkflow(orgID string, w models.Workflow) (*models.Workflow, error) { ctx, cancel := wfCtx() defer cancel() + w.OrgID = orgID w.WorkflowID = uuid.New().String() w.CreatedAt = time.Now() w.UpdatedAt = w.CreatedAt @@ -217,6 +224,9 @@ func CreateWorkflow(w models.Workflow) (*models.Workflow, error) { if err := ValidateWorkflow(w); err != nil { return nil, err } + if err := validateTargetServers(orgID, w.TargetServerIDs); err != nil { + return nil, err + } normalizeInlineSteps(&w) if _, err := db.Col("workflows").InsertOne(ctx, w); err != nil { return nil, err @@ -224,14 +234,17 @@ func CreateWorkflow(w models.Workflow) (*models.Workflow, error) { return &w, nil } -func UpdateWorkflow(id string, w models.Workflow) error { +func UpdateWorkflow(orgID, id string, w models.Workflow) error { ctx, cancel := wfCtx() defer cancel() if err := ValidateWorkflow(w); err != nil { return err } + if err := validateTargetServers(orgID, w.TargetServerIDs); err != nil { + return err + } normalizeInlineSteps(&w) - _, err := db.Col("workflows").UpdateOne(ctx, bson.M{"workflow_id": id}, bson.M{"$set": bson.M{ + _, err := db.Col("workflows").UpdateOne(ctx, bson.M{"workflow_id": id, "org_id": orgID}, bson.M{"$set": bson.M{ "name": w.Name, "target_server_ids": w.TargetServerIDs, "steps": w.Steps, @@ -240,6 +253,18 @@ func UpdateWorkflow(id string, w models.Workflow) error { return err } +// validateTargetServers rejects any target server that does not belong to the +// org. The IDs are client-supplied and are later consumed by the runner's +// unscoped lookups, so ownership has to be proven at the write boundary. +func validateTargetServers(orgID string, serverIDs []string) error { + for _, sid := range serverIDs { + if _, err := GetServer(orgID, sid); err != nil { + return fmt.Errorf("target server %s not found", sid) + } + } + return nil +} + // normalizeInlineSteps derives outputs for inline steps and strips fields that // only belong to library steps. func normalizeInlineSteps(w *models.Workflow) { @@ -263,9 +288,9 @@ func normalizeInlineSteps(w *models.Workflow) { } } -func DeleteWorkflow(id string) error { +func DeleteWorkflow(orgID, id string) error { ctx, cancel := wfCtx() defer cancel() - _, err := db.Col("workflows").DeleteOne(ctx, bson.M{"workflow_id": id}) + _, err := db.Col("workflows").DeleteOne(ctx, bson.M{"workflow_id": id, "org_id": orgID}) return err } diff --git a/server/internal/services/workflows_usage_test.go b/server/internal/services/workflows_usage_test.go deleted file mode 100644 index aef5c63..0000000 --- a/server/internal/services/workflows_usage_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package services - -import ( - "os" - "testing" - - "github.com/mrhid6/vantage/server/internal/db" - "github.com/mrhid6/vantage/server/internal/models" -) - -var mongoAvailable bool - -func TestMain(m *testing.M) { - uri := os.Getenv("VANTAGE_TEST_MONGO_URI") - if uri == "" { - uri = "mongodb://localhost:27117" - } - if err := db.Connect(uri, "vantage_test"); err != nil { - // No MongoDB available in this environment; DB-backed tests will be skipped - // individually, but the rest of the package's tests must still run. - mongoAvailable = false - } else { - mongoAvailable = true - } - os.Exit(m.Run()) -} - -func mkUsageStep(name string) models.WorkflowStep { - return models.WorkflowStep{Name: name, Interpreter: "bash", Script: "echo hi"} -} - -func mkWorkflowWithStep(name, stepID string) models.Workflow { - return models.Workflow{Name: name, Steps: []models.WorkflowStepRef{{StepID: stepID, Order: 0, OnFailure: "stop"}}} -} - -func TestStepUsageCounts(t *testing.T) { - if !mongoAvailable { - t.Skip("mongo unavailable: set VANTAGE_TEST_MONGO_URI") - } - // A step used by two workflows, a step used by none. - used, err := CreateStep(mkUsageStep("used-step")) - if err != nil { - t.Fatal(err) - } - unused, err := CreateStep(mkUsageStep("unused-step")) - if err != nil { - t.Fatal(err) - } - if _, err := CreateWorkflow(mkWorkflowWithStep("wf-a", used.StepID)); err != nil { - t.Fatal(err) - } - if _, err := CreateWorkflow(mkWorkflowWithStep("wf-b", used.StepID)); err != nil { - t.Fatal(err) - } - - counts, err := StepUsageCounts() - if err != nil { - t.Fatal(err) - } - if counts[used.StepID] != 2 { - t.Fatalf("used step: want 2, got %d", counts[used.StepID]) - } - if counts[unused.StepID] != 0 { - t.Fatalf("unused step: want 0, got %d", counts[unused.StepID]) - } -} diff --git a/web/app/audit/page.tsx b/web/app/(app)/audit/page.tsx similarity index 100% rename from web/app/audit/page.tsx rename to web/app/(app)/audit/page.tsx diff --git a/web/app/keys/[id]/page.tsx b/web/app/(app)/keys/[id]/page.tsx similarity index 100% rename from web/app/keys/[id]/page.tsx rename to web/app/(app)/keys/[id]/page.tsx diff --git a/web/app/keys/page.tsx b/web/app/(app)/keys/page.tsx similarity index 100% rename from web/app/keys/page.tsx rename to web/app/(app)/keys/page.tsx diff --git a/web/app/(app)/layout.tsx b/web/app/(app)/layout.tsx new file mode 100644 index 0000000..f03b1bc --- /dev/null +++ b/web/app/(app)/layout.tsx @@ -0,0 +1,17 @@ +import { AuthProvider } from "@/components/AuthProvider"; +import { Sidebar } from "@/components/Sidebar"; + +export default function AppLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + +
+ +
{children}
+
+
+ ); +} diff --git a/web/app/monitors/[id]/edit/page.tsx b/web/app/(app)/monitors/[id]/edit/page.tsx similarity index 100% rename from web/app/monitors/[id]/edit/page.tsx rename to web/app/(app)/monitors/[id]/edit/page.tsx diff --git a/web/app/monitors/[id]/page.tsx b/web/app/(app)/monitors/[id]/page.tsx similarity index 100% rename from web/app/monitors/[id]/page.tsx rename to web/app/(app)/monitors/[id]/page.tsx diff --git a/web/app/monitors/new/page.tsx b/web/app/(app)/monitors/new/page.tsx similarity index 100% rename from web/app/monitors/new/page.tsx rename to web/app/(app)/monitors/new/page.tsx diff --git a/web/app/monitors/page.tsx b/web/app/(app)/monitors/page.tsx similarity index 100% rename from web/app/monitors/page.tsx rename to web/app/(app)/monitors/page.tsx diff --git a/web/app/page.tsx b/web/app/(app)/page.tsx similarity index 100% rename from web/app/page.tsx rename to web/app/(app)/page.tsx diff --git a/web/app/secrets/[group]/page.tsx b/web/app/(app)/secrets/[group]/page.tsx similarity index 100% rename from web/app/secrets/[group]/page.tsx rename to web/app/(app)/secrets/[group]/page.tsx diff --git a/web/app/secrets/page.tsx b/web/app/(app)/secrets/page.tsx similarity index 100% rename from web/app/secrets/page.tsx rename to web/app/(app)/secrets/page.tsx diff --git a/web/app/servers/[id]/console/page.tsx b/web/app/(app)/servers/[id]/console/page.tsx similarity index 100% rename from web/app/servers/[id]/console/page.tsx rename to web/app/(app)/servers/[id]/console/page.tsx diff --git a/web/app/servers/[id]/page.tsx b/web/app/(app)/servers/[id]/page.tsx similarity index 100% rename from web/app/servers/[id]/page.tsx rename to web/app/(app)/servers/[id]/page.tsx diff --git a/web/app/servers/new/page.tsx b/web/app/(app)/servers/new/page.tsx similarity index 100% rename from web/app/servers/new/page.tsx rename to web/app/(app)/servers/new/page.tsx diff --git a/web/app/servers/page.tsx b/web/app/(app)/servers/page.tsx similarity index 100% rename from web/app/servers/page.tsx rename to web/app/(app)/servers/page.tsx diff --git a/web/app/settings/notifications/page.tsx b/web/app/(app)/settings/notifications/page.tsx similarity index 100% rename from web/app/settings/notifications/page.tsx rename to web/app/(app)/settings/notifications/page.tsx diff --git a/web/app/(app)/settings/org/page.tsx b/web/app/(app)/settings/org/page.tsx new file mode 100644 index 0000000..4089392 --- /dev/null +++ b/web/app/(app)/settings/org/page.tsx @@ -0,0 +1,418 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api, auth as authApi, type OrgUser, type Role } from "@/lib/api"; +import { useAuth } from "@/components/AuthProvider"; +import { Badge, Button, Card, Modal, Table, Tbody, Td, Th, Thead, Tr } from "@/components/ui"; + +const ROLES: Role[] = ["owner", "admin", "member"]; + +const inputClass = + "w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"; + +function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ); +} + +function roleVariant(role: Role) { + if (role === "owner") return "accent" as const; + if (role === "admin") return "warning" as const; + return "neutral" as const; +} + +function MembersCard() { + const queryClient = useQueryClient(); + const { user } = useAuth(); + const [addOpen, setAddOpen] = useState(false); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [role, setRole] = useState("member"); + + const { data: users, isLoading, error } = useQuery({ queryKey: ["org-users"], queryFn: api.listOrgUsers }); + + const invalidate = () => queryClient.invalidateQueries({ queryKey: ["org-users"] }); + + const { mutate: createUser, isPending: creating, error: createError } = useMutation({ + mutationFn: () => api.createOrgUser({ email, password, role }), + onSuccess: () => { + invalidate(); + setAddOpen(false); + setEmail(""); + setPassword(""); + setRole("member"); + }, + }); + + const { mutate: changeRole, error: roleError } = useMutation({ + mutationFn: ({ userId, next }: { userId: string; next: Role }) => api.updateOrgUserRole(userId, next), + onSuccess: invalidate, + // A rejected change (last owner, owner-only grant) leaves the select showing + // the value the server refused — refetch so the row snaps back to the truth. + onError: invalidate, + }); + + const { mutate: removeUser, error: removeError } = useMutation({ + mutationFn: (userId: string) => api.deleteOrgUser(userId), + onSuccess: invalidate, + }); + + const actionError = (roleError ?? removeError) as Error | null; + + // The server lets only an owner grant or change the owner role. Mirror that + // here so admins aren't offered controls that can only 403. + const isOwner = user?.role === "owner"; + const assignableRoles = isOwner ? ROLES : ROLES.filter((r) => r !== "owner"); + + return ( + +
+
+

Members

+

+ People with access to this organization. Owners and admins can manage settings. +

+
+ +
+ + {actionError && ( +
+ {actionError.message} +
+ )} + + {isLoading ? ( +
+
+
+ ) : error ? ( +

{(error as Error).message}

+ ) : !users || users.length === 0 ? ( +

No members yet.

+ ) : ( + + + + + + + + + + + + {users.map((u: OrgUser) => { + const isSelf = u.user_id === user?.user_id; + // Own row stays read-only, and only owners may act on owners. + const locked = isSelf || (u.role === "owner" && !isOwner); + return ( + + + + + + + + ); + })} + +
EmailRoleSign-inLast loginActions
+ {u.email} + {isSelf && (you)} + + {locked ? ( + {u.role} + ) : ( + + )} + + {u.auth_source === "oidc" ? "SSO" : "Password"} + + {u.last_login ? new Date(u.last_login).toLocaleString() : "Never"} + + {!locked && ( + + )} +
+ )} + + setAddOpen(false)}> +
{ + e.preventDefault(); + createUser(); + }} + className="space-y-4" + > + + setEmail(e.target.value)} + className={inputClass} + /> + + + + setPassword(e.target.value)} + className={inputClass} + /> + + + + + + + {createError && ( +
+ {(createError as Error).message} +
+ )} + +
+ + +
+
+
+ + ); +} + +function OIDCCard() { + const queryClient = useQueryClient(); + const { data: cfg, isLoading } = useQuery({ queryKey: ["org-oidc"], queryFn: api.getOrgOIDC }); + + const [issuer, setIssuer] = useState(""); + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + const [enabled, setEnabled] = useState(false); + const [saved, setSaved] = useState(false); + const [copied, setCopied] = useState(false); + + const redirectUrl = authApi.oidcRedirectUrl(); + + useEffect(() => { + if (!cfg) return; + setIssuer(cfg.issuer ?? ""); + setClientId(cfg.client_id ?? ""); + setEnabled(cfg.enabled); + // The secret is never returned; leave the field blank to mean "unchanged". + setClientSecret(""); + }, [cfg]); + + const { mutate: save, isPending, error } = useMutation({ + mutationFn: () => api.saveOrgOIDC({ issuer, client_id: clientId, client_secret: clientSecret, enabled }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["org-oidc"] }); + setClientSecret(""); + setSaved(true); + setTimeout(() => setSaved(false), 3000); + }, + }); + + async function copyRedirect() { + await navigator.clipboard.writeText(redirectUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + if (isLoading) { + return ( + +
+
+
+ + ); + } + + const secretSet = cfg?.client_secret_set ?? false; + + return ( + +
+

Single Sign-On (OIDC)

+

+ Let members sign in with your identity provider. Users are provisioned into this organization on + first sign-in. +

+
+ +
+

+ Register this redirect URL with your provider: +

+
+ + {redirectUrl} + + +
+
+ +
{ + e.preventDefault(); + save(); + }} + className="space-y-4" + > + + setIssuer(e.target.value)} + className={inputClass} + /> + + + + setClientId(e.target.value)} + className={inputClass} + /> + + + + setClientSecret(e.target.value)} + className={inputClass} + /> + + +
+ + + {secretSet ? "Client secret is configured" : "No client secret configured"} + +
+ + + + {enabled && !secretSet && !clientSecret && ( +
+ SSO cannot complete sign-in without a client secret. +
+ )} + + {error && ( +
+ {(error as Error).message} +
+ )} + +
+ + {saved && SSO settings saved.} +
+
+
+ ); +} + +export default function OrgSettingsPage() { + const { org, isAdmin } = useAuth(); + + if (!isAdmin) { + return ( +
+ +

You don't have access

+

+ Organization settings are available to owners and admins only. Ask an administrator if you need + access. +

+
+
+ ); + } + + return ( +
+
+

Organization

+

+ {org ? `Manage members and sign-in for ${org.name}.` : "Manage members and sign-in."} +

+
+ +
+ + +
+
+ ); +} diff --git a/web/app/settings/page.tsx b/web/app/(app)/settings/page.tsx similarity index 93% rename from web/app/settings/page.tsx rename to web/app/(app)/settings/page.tsx index 4fd7cc6..4b253e9 100644 --- a/web/app/settings/page.tsx +++ b/web/app/(app)/settings/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import Link from "next/link"; import { api } from "@/lib/api"; +import { useAuth } from "@/components/AuthProvider"; import { Button, Card } from "@/components/ui"; function SectionCard({ title, description, icon, children, className }: { title: string; description?: string; icon: React.ReactNode; children: React.ReactNode; className?: string }) { @@ -136,8 +137,14 @@ function SecretsTokenCard({ tokenSet, rotatedAt }: { tokenSet: boolean; rotatedA export default function SettingsPage() { const queryClient = useQueryClient(); + const { isAdmin } = useAuth(); - const { data: settings, isLoading } = useQuery({ queryKey: ["settings"], queryFn: api.getSettings }); + // /api/settings requires owner|admin and 403s for members, so don't even ask. + const { data: settings, isLoading } = useQuery({ + queryKey: ["settings"], + queryFn: api.getSettings, + enabled: isAdmin, + }); const [thresholdMinutes, setThresholdMinutes] = useState(5); const [logRetentionDays, setLogRetentionDays] = useState(30); @@ -170,6 +177,19 @@ export default function SettingsPage() { }); } + if (!isAdmin) { + return ( +
+ +

You don't have access

+

+ Settings are available to owners and admins only. Ask an administrator if you need access. +

+
+
+ ); + } + if (isLoading) { return (
diff --git a/web/app/steps/page.tsx b/web/app/(app)/steps/page.tsx similarity index 100% rename from web/app/steps/page.tsx rename to web/app/(app)/steps/page.tsx diff --git a/web/app/workflows/[id]/page.tsx b/web/app/(app)/workflows/[id]/page.tsx similarity index 100% rename from web/app/workflows/[id]/page.tsx rename to web/app/(app)/workflows/[id]/page.tsx diff --git a/web/app/workflows/[id]/runs/[runId]/page.tsx b/web/app/(app)/workflows/[id]/runs/[runId]/page.tsx similarity index 100% rename from web/app/workflows/[id]/runs/[runId]/page.tsx rename to web/app/(app)/workflows/[id]/runs/[runId]/page.tsx diff --git a/web/app/workflows/[id]/runs/page.tsx b/web/app/(app)/workflows/[id]/runs/page.tsx similarity index 100% rename from web/app/workflows/[id]/runs/page.tsx rename to web/app/(app)/workflows/[id]/runs/page.tsx diff --git a/web/app/workflows/page.tsx b/web/app/(app)/workflows/page.tsx similarity index 100% rename from web/app/workflows/page.tsx rename to web/app/(app)/workflows/page.tsx diff --git a/web/app/layout.tsx b/web/app/layout.tsx index c181f0d..eabf01e 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -1,8 +1,6 @@ import type { Metadata } from "next"; import "./globals.css"; import { Providers } from "@/components/Providers"; -import { AuthProvider } from "@/components/AuthProvider"; -import { Sidebar } from "@/components/Sidebar"; export const metadata: Metadata = { title: "Vantage", @@ -17,16 +15,7 @@ export default function RootLayout({ return ( - - -
- -
- {children} -
-
-
-
+ {children} ); diff --git a/web/app/login/page.tsx b/web/app/login/page.tsx new file mode 100644 index 0000000..5eb3aa2 --- /dev/null +++ b/web/app/login/page.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { auth } from "@/lib/api"; +import { Button, Card } from "@/components/ui"; + +export default function LoginPage() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + + // If the org has no users yet, first-run setup is the only way in. And if the + // visitor already has a valid session on this host, the form is a dead end — + // send them into the app instead. + useEffect(() => { + (async () => { + try { + const s = await auth.bootstrapStatus(); + if (s.needs_setup) { + window.location.href = "/setup"; + return; + } + } catch { + // Status unavailable — fall through and let the login form stand. + } + try { + await auth.me(); + window.location.href = "/"; + } catch { + // Not signed in (or session invalid here) — show the form. + } + })(); + }, []); + + const { mutate: signIn, isPending, error } = useMutation({ + mutationFn: () => auth.login(email, password), + onSuccess: () => { + window.location.href = "/"; + }, + }); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + signIn(); + } + + return ( +
+
+
+
+ + + +
+

Sign in to Vantage

+
+ + +
+
+ + setEmail(e.target.value)} + className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30" + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30" + /> +
+ + {error && ( +
+ {(error as Error).message} +
+ )} + + +
+ +
+
+ or +
+
+ + + + +

+ SSO must be enabled for this organization by an administrator. +

+ +
+
+ ); +} diff --git a/web/app/setup/page.tsx b/web/app/setup/page.tsx new file mode 100644 index 0000000..835b482 --- /dev/null +++ b/web/app/setup/page.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { auth } from "@/lib/api"; +import { Button, Card } from "@/components/ui"; + +const MIN_PASSWORD_LENGTH = 8; + +/** + * Org hosts are `.vantage.` and the apex is `vantage.` (see + * auth.hostSlug on the server). Build the new org's URL by prepending — or + * replacing — the leftmost label. Hosts that don't match that shape (localhost, + * bare IPs) have no per-org subdomain, so stay put. + * + * Setup runs on the apex, and the session cookie it sets is scoped to that + * exact host by design — org hosts must not share cookies. So the new owner is + * sent to the org host's *login* page to sign in there, which is what puts a + * session cookie on the host their org actually lives on. + */ +function orgLoginUrlForSlug(slug: string): string { + if (typeof window === "undefined") return "/login"; + const { protocol, host } = window.location; + const [hostname, port] = host.split(":"); + const parts = hostname.split("."); + + if (parts.length < 2 || parts[parts.length - 1] === "localhost") return "/login"; + + const rest = parts[0] === "vantage" ? parts : parts.slice(1); + if (rest[0] !== "vantage") return "/login"; + + const newHost = [slug, ...rest].join(".") + (port ? `:${port}` : ""); + return `${protocol}//${newHost}/login`; +} + +export default function SetupPage() { + const [orgName, setOrgName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [validationError, setValidationError] = useState(null); + const [created, setCreated] = useState<{ slug: string; loginUrl: string } | null>(null); + + // Setup is a one-shot route; once an owner exists it must not be reachable. + useEffect(() => { + auth + .bootstrapStatus() + .then((s) => { + if (!s.needs_setup) window.location.href = "/login"; + }) + .catch(() => { + // Status unavailable — let the form stand; the backend re-checks on submit. + }); + }, []); + + const { mutate: bootstrap, isPending, error } = useMutation({ + mutationFn: () => auth.bootstrap({ org_name: orgName, email, password }), + onSuccess: (res) => { + setCreated({ slug: res.slug, loginUrl: orgLoginUrlForSlug(res.slug) }); + }, + }); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (password.length < MIN_PASSWORD_LENGTH) { + setValidationError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`); + return; + } + if (password !== confirm) { + setValidationError("Passwords do not match."); + return; + } + setValidationError(null); + bootstrap(); + } + + // Prefer the backend's message (it owns the real validation rules). + const message = validationError ?? (error ? (error as Error).message : null); + + const inputClass = + "w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"; + + if (created) { + return ( +
+
+
+

Organization created

+

+ Your owner account is ready. One more step to finish signing in. +

+
+ + +

+ {created.slug} has its own address, and sign-in is kept separate per organization. Continue + to your organization's sign-in page and log in with the email and password you just + chose. +

+ + {created.loginUrl} + + + + +
+
+
+ ); + } + + return ( +
+
+
+

Welcome to Vantage

+

+ Create your organization and its owner account to get started. +

+
+ + +
+
+ + setOrgName(e.target.value)} + className={inputClass} + /> +

+ Used to derive your organization's subdomain. +

+
+ +
+ + setEmail(e.target.value)} + className={inputClass} + /> +
+ +
+ + setPassword(e.target.value)} + className={inputClass} + /> +

+ At least {MIN_PASSWORD_LENGTH} characters. +

+
+ +
+ + setConfirm(e.target.value)} + className={inputClass} + /> +
+ + {message && ( +
+ {message} +
+ )} + + +
+
+
+
+ ); +} diff --git a/web/components/AuthProvider.tsx b/web/components/AuthProvider.tsx index 0242ea5..0edfb9b 100644 --- a/web/components/AuthProvider.tsx +++ b/web/components/AuthProvider.tsx @@ -1,49 +1,68 @@ "use client"; import { createContext, useContext, useEffect, useState, ReactNode } from "react"; +import { auth, type Org, type Role, type SessionUser } from "@/lib/api"; -export interface User { - user_id: string; - email: string; - name: string; -} +export type { Org, Role, SessionUser }; interface AuthContextType { - user: User | null; - authEnabled: boolean; + user: SessionUser | null; + org: Org | null; + /** True for owner and admin — the roles the /api/settings and /api/org routes require. */ + isAdmin: boolean; } -const AuthContext = createContext({ user: null, authEnabled: false }); +const AuthContext = createContext({ user: null, org: null, isAdmin: false }); export function useAuth() { return useContext(AuthContext); } +/** + * Wraps the authenticated app shell only (see app/(app)/layout.tsx). /login and + * /setup live outside the group, so no pathname guard is needed here. + */ export function AuthProvider({ children }: { children: ReactNode }) { - const [user, setUser] = useState(null); - const [authEnabled, setAuthEnabled] = useState(false); + const [user, setUser] = useState(null); + const [org, setOrg] = useState(null); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); useEffect(() => { - fetch("/auth/me", { credentials: "include" }) - .then(async (res) => { - if (res.status === 401) { - window.location.href = "/auth/login"; + let cancelled = false; + + (async () => { + try { + const status = await auth.bootstrapStatus(); + if (status.needs_setup) { + window.location.href = "/setup"; return; } - const data = await res.json(); - if (data.auth_enabled === false) { - setAuthEnabled(false); - } else { - setAuthEnabled(true); - setUser(data as User); + + const me = await auth.me(); + if (cancelled) return; + setUser(me.user); + setOrg(me.org); + setLoading(false); + } catch (err) { + if (cancelled) return; + const status = (err as { status?: number }).status; + if (status === 401) { + window.location.href = "/login"; + return; } + // Anything else (backend unreachable, org host mismatch) leaves us with + // no session. Rendering children here would mount the whole shell with + // user=null — every page would fire its own doomed API calls and the UI + // would read as a member view. Show the failure instead. + setError((err as Error).message || "Unable to load your session."); setLoading(false); - }) - .catch(() => { - // Backend unreachable — don't block the UI - setLoading(false); - }); + } + })(); + + return () => { + cancelled = true; + }; }, []); if (loading) { @@ -54,9 +73,36 @@ export function AuthProvider({ children }: { children: ReactNode }) { ); } + if (error || !user) { + return ( +
+
+

Can't load your session

+

+ {error ?? "Unable to load your session."} +

+
+ + + Sign in + +
+
+
+ ); + } + + const isAdmin = user.role === "owner" || user.role === "admin"; + return ( - - {children} - + {children} ); } diff --git a/web/components/Sidebar.tsx b/web/components/Sidebar.tsx index 60d7520..2e0aa97 100644 --- a/web/components/Sidebar.tsx +++ b/web/components/Sidebar.tsx @@ -4,11 +4,14 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { clsx } from "clsx"; import { useAuth } from "@/components/AuthProvider"; +import { auth } from "@/lib/api"; interface NavItem { href: string; label: string; icon: React.ReactNode; + /** Restricted to owner/admin — the roles the backing API requires. */ + adminOnly?: boolean; } function ServerIcon() { @@ -76,6 +79,14 @@ function StepsIcon() { ); } +function OrgIcon() { + return ( + + + + ); +} + const navItems: NavItem[] = [ { href: "/servers", label: "Servers", icon: }, { href: "/monitors", label: "Monitors", icon: }, @@ -84,12 +95,32 @@ const navItems: NavItem[] = [ { href: "/workflows", label: "Workflows", icon: }, { href: "/steps", label: "Steps", icon: }, { href: "/audit", label: "Audit Log", icon: }, - { href: "/settings", label: "Settings", icon: }, + { href: "/settings/org", label: "Organization", icon: , adminOnly: true }, + { href: "/settings", label: "Settings", icon: , adminOnly: true }, ]; export function Sidebar() { const pathname = usePathname(); - const { user, authEnabled } = useAuth(); + const { user, org, isAdmin } = useAuth(); + + const visibleItems = navItems.filter((item) => !item.adminOnly || isAdmin); + + // Longest match wins, so /settings/org doesn't also light up /settings. + const activeHref = visibleItems.reduce((best, item) => { + const matches = pathname === item.href || pathname.startsWith(item.href + "/"); + if (!matches) return best; + return best === null || item.href.length > best.length ? item.href : best; + }, null); + + async function handleLogout() { + // /auth/logout is POST-only on the server. + try { + await auth.logout(); + } catch { + // Fall through — clearing the client-side session view is what matters. + } + window.location.href = "/login"; + } return (
- Vantage +
+ Vantage + {org && {org.name}} +