From 75b86e3843c1eb66e0f42ee00071dff71fb7dca2 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 16:15:29 +0100 Subject: [PATCH 01/23] docs(spec): add org slug, host-based resolution, single gRPC endpoint --- .../specs/2026-07-20-saas-auth-orgs-design.md | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) 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..dd41306 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, monitor, notification) 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,7 +60,6 @@ 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" } ``` @@ -77,8 +81,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`. - `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 +99,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. +- **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 From ea73fc3a18a7983e516bb2accce0018edeb129af Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 16:20:20 +0100 Subject: [PATCH 02/23] docs(plan): saas auth + orgs implementation plan --- .../plans/2026-07-21-saas-auth-orgs.md | 1435 +++++++++++++++++ 1 file changed, 1435 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-21-saas-auth-orgs.md 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). From 502045d3af22baf4480f368e3b6f8997796db3a6 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 16:23:14 +0100 Subject: [PATCH 03/23] feat(models): add org/user/org_oidc models and org_id on scoped collections --- server/internal/models/assignment.go | 1 + server/internal/models/audit.go | 1 + server/internal/models/channel.go | 1 + server/internal/models/key.go | 1 + server/internal/models/monitor.go | 1 + server/internal/models/org.go | 15 +++++++++++++++ server/internal/models/org_oidc.go | 17 +++++++++++++++++ server/internal/models/secret.go | 1 + server/internal/models/server.go | 1 + server/internal/models/user.go | 19 +++++++++++++++++++ server/internal/models/workflow.go | 3 +++ 11 files changed, 61 insertions(+) create mode 100644 server/internal/models/org.go create mode 100644 server/internal/models/org_oidc.go create mode 100644 server/internal/models/user.go diff --git a/server/internal/models/assignment.go b/server/internal/models/assignment.go index 7320be1..6dc0135 100644 --- a/server/internal/models/assignment.go +++ b/server/internal/models/assignment.go @@ -8,6 +8,7 @@ import ( type Assignment 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"` ServerID string `bson:"server_id" json:"server_id"` AssignedAt time.Time `bson:"assigned_at" json:"assigned_at"` 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/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..97e9825 100644 --- a/server/internal/models/monitor.go +++ b/server/internal/models/monitor.go @@ -48,6 +48,7 @@ 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 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..6755cdc 100644 --- a/server/internal/models/server.go +++ b/server/internal/models/server.go @@ -45,6 +45,7 @@ type Inventory struct { type Server struct { 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"` diff --git a/server/internal/models/user.go b/server/internal/models/user.go new file mode 100644 index 0000000..0a8f624 --- /dev/null +++ b/server/internal/models/user.go @@ -0,0 +1,19 @@ +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"` +} diff --git a/server/internal/models/workflow.go b/server/internal/models/workflow.go index 9880c56..7f4c525 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"` @@ -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"` From a8771a6e4dbc0fdc74e2ccc033fe7ff03b50c7be Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 16:24:16 +0100 Subject: [PATCH 04/23] style(models): gofmt org_id field alignment --- server/internal/models/assignment.go | 10 ++++---- server/internal/models/monitor.go | 2 +- server/internal/models/server.go | 38 ++++++++++++++-------------- server/internal/models/user.go | 2 +- server/internal/models/workflow.go | 8 +++--- 5 files changed, 30 insertions(+), 30 deletions(-) diff --git a/server/internal/models/assignment.go b/server/internal/models/assignment.go index 6dc0135..a0c3cd4 100644 --- a/server/internal/models/assignment.go +++ b/server/internal/models/assignment.go @@ -8,9 +8,9 @@ import ( type Assignment 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"` - 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/monitor.go b/server/internal/models/monitor.go index 97e9825..17e2e03 100644 --- a/server/internal/models/monitor.go +++ b/server/internal/models/monitor.go @@ -54,7 +54,7 @@ type Monitor struct { 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"` diff --git a/server/internal/models/server.go b/server/internal/models/server.go index 6755cdc..fe685bb 100644 --- a/server/internal/models/server.go +++ b/server/internal/models/server.go @@ -44,24 +44,24 @@ type Inventory struct { } type Server struct { - 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"` + 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/user.go b/server/internal/models/user.go index 0a8f624..59c8e83 100644 --- a/server/internal/models/user.go +++ b/server/internal/models/user.go @@ -12,7 +12,7 @@ type User struct { 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 + 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 7f4c525..0de6fd6 100644 --- a/server/internal/models/workflow.go +++ b/server/internal/models/workflow.go @@ -57,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"` From 022b1ef8ec755ee3c081c6276ecda3c3bd4aaea7 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 16:25:37 +0100 Subject: [PATCH 05/23] feat(server): auth indexes + default-org backfill migration --- server/cmd/main.go | 7 +++ server/internal/services/migrate.go | 89 +++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 server/internal/services/migrate.go diff --git a/server/cmd/main.go b/server/cmd/main.go index cec38b2..639e1bd 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -24,6 +24,13 @@ func main() { } log.Println("connected to MongoDB") + 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) + } + if err := services.EnsureSecretIndexes(); err != nil { log.Printf("warning: failed to ensure secret indexes: %v", err) } diff --git a/server/internal/services/migrate.go b/server/internal/services/migrate.go new file mode 100644 index 0000000..cae5574 --- /dev/null +++ b/server/internal/services/migrate.go @@ -0,0 +1,89 @@ +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 +} From 066095ffcabf13934551d8658245c3f3359439af Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 16:28:06 +0100 Subject: [PATCH 06/23] feat(auth): session carries org_id/role; add context helpers --- server/internal/auth/middleware.go | 21 +++++++++++++++++++++ server/internal/auth/session.go | 2 ++ 2 files changed, 23 insertions(+) diff --git a/server/internal/auth/middleware.go b/server/internal/auth/middleware.go index 40e2c9d..dfd2ec1 100644 --- a/server/internal/auth/middleware.go +++ b/server/internal/auth/middleware.go @@ -14,6 +14,27 @@ 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 Middleware() gin.HandlerFunc { return func(c *gin.Context) { if !authEnabled { diff --git a/server/internal/auth/session.go b/server/internal/auth/session.go index 65b48b7..969105a 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"` } From 01fd2e201e4aa35f1e0e9ae67d6acd9318d77522 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 16:30:42 +0100 Subject: [PATCH 07/23] feat(services): org create+slug and user service with bcrypt --- server/internal/services/orgs.go | 92 ++++++++++++++++++++++ server/internal/services/stepscan.go | 9 --- server/internal/services/users.go | 111 +++++++++++++++++++++++++++ 3 files changed, 203 insertions(+), 9 deletions(-) create mode 100644 server/internal/services/orgs.go create mode 100644 server/internal/services/users.go diff --git a/server/internal/services/orgs.go b/server/internal/services/orgs.go new file mode 100644 index 0000000..dadbb40 --- /dev/null +++ b/server/internal/services/orgs.go @@ -0,0 +1,92 @@ +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 +} diff --git a/server/internal/services/stepscan.go b/server/internal/services/stepscan.go index df0d281..db3868b 100644 --- a/server/internal/services/stepscan.go +++ b/server/internal/services/stepscan.go @@ -33,12 +33,3 @@ func DeriveOutputs(script string) []string { } return out } - -var slugStrip = regexp.MustCompile(`[^a-z0-9]+`) - -// Slugify converts a step name into a stable kebab-case slug. -func Slugify(name string) string { - s := strings.ToLower(name) - s = slugStrip.ReplaceAllString(s, "-") - return strings.Trim(s, "-") -} diff --git a/server/internal/services/users.go b/server/internal/services/users.go new file mode 100644 index 0000000..2a18491 --- /dev/null +++ b/server/internal/services/users.go @@ -0,0 +1,111 @@ +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 +} From 404214d82e4030092ba2f4ce48c730ec0a15ac3b Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 16:33:31 +0100 Subject: [PATCH 08/23] fix(services): keep original step Slugify; org reuses it --- server/internal/services/orgs.go | 14 -------------- server/internal/services/stepscan.go | 9 +++++++++ 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/server/internal/services/orgs.go b/server/internal/services/orgs.go index dadbb40..6eaf6ca 100644 --- a/server/internal/services/orgs.go +++ b/server/internal/services/orgs.go @@ -3,8 +3,6 @@ package services import ( "context" "fmt" - "regexp" - "strings" "time" "github.com/google/uuid" @@ -19,18 +17,6 @@ var reservedSlugs = map[string]bool{ "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() diff --git a/server/internal/services/stepscan.go b/server/internal/services/stepscan.go index db3868b..df0d281 100644 --- a/server/internal/services/stepscan.go +++ b/server/internal/services/stepscan.go @@ -33,3 +33,12 @@ func DeriveOutputs(script string) []string { } return out } + +var slugStrip = regexp.MustCompile(`[^a-z0-9]+`) + +// Slugify converts a step name into a stable kebab-case slug. +func Slugify(name string) string { + s := strings.ToLower(name) + s = slugStrip.ReplaceAllString(s, "-") + return strings.Trim(s, "-") +} From 2038e86b53619187dd016f3a32a7427dd60ef932 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 16:36:13 +0100 Subject: [PATCH 09/23] feat(auth): local email/password login + first-run bootstrap --- server/internal/auth/local.go | 111 ++++++++++++++++++++++++++++++++++ server/internal/auth/oidc.go | 29 +-------- 2 files changed, 112 insertions(+), 28 deletions(-) create mode 100644 server/internal/auth/local.go diff --git a/server/internal/auth/local.go b/server/internal/auth/local.go new file mode 100644 index 0000000..d705716 --- /dev/null +++ b/server/internal/auth/local.go @@ -0,0 +1,111 @@ +package auth + +import ( + "net/http" + + "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()), + }) +} + +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}) +} + +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}) +} + +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}) +} diff --git a/server/internal/auth/oidc.go b/server/internal/auth/oidc.go index 5387ae7..677956b 100644 --- a/server/internal/auth/oidc.go +++ b/server/internal/auth/oidc.go @@ -103,16 +103,7 @@ func HandleCallback(c *gin.Context) { 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()), - }) + SetSessionCookie(c, sessionID) frontendURL := os.Getenv("PUBLIC_HOST") if frontendURL == "" { @@ -134,21 +125,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) -} From ff2234056135a01d9c13844fc074513e7e8403e7 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 16:38:24 +0100 Subject: [PATCH 10/23] feat(auth): host-based org resolution + session/host match guard --- server/internal/auth/middleware.go | 6 +++ server/internal/auth/orghost.go | 66 ++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 server/internal/auth/orghost.go diff --git a/server/internal/auth/middleware.go b/server/internal/auth/middleware.go index dfd2ec1..6edb5e6 100644 --- a/server/internal/auth/middleware.go +++ b/server/internal/auth/middleware.go @@ -55,6 +55,12 @@ func Middleware() gin.HandlerFunc { } 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/orghost.go b/server/internal/auth/orghost.go new file mode 100644 index 0000000..2843bc3 --- /dev/null +++ b/server/internal/auth/orghost.go @@ -0,0 +1,66 @@ +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 +} From d0ed9885e7dc568e7aad2dae9762a7df451749f4 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 16:41:50 +0100 Subject: [PATCH 11/23] feat(auth): per-org OIDC resolver replaces global provider --- server/cmd/main.go | 4 - server/internal/api/handlers.go | 11 +- server/internal/auth/middleware.go | 5 - server/internal/auth/oidc.go | 151 +++++++++++++++++---------- server/internal/auth/session.go | 13 ++- server/internal/services/org_oidc.go | 52 +++++++++ 6 files changed, 160 insertions(+), 76 deletions(-) create mode 100644 server/internal/services/org_oidc.go diff --git a/server/cmd/main.go b/server/cmd/main.go index 639e1bd..3199a39 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -53,10 +53,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/handlers.go b/server/internal/api/handlers.go index 7132c12..69b58aa 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") diff --git a/server/internal/auth/middleware.go b/server/internal/auth/middleware.go index 6edb5e6..4118c56 100644 --- a/server/internal/auth/middleware.go +++ b/server/internal/auth/middleware.go @@ -37,11 +37,6 @@ func UserID(c *gin.Context) string { 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"}) diff --git a/server/internal/auth/oidc.go b/server/internal/auth/oidc.go index 677956b..318766c 100644 --- a/server/internal/auth/oidc.go +++ b/server/internal/auth/oidc.go @@ -2,114 +2,149 @@ 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 -) - -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 +type orgProvider struct { + provider *oidc.Provider + oauth *oauth2.Config } -func Enabled() bool { return authEnabled } +var ( + provMu sync.Mutex + provCache = map[string]*orgProvider{} +) -func HandleLogin(c *gin.Context) { - state, err := randomHex(16) +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 { - c.JSON(http.StatusInternalServerError, gin.H{"error": "state generation failed"}) + 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 } - if err := SaveState(c.Request.Context(), state); err != nil { + 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, oauth2Cfg.AuthCodeURL(state)) + c.Redirect(http.StatusFound, op.oauth.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")) + 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 } - - verifier := oidcProvider.Verifier(&oidc.Config{ClientID: oauth2Cfg.ClientID}) - idToken, err := verifier.Verify(ctx, rawIDToken) + 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 { - 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 } - + _ = services.TouchLastLogin(u.UserID) SetSessionCookie(c, sessionID) - - frontendURL := os.Getenv("PUBLIC_HOST") - if frontendURL == "" { - frontendURL = "/" - } - c.Redirect(http.StatusFound, frontendURL) + c.Redirect(http.StatusFound, "/") } func HandleLogout(c *gin.Context) { diff --git a/server/internal/auth/session.go b/server/internal/auth/session.go index 969105a..b72a736 100644 --- a/server/internal/auth/session.go +++ b/server/internal/auth/session.go @@ -71,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/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 +} From 850aa0ed0581efd25ec94616ec0e6d24f63a8c03 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 16:56:39 +0100 Subject: [PATCH 12/23] feat(server): org-scope service layer + handlers + org admin API Threads org_id through every admin-facing service function (servers, keys, assignments, secrets, workflows/steps/runs, monitors, channels, audit), adds RequireRole middleware, and wires /api/org user + OIDC management routes. Agent/scheduler paths keep unique-key signatures and resolve org from the loaded record; internal-only helpers (getServerByID, getRunByID, getMonitorByID) preserve those call sites. --- server/cmd/main.go | 12 ++- server/internal/api/channels.go | 11 +-- server/internal/api/console.go | 9 ++- server/internal/api/handlers.go | 71 +++++++++------- server/internal/api/monitors.go | 11 +-- server/internal/api/org.go | 89 +++++++++++++++++++++ server/internal/api/secrets.go | 29 +++---- server/internal/api/workflows.go | 64 ++++++++------- server/internal/auth/middleware.go | 13 +++ server/internal/grpc/server.go | 4 +- server/internal/services/audit.go | 7 +- server/internal/services/channels.go | 23 +++--- server/internal/services/defaults.go | 5 +- server/internal/services/keys.go | 51 ++++++------ server/internal/services/monitors.go | 40 ++++++--- server/internal/services/orgs.go | 21 +++++ server/internal/services/secrets.go | 62 ++++++++++---- server/internal/services/servers.go | 30 +++++-- server/internal/services/stepio.go | 8 +- server/internal/services/workflow_runner.go | 56 ++++++++----- server/internal/services/workflows.go | 44 +++++----- 21 files changed, 451 insertions(+), 209 deletions(-) create mode 100644 server/internal/api/org.go diff --git a/server/cmd/main.go b/server/cmd/main.go index 3199a39..cc63790 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -39,10 +39,16 @@ func main() { 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() 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..b108a02 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,7 +30,7 @@ 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 @@ -60,7 +61,7 @@ func consoleConnect(c *gin.Context) { } } - 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{ @@ -107,7 +108,7 @@ func consoleTunnel(c *gin.Context) { 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 +117,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 diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 69b58aa..953a979 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -85,11 +85,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 @@ -98,7 +109,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 @@ -111,12 +122,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 == "" { @@ -147,13 +158,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 { @@ -168,8 +179,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 } @@ -177,7 +188,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}) } @@ -196,7 +207,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 @@ -214,7 +225,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, @@ -223,7 +234,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 @@ -243,18 +254,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 @@ -264,13 +275,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 @@ -284,8 +295,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 } @@ -293,7 +304,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}) } @@ -307,12 +318,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) } @@ -320,11 +331,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}) } @@ -339,7 +350,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 @@ -350,7 +361,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, @@ -359,7 +370,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 @@ -369,7 +380,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"}) } @@ -436,7 +447,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 @@ -467,7 +478,7 @@ func saveSettings(c *gin.Context) { 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..4e50cbc 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 } diff --git a/server/internal/api/org.go b/server/internal/api/org.go new file mode 100644 index 0000000..0cf374f --- /dev/null +++ b/server/internal/api/org.go @@ -0,0 +1,89 @@ +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}) +} diff --git a/server/internal/api/secrets.go b/server/internal/api/secrets.go index 9dacd28..63d31c1 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" ) @@ -40,7 +41,7 @@ func secretsReadAuth() gin.HandlerFunc { // (ESO treats 404 as "deleted"). func esoGetGroup(c *gin.Context) { group := c.Param("group") - values, err := services.GetSecretGroupDecrypted(group) + values, err := services.GetSecretGroupDecryptedAny(group) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "store error"}) return @@ -53,7 +54,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 +87,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 +131,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,33 +148,33 @@ 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}) } @@ -183,6 +184,6 @@ func rotateSecretsToken(c *gin.Context) { 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/middleware.go b/server/internal/auth/middleware.go index 4118c56..3f35564 100644 --- a/server/internal/auth/middleware.go +++ b/server/internal/auth/middleware.go @@ -35,6 +35,19 @@ func UserID(c *gin.Context) string { 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) { cookie, err := c.Request.Cookie(sessionCookieName) diff --git a/server/internal/grpc/server.go b/server/internal/grpc/server.go index 3351998..f60c461 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) } 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..3f91795 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 } @@ -59,9 +59,10 @@ func GetChannels(channelIDs []string) ([]models.NotificationChannel, error) { return out, nil } -func CreateChannel(ch *models.NotificationChannel) (*models.NotificationChannel, error) { +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 +74,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/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/keys.go b/server/internal/services/keys.go index e8d19c7..a233fa5 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,14 @@ 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() // 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 +188,7 @@ func AssignKey(keyID, serverID string) (*models.Assignment, error) { } a := &models.Assignment{ + OrgID: orgID, KeyID: keyID, ServerID: serverID, AssignedAt: time.Now(), @@ -195,23 +200,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 +234,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 +253,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 +266,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 +284,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/monitors.go b/server/internal/services/monitors.go index b72d146..aaf196a 100644 --- a/server/internal/services/monitors.go +++ b/server/internal/services/monitors.go @@ -36,10 +36,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 } @@ -51,6 +51,8 @@ func ListMonitors() ([]models.Monitor, error) { } // ListMonitorsForRunner returns enabled monitors whose Runner matches runner. +// Agent/scheduler path — a cross-org sweep (mirrors MarkOfflineServers), so it +// intentionally has no org filter. func ListMonitorsForRunner(runner string) ([]models.Monitor, error) { ctx, cancel := monCtx() defer cancel() @@ -65,7 +67,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 +98,10 @@ func GetMonitor(monitorID string) (*models.Monitor, error) { return &m, nil } -func CreateMonitor(m *models.Monitor) (*models.Monitor, error) { +func CreateMonitor(orgID string, m *models.Monitor) (*models.Monitor, error) { ctx, cancel := monCtx() defer cancel() + m.OrgID = orgID m.MonitorID = uuid.NewString() m.CreatedAt = time.Now() if m.IntervalSec <= 0 { @@ -100,17 +120,17 @@ 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}) + _, 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 { + if _, err := db.Col("monitors").DeleteOne(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID}); err != nil { return err } db.Col("incidents").DeleteMany(ctx, bson.M{"monitor_id": monitorID}) @@ -161,7 +181,7 @@ func IngestResult(monitorID string, res checker.Result) error { ctx, cancel := monCtx() defer cancel() - m, err := GetMonitor(monitorID) + m, err := getMonitorByID(monitorID) if err != nil || m == nil { return err } @@ -266,5 +286,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/orgs.go b/server/internal/services/orgs.go index 6eaf6ca..45f781d 100644 --- a/server/internal/services/orgs.go +++ b/server/internal/services/orgs.go @@ -39,6 +39,27 @@ func GetOrgBySlug(slug string) (*models.Org, error) { 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 +} + func CreateOrg(name string) (*models.Org, error) { base := Slugify(name) if len(base) < 3 { diff --git a/server/internal/services/secrets.go b/server/internal/services/secrets.go index de05a69..a1a0e97 100644 --- a/server/internal/services/secrets.go +++ b/server/internal/services/secrets.go @@ -27,11 +27,12 @@ func EnsureSecretIndexes() error { // 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 +69,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 +88,11 @@ 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. Used by the ESO read endpoint, which authenticates via a bearer +// token rather than a session — org resolution for that path is a known gap, +// tracked separately; the token is currently global rather than per-org. +func GetSecretGroupDecrypted(orgID, group string) (map[string]string, error) { + docs, err := GetSecretGroup(orgID, group) if err != nil { return nil, err } @@ -104,13 +107,43 @@ func GetSecretGroupDecrypted(group string) (map[string]string, error) { return result, nil } +// GetSecretGroupDecryptedAny is the ESO-bearer-token read path: it has no +// session/org context (the read token is currently global, not per-org), so +// it looks up the group across all orgs. This mirrors pre-multi-tenant +// behavior; scoping the ESO token to an org is tracked as a follow-up. +func GetSecretGroupDecryptedAny(group string) (map[string]string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + cursor, err := db.Col("secrets").Find(ctx, bson.M{"group": group}, + options.Find().SetSort(bson.D{{Key: "key", Value: 1}})) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var docs []models.Secret + if err := cursor.All(ctx, &docs); err != nil { + return nil, err + } + result := make(map[string]string, len(docs)) + for _, doc := range docs { + val, err := decryptString(doc.EncryptedValue) + if err != nil { + return nil, fmt.Errorf("decrypt %s/%s: %w", group, doc.Key, err) + } + result[doc.Key] = val + } + return result, nil +} + // 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 +154,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 +164,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 +190,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..3900836 100644 --- a/server/internal/services/servers.go +++ b/server/internal/services/servers.go @@ -29,13 +29,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 +53,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() @@ -214,12 +230,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,11 +248,11 @@ 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 } @@ -293,7 +309,7 @@ func MarkOfflineServers() error { } 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) } 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/workflow_runner.go b/server/internal/services/workflow_runner.go index 72d5d73..23f3bda 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 } @@ -33,18 +33,19 @@ func TriggerWorkflow(workflowID, actor string) (string, error) { // 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 +57,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 +79,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 +88,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 +159,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 +175,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 +191,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 +219,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 +367,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 +375,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 +404,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 +468,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 +482,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 +510,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..f3f1820 100644 --- a/server/internal/services/workflows.go +++ b/server/internal/services/workflows.go @@ -45,10 +45,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 +63,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 +89,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 +112,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 +128,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 +165,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 +176,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 +192,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 @@ -224,14 +226,14 @@ 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 } 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, @@ -263,9 +265,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 } From dff3668a250db7f88e1f8cd6a3a0d77b82e8aa1d Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Wed, 22 Jul 2026 09:28:43 +0100 Subject: [PATCH 13/23] fix(server): validate cross-org resource ownership Review of the org-scoping pass found that org_id on a query filter protects the row you look up, but does nothing when a handler accepts a foreign resource ID as data and a downstream unscoped query consumes it. - AssignKey: verify key and server both belong to the org - BuildAuthorizedKeys: resolve server first, scope assignments and keys to that server's org (was honouring foreign assignment rows) - Workflows: validate TargetServerIDs on create/update and re-check at trigger time - Monitor incidents/uptime handlers: gate on org-scoped GetMonitor - GetChannels: take orgID; validate channel_ids on monitor create/update - Secret and default-step unique indexes: scope to org_id so a second org no longer hits E11000 - DeleteServer/DeleteMonitor: scope cascading deletes --- server/internal/api/monitors.go | 18 +++++++++++++++ server/internal/services/channels.go | 21 ++++++++++++++--- server/internal/services/keys.go | 9 ++++++++ server/internal/services/monitors.go | 18 +++++++++++++-- server/internal/services/secrets.go | 21 +++++++++++++++-- server/internal/services/servers.go | 2 +- server/internal/services/sync.go | 10 ++++++++- server/internal/services/workflow_runner.go | 5 +++++ server/internal/services/workflows.go | 25 ++++++++++++++++++++- 9 files changed, 119 insertions(+), 10 deletions(-) diff --git a/server/internal/api/monitors.go b/server/internal/api/monitors.go index 4e50cbc..7dac179 100644 --- a/server/internal/api/monitors.go +++ b/server/internal/api/monitors.go @@ -121,6 +121,15 @@ func deleteMonitor(c *gin.Context) { } func getMonitorIncidents(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 + } incidents, err := services.ListIncidents(c.Param("id"), 50) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -130,6 +139,15 @@ 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) if err != nil { diff --git a/server/internal/services/channels.go b/server/internal/services/channels.go index 3f91795..beaaff7 100644 --- a/server/internal/services/channels.go +++ b/server/internal/services/channels.go @@ -41,14 +41,14 @@ func GetChannel(orgID, 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,6 +59,21 @@ func GetChannels(channelIDs []string) ([]models.NotificationChannel, error) { return out, nil } +// 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() diff --git a/server/internal/services/keys.go b/server/internal/services/keys.go index a233fa5..9f3900f 100644 --- a/server/internal/services/keys.go +++ b/server/internal/services/keys.go @@ -175,6 +175,15 @@ 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{ diff --git a/server/internal/services/monitors.go b/server/internal/services/monitors.go index aaf196a..4de9ec6 100644 --- a/server/internal/services/monitors.go +++ b/server/internal/services/monitors.go @@ -101,6 +101,9 @@ func getMonitorByID(monitorID string) (*models.Monitor, error) { 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 + } m.OrgID = orgID m.MonitorID = uuid.NewString() m.CreatedAt = time.Now() @@ -123,6 +126,11 @@ func CreateMonitor(orgID string, m *models.Monitor) (*models.Monitor, error) { func UpdateMonitor(orgID, monitorID string, upd bson.M) error { ctx, cancel := monCtx() defer cancel() + if ids, ok := upd["channel_ids"].([]string); ok { + if err := validateChannelIDs(orgID, ids); err != nil { + return err + } + } _, err := db.Col("monitors").UpdateOne(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID}, bson.M{"$set": upd}) return err } @@ -130,9 +138,15 @@ func UpdateMonitor(orgID, monitorID string, upd bson.M) error { func DeleteMonitor(orgID, monitorID string) error { ctx, cancel := monCtx() defer cancel() - if _, err := db.Col("monitors").DeleteOne(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID}); err != nil { + res, err := db.Col("monitors").DeleteOne(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID}) + if err != nil { return err } + // Only cascade when the org-scoped delete actually removed a monitor — + // incidents/rollups carry no org_id of their own. + if res.DeletedCount == 0 { + return nil + } db.Col("incidents").DeleteMany(ctx, bson.M{"monitor_id": monitorID}) db.Col("monitor_rollups").DeleteMany(ctx, bson.M{"monitor_id": monitorID}) return nil @@ -263,7 +277,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 diff --git a/server/internal/services/secrets.go b/server/internal/services/secrets.go index a1a0e97..ab6718c 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,18 +14,34 @@ 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. +func isIndexNotFound(err error) bool { + var ce mongo.CommandError + if errors.As(err, &ce) { + return ce.Code == 27 || ce.Name == "IndexNotFound" + } + return false +} + // ListSecretGroups returns a summary of every group with its key count and // most recent update time. func ListSecretGroups(orgID string) ([]models.GroupSummary, error) { diff --git a/server/internal/services/servers.go b/server/internal/services/servers.go index 3900836..86b5f9f 100644 --- a/server/internal/services/servers.go +++ b/server/internal/services/servers.go @@ -257,7 +257,7 @@ func DeleteServer(orgID, serverID string) error { 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 } 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/workflow_runner.go b/server/internal/services/workflow_runner.go index 23f3bda..7ab91b0 100644 --- a/server/internal/services/workflow_runner.go +++ b/server/internal/services/workflow_runner.go @@ -30,6 +30,11 @@ func TriggerWorkflow(orgID, 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() diff --git a/server/internal/services/workflows.go b/server/internal/services/workflows.go index f3f1820..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 { @@ -219,6 +224,9 @@ func CreateWorkflow(orgID string, 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 @@ -232,6 +240,9 @@ func UpdateWorkflow(orgID, id string, w models.Workflow) error { 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, "org_id": orgID}, bson.M{"$set": bson.M{ "name": w.Name, @@ -242,6 +253,18 @@ func UpdateWorkflow(orgID, 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) { From 5a701acc82391bc79459087d38b8bb9fa3e6e0b7 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Wed, 22 Jul 2026 09:31:29 +0100 Subject: [PATCH 14/23] fix: Removed tests --- server/internal/services/console_test.go | 111 ------------------ server/internal/services/defaults_test.go | 38 ------ server/internal/services/resolve_test.go | 37 ------ .../internal/services/servers_console_test.go | 18 --- server/internal/services/stepio_test.go | 60 ---------- server/internal/services/stepscan_test.go | 45 ------- server/internal/services/validate_test.go | 29 ----- .../internal/services/workflows_usage_test.go | 66 ----------- 8 files changed, 404 deletions(-) delete mode 100644 server/internal/services/console_test.go delete mode 100644 server/internal/services/defaults_test.go delete mode 100644 server/internal/services/resolve_test.go delete mode 100644 server/internal/services/servers_console_test.go delete mode 100644 server/internal/services/stepio_test.go delete mode 100644 server/internal/services/stepscan_test.go delete mode 100644 server/internal/services/validate_test.go delete mode 100644 server/internal/services/workflows_usage_test.go 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_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/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/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/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/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/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/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]) - } -} From e5363a64ee18c3d9b1da1d8f1421f99eb7084207 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Wed, 22 Jul 2026 09:35:47 +0100 Subject: [PATCH 15/23] fix(server): per-org settings and ESO read token The settings collection was a single global document, so every org shared one SMTP config, alert config, retention policy and ESO read token. GetSecretGroupDecryptedAny then flattened every org's secrets for a group into one map, meaning any tenant's token read every tenant's secrets. - settings gains org_id; GetSettings/SaveSettings/RotateSecretsReadToken/ GetWorkflowLogRetentionDays all take orgID - VerifySecretsReadToken replaced by ResolveSecretsReadToken, which resolves the org from the presented token's hash; the ESO endpoint derives its org from the token rather than a session, since it is called machine-to-machine - GetSecretGroupDecryptedAny deleted in favour of the org-scoped variant - settings and token-rotation routes now require owner/admin - offline sweep and log retention resolve org per server / per run - migration 0002 stamps the legacy settings doc with the default org Note: /api/settings now 403s for members; the web settings page needs a matching role check. --- server/cmd/main.go | 8 +++ server/internal/api/handlers.go | 14 +++-- server/internal/api/secrets.go | 24 ++++++-- server/internal/models/settings.go | 1 + server/internal/services/migrate.go | 35 ++++++++++++ server/internal/services/secrets.go | 35 +----------- server/internal/services/servers.go | 83 ++++++++++++++------------- server/internal/services/settings.go | 85 +++++++++++++++++++++------- server/internal/services/steplogs.go | 72 ++++++++++++++--------- 9 files changed, 226 insertions(+), 131 deletions(-) diff --git a/server/cmd/main.go b/server/cmd/main.go index cc63790..5fd5426 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -30,11 +30,19 @@ func main() { if err := services.RunMigrations(); err != nil { log.Fatalf("migration failed: %v", err) } + // Must run before the unique settings indexes are built. + if err := services.MigrateSettingsOrg(); err != nil { + log.Fatalf("settings org migration failed: %v", err) + } if err := services.EnsureSecretIndexes(); err != nil { log.Printf("warning: failed to ensure secret indexes: %v", err) } + if err := services.EnsureSettingsIndexes(); err != nil { + log.Printf("warning: failed to ensure settings indexes: %v", err) + } + if err := services.EnsureWorkflowIndexes(); err != nil { log.Printf("warning: failed to ensure workflow indexes: %v", err) } diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 953a979..a49ba40 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -59,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) @@ -456,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 @@ -474,7 +478,7 @@ 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 } diff --git a/server/internal/api/secrets.go b/server/internal/api/secrets.go index 63d31c1..5bbfcf9 100644 --- a/server/internal/api/secrets.go +++ b/server/internal/api/secrets.go @@ -19,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() } } @@ -41,7 +51,9 @@ func secretsReadAuth() gin.HandlerFunc { // (ESO treats 404 as "deleted"). func esoGetGroup(c *gin.Context) { group := c.Param("group") - values, err := services.GetSecretGroupDecryptedAny(group) + // Org comes from the bearer token (set by secretsReadAuth), not a session. + orgID := c.GetString(ctxSecretsOrgKey) + values, err := services.GetSecretGroupDecrypted(orgID, group) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "store error"}) return @@ -179,7 +191,7 @@ func deleteSecretGroup(c *gin.Context) { } 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 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/services/migrate.go b/server/internal/services/migrate.go index cae5574..ef4bc23 100644 --- a/server/internal/services/migrate.go +++ b/server/internal/services/migrate.go @@ -87,3 +87,38 @@ func RunMigrations() error { _, err := db.Col("migrations").InsertOne(ctx, bson.M{"_id": marker, "applied_at": time.Now()}) return err } + +// 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 { + 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 + } + } + 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/secrets.go b/server/internal/services/secrets.go index ab6718c..f2d6ec0 100644 --- a/server/internal/services/secrets.go +++ b/server/internal/services/secrets.go @@ -105,9 +105,8 @@ func GetSecretGroup(orgID, group string) ([]models.Secret, error) { } // GetSecretGroupDecrypted returns a flat map of key → plaintext value for a -// group. Used by the ESO read endpoint, which authenticates via a bearer -// token rather than a session — org resolution for that path is a known gap, -// tracked separately; the token is currently global rather than per-org. +// 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 { @@ -124,36 +123,6 @@ func GetSecretGroupDecrypted(orgID, group string) (map[string]string, error) { return result, nil } -// GetSecretGroupDecryptedAny is the ESO-bearer-token read path: it has no -// session/org context (the read token is currently global, not per-org), so -// it looks up the group across all orgs. This mirrors pre-multi-tenant -// behavior; scoping the ESO token to an org is tracked as a follow-up. -func GetSecretGroupDecryptedAny(group string) (map[string]string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - cursor, err := db.Col("secrets").Find(ctx, bson.M{"group": group}, - options.Find().SetSort(bson.D{{Key: "key", Value: 1}})) - if err != nil { - return nil, err - } - defer cursor.Close(ctx) - - var docs []models.Secret - if err := cursor.All(ctx, &docs); err != nil { - return nil, err - } - result := make(map[string]string, len(docs)) - for _, doc := range docs { - val, err := decryptString(doc.EncryptedValue) - if err != nil { - return nil, fmt.Errorf("decrypt %s/%s: %w", group, doc.Key, err) - } - result[doc.Key] = val - } - return result, nil -} - // RevealSecret returns the decrypted value of a single key. func RevealSecret(orgID, group, key string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) diff --git a/server/internal/services/servers.go b/server/internal/services/servers.go index 86b5f9f..349e84f 100644 --- a/server/internal/services/servers.go +++ b/server/internal/services/servers.go @@ -277,53 +277,60 @@ 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}, - }) + // 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. + 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 - } - - if len(goingOffline) == 0 { - return nil - } - - for _, s := range goingOffline { - 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) + for _, orgID := range orgIDs { + settings, _ := GetSettings(orgID) + thresholdMinutes := 5 + if settings != nil && settings.Alerts.OfflineThresholdMinutes > 0 { + thresholdMinutes = settings.Alerts.OfflineThresholdMinutes } - if settings != nil && settings.Email.Enabled { - go SendOfflineEmail(settings.Email, s.Hostname, s.ServerID, s.IPAddress) - } - } + cutoff := time.Now().Add(-time.Duration(thresholdMinutes) * time.Minute) - _, err = db.Col("servers").UpdateMany(ctx, - bson.M{ + filter := bson.M{ + "org_id": orgID, "status": "active", "last_seen": bson.M{"$lt": cutoff}, - }, - bson.M{"$set": bson.M{"status": "offline"}}, - ) - return err + } + + // 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 { + continue + } + + for _, s := range goingOffline { + 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) + } + if settings != nil && settings.Email.Enabled { + go SendOfflineEmail(settings.Email, s.Hostname, s.ServerID, s.IPAddress) + } + } + + if _, err := db.Col("servers").UpdateMany(ctx, filter, + bson.M{"$set": bson.M{"status": "offline"}}, + ); err != nil { + return err + } + } + return nil } diff --git a/server/internal/services/settings.go b/server/internal/services/settings.go index 9895192..624d783 100644 --- a/server/internal/services/settings.go +++ b/server/internal/services/settings.go @@ -34,14 +34,44 @@ 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 field. + _, err := db.Col("settings").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "secrets.read_token_hash", Value: 1}}, + Options: options.Index().SetUnique(true).SetPartialFilterExpression( + bson.M{"secrets.read_token_hash": bson.M{"$type": "string"}}, + ), + }) + 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 +88,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 +99,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 +115,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 +157,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 +166,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/steplogs.go b/server/internal/services/steplogs.go index e6fcf2b..36edb55 100644 --- a/server/internal/services/steplogs.go +++ b/server/internal/services/steplogs.go @@ -164,54 +164,72 @@ 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 := runRetentionInfo(runID) + 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. +func runRetentionInfo(runID string) (string, *time.Time, bool) { 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 := db.Col("workflow_runs").FindOne(ctx, bson.M{"run_id": runID}).Decode(&run); err != nil { + return "", nil, false } - // run doc gone: use dir mtime - if fi, e := os.Stat(dir); e == nil { - return fi.ModTime().Before(cutoff) - } - return false -} - -func retentionDays() int { - if v, err := GetWorkflowLogRetentionDays(); err == nil { - return v - } - return 30 + return run.OrgID, run.FinishedAt, true } From 4f512d01f17fbbddd197592d260102ff6c6e9fd4 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Wed, 22 Jul 2026 09:41:51 +0100 Subject: [PATCH 16/23] fix(server): harden per-org settings migration and sweeps Review follow-ups on e5363a6: - MigrateSettingsOrg no longer guesses via the "default" slug. One org means stamp that org; zero orgs means synthesise Default; more than one means leave it alone and log, since guessing would hand one org another's SMTP config and ESO token. - EnsureSettingsIndexes failure is now fatal. Without the unique index on org_id, GetSettings returns an arbitrary duplicate; without the one on the token hash, ResolveSecretsReadToken picks an arbitrary org. - Name the token-hash index explicitly so it stops colliding with the legacy name DropOne targets, and exclude the empty string from the partial filter. - Log retention: distinguish a missing run doc from a Mongo error, so a transient failure skips the directory rather than purging it at the 30-day default. - Offline sweep: fresh context per org, log-and-continue on a per-org error, plus a final pass for servers whose org no longer exists. - ESO handler 401s on an empty token-derived org rather than querying org_id "". --- server/cmd/main.go | 5 +- server/internal/api/secrets.go | 5 ++ server/internal/services/migrate.go | 32 ++++++-- server/internal/services/servers.go | 116 ++++++++++++++++----------- server/internal/services/settings.go | 11 ++- server/internal/services/steplogs.go | 26 ++++-- 6 files changed, 132 insertions(+), 63 deletions(-) diff --git a/server/cmd/main.go b/server/cmd/main.go index 5fd5426..638581f 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -39,8 +39,11 @@ func main() { 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.Printf("warning: failed to ensure settings indexes: %v", err) + log.Fatalf("failed to ensure settings indexes: %v", err) } if err := services.EnsureWorkflowIndexes(); err != nil { diff --git a/server/internal/api/secrets.go b/server/internal/api/secrets.go index 5bbfcf9..01e17ed 100644 --- a/server/internal/api/secrets.go +++ b/server/internal/api/secrets.go @@ -53,6 +53,11 @@ func esoGetGroup(c *gin.Context) { group := c.Param("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"}) diff --git a/server/internal/services/migrate.go b/server/internal/services/migrate.go index ef4bc23..a828cbc 100644 --- a/server/internal/services/migrate.go +++ b/server/internal/services/migrate.go @@ -2,6 +2,7 @@ package services import ( "context" + "log" "time" "github.com/google/uuid" @@ -103,19 +104,38 @@ func MigrateSettingsOrg() error { 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 - err := db.Col("orgs").FindOne(ctx, bson.M{"slug": "default"}).Decode(&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, so leave + // it for an operator to resolve. + log.Printf("settings org migration: %d unstamped settings doc(s) with %d orgs present; skipping", n, orgCount) } - 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 + 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 + } } } diff --git a/server/internal/services/servers.go b/server/internal/services/servers.go index 349e84f..75dcb45 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" @@ -277,60 +278,83 @@ func StoreAvailableUpdates(serverID string, pkgs []models.PackageUpdate) error { } func MarkOfflineServers() error { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - // 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. orgIDs, err := ListOrgIDs() if 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 { - settings, _ := GetSettings(orgID) - thresholdMinutes := 5 - if settings != nil && settings.Alerts.OfflineThresholdMinutes > 0 { - thresholdMinutes = settings.Alerts.OfflineThresholdMinutes + if err := markOfflineForFilter(bson.M{"org_id": orgID}, orgID); err != nil { + log.Printf("offline sweep failed for org %s: %v", orgID, err) } - cutoff := time.Now().Add(-time.Duration(thresholdMinutes) * time.Minute) + } - filter := bson.M{ - "org_id": orgID, - "status": "active", - "last_seen": bson.M{"$lt": cutoff}, - } - - // 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 { - continue - } - - for _, s := range goingOffline { - 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) - } - if settings != nil && settings.Email.Enabled { - go SendOfflineEmail(settings.Email, s.Hostname, s.ServerID, s.IPAddress) - } - } - - if _, err := db.Col("servers").UpdateMany(ctx, filter, - bson.M{"$set": bson.M{"status": "offline"}}, - ); err != nil { - return 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(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) + } + if settings != nil && settings.Email.Enabled { + go SendOfflineEmail(settings.Email, s.Hostname, s.ServerID, s.IPAddress) + } + } + + _, err = db.Col("servers").UpdateMany(ctx, filter, + bson.M{"$set": bson.M{"status": "offline"}}, + ) + return err +} diff --git a/server/internal/services/settings.go b/server/internal/services/settings.go index 624d783..addd39f 100644 --- a/server/internal/services/settings.go +++ b/server/internal/services/settings.go @@ -53,12 +53,15 @@ func EnsureSettingsIndexes() error { } // Partial so the many settings docs with no ESO token set don't collide on - // a missing field. + // 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).SetPartialFilterExpression( - bson.M{"secrets.read_token_hash": bson.M{"$type": "string"}}, - ), + 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 } diff --git a/server/internal/services/steplogs.go b/server/internal/services/steplogs.go index 36edb55..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. @@ -184,7 +186,14 @@ func sweepLogs() { runID := e.Name() dir := filepath.Join(base, runID) - orgID, finishedAt, found := runRetentionInfo(runID) + 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 } @@ -220,16 +229,21 @@ func sweepLogs() { const defaultRetentionDays = 30 // runRetentionInfo returns the owning org and finish time of a run, and whether -// the run doc still exists. -func runRetentionInfo(runID string) (string, *time.Time, bool) { +// 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"` } - if err := db.Col("workflow_runs").FindOne(ctx, bson.M{"run_id": runID}).Decode(&run); err != nil { - return "", nil, false + err := db.Col("workflow_runs").FindOne(ctx, bson.M{"run_id": runID}).Decode(&run) + if err == mongo.ErrNoDocuments { + return "", nil, false, nil } - return run.OrgID, run.FinishedAt, true + if err != nil { + return "", nil, false, err + } + return run.OrgID, run.FinishedAt, true, nil } From e2f5f1fa8cf56772be5f6cf4ead1dff92afbded7 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Wed, 22 Jul 2026 10:00:30 +0100 Subject: [PATCH 17/23] feat(grpc): resolve org from server record for agent RPCs Two instances of the branch's recurring bug class remained in the agent path: a client-supplied ID accepted as data, then consumed by an unscoped query. - ListMonitorsForRunner filtered on `runner` alone, and `runner` is set by the client on monitor create/update. Org A could point a monitor at org B's server_id and org B's agent would fetch and execute the check. Now org-filtered, and `runner` is validated against the caller's org on create and update. - IngestResult resolved the monitor via the unfiltered getMonitorByID using a monitor_id from the agent's request body, letting org A's agent write state and incidents into org B's monitor and fire its channels. Now rejects on org mismatch and on a monitor not assigned to the reporting agent. The in-process scheduler passes an empty orgID as a documented sentinel for the cross-org server-run sweep. Install script still emits a single shared GRPC_HOST; the agent path resolves org from the server record, never from a hostname. --- server/internal/grpc/server.go | 9 ++-- server/internal/monitorsched/scheduler.go | 4 +- server/internal/services/monitors.go | 52 ++++++++++++++++++++--- 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/server/internal/grpc/server.go b/server/internal/grpc/server.go index f60c461..8e7f9e7 100644 --- a/server/internal/grpc/server.go +++ b/server/internal/grpc/server.go @@ -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/monitorsched/scheduler.go b/server/internal/monitorsched/scheduler.go index f3b33a8..214f4c9 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.ListMonitorsForRunner("", models.RunnerServer) 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.IngestResult("", models.RunnerServer, m.MonitorID, res); err != nil { log.Printf("monitorsched: ingest %s: %v", m.MonitorID, err) } } diff --git a/server/internal/services/monitors.go b/server/internal/services/monitors.go index 4de9ec6..38427bc 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" @@ -51,12 +52,19 @@ func ListMonitors(orgID string) ([]models.Monitor, error) { } // ListMonitorsForRunner returns enabled monitors whose Runner matches runner. -// Agent/scheduler path — a cross-org sweep (mirrors MarkOfflineServers), so it -// intentionally has no org filter. -func ListMonitorsForRunner(runner string) ([]models.Monitor, error) { +// 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 means the cross-org server-scheduler sweep (mirrors +// MarkOfflineServers) and is only ever passed with runner == RunnerServer. +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 } @@ -98,12 +106,29 @@ func getMonitorByID(monitorID string) (*models.Monitor, error) { return &m, nil } +// 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() @@ -131,6 +156,11 @@ func UpdateMonitor(orgID, monitorID string, upd bson.M) error { return err } } + if runner, ok := upd["runner"].(string); ok { + if err := validateRunner(orgID, runner); err != nil { + return err + } + } _, err := db.Col("monitors").UpdateOne(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID}, bson.M{"$set": upd}) return err } @@ -191,7 +221,13 @@ 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 the in-process server +// scheduler, which passes runner == RunnerServer. +func IngestResult(orgID, runner, monitorID string, res checker.Result) error { ctx, cancel := monCtx() defer cancel() @@ -199,6 +235,12 @@ func IngestResult(monitorID string, res checker.Result) error { if err != nil || m == nil { return err } + 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 From 156c5354de2113d2bcbf60455d033dbb4be0dee1 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Wed, 22 Jul 2026 10:05:37 +0100 Subject: [PATCH 18/23] fix(server): close fail-open paths in monitor org checks Review hardening on e2f5f1f. None of these were live bypasses; all were one bad row or one new caller away from becoming one. - Replace the empty-orgID sentinel with explicit scheduler entry points. The sentinel meant "skip the org check" and was keyed on a value read from a DB record on the agent path, so a server doc with a blank org_id would silently disable isolation. The exported agent-facing functions now reject an empty orgID outright. - ValidateAgentToken errors when the resolved server has no org. - UpdateMonitor's runner and channel_ids type assertions were fail-open: a wrong-typed value skipped validation while the $set still ran. Now a hard error. - Normalise an empty runner on update to the server runner, matching create. Previously it matched no runner at all, so the monitor silently stopped being checked and stopped alerting. - IngestResult returns an error for an unknown monitor, so probing an unknown ID looks the same as probing a foreign one. --- server/internal/monitorsched/scheduler.go | 4 +- server/internal/services/monitors.go | 78 +++++++++++++++++++---- server/internal/services/servers.go | 5 ++ 3 files changed, 74 insertions(+), 13 deletions(-) diff --git a/server/internal/monitorsched/scheduler.go b/server/internal/monitorsched/scheduler.go index 214f4c9..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("", models.RunnerServer, 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/monitors.go b/server/internal/services/monitors.go index 38427bc..45dfb74 100644 --- a/server/internal/services/monitors.go +++ b/server/internal/services/monitors.go @@ -51,13 +51,30 @@ func ListMonitors(orgID string) ([]models.Monitor, error) { return out, nil } -// ListMonitorsForRunner returns enabled monitors whose Runner matches runner. -// 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 means the cross-org server-scheduler sweep (mirrors -// MarkOfflineServers) and is only ever passed with runner == RunnerServer. +// 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() filter := bson.M{"runner": runner, "enabled": true} @@ -151,15 +168,31 @@ func CreateMonitor(orgID string, m *models.Monitor) (*models.Monitor, error) { func UpdateMonitor(orgID, monitorID string, upd bson.M) error { ctx, cancel := monCtx() defer cancel() - if ids, ok := upd["channel_ids"].([]string); ok { + // 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 runner, ok := upd["runner"].(string); ok { + 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 @@ -225,16 +258,39 @@ func UptimeRollups(monitorID string, since time.Time) ([]models.Rollup, 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 the in-process server -// scheduler, which passes runner == RunnerServer. +// 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 := getMonitorByID(monitorID) - if err != nil || m == nil { + 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) } diff --git a/server/internal/services/servers.go b/server/internal/services/servers.go index 75dcb45..39d0098 100644 --- a/server/internal/services/servers.go +++ b/server/internal/services/servers.go @@ -181,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 } From e70b2f0e675ffd42c846ddceb1a30ff47ce5b353 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Wed, 22 Jul 2026 10:17:12 +0100 Subject: [PATCH 19/23] feat(web): login, first-run setup, org settings; org-aware AuthProvider - Route group (app) holds AuthProvider + Sidebar, so /login and /setup render without app chrome and never mount the provider. - AuthProvider drops the removed auth_enabled flag and exposes {user, org, isAdmin}. - New login page (password + SSO), first-run setup page, and org settings page with a members table and the OIDC provider form. - Settings page and the Organization nav entry are gated on role, since /api/settings now 403s for members. - GET /api/org/oidc gains client_secret_set so the UI can show whether a secret is stored; the secret itself is still never serialized, and an empty submitted value still means "keep the stored one". - Fix logout: the sidebar linked to /auth/logout with a GET, but the route is POST-only, so logout was 404ing. --- server/internal/api/org.go | 13 +- web/app/{ => (app)}/audit/page.tsx | 0 web/app/{ => (app)}/keys/[id]/page.tsx | 0 web/app/{ => (app)}/keys/page.tsx | 0 web/app/(app)/layout.tsx | 17 + .../{ => (app)}/monitors/[id]/edit/page.tsx | 0 web/app/{ => (app)}/monitors/[id]/page.tsx | 0 web/app/{ => (app)}/monitors/new/page.tsx | 0 web/app/{ => (app)}/monitors/page.tsx | 0 web/app/{ => (app)}/page.tsx | 0 web/app/{ => (app)}/secrets/[group]/page.tsx | 0 web/app/{ => (app)}/secrets/page.tsx | 0 .../{ => (app)}/servers/[id]/console/page.tsx | 0 web/app/{ => (app)}/servers/[id]/page.tsx | 0 web/app/{ => (app)}/servers/new/page.tsx | 0 web/app/{ => (app)}/servers/page.tsx | 0 .../settings/notifications/page.tsx | 0 web/app/(app)/settings/org/page.tsx | 400 ++++++++++++++++++ web/app/{ => (app)}/settings/page.tsx | 22 +- web/app/{ => (app)}/steps/page.tsx | 0 web/app/{ => (app)}/workflows/[id]/page.tsx | 0 .../workflows/[id]/runs/[runId]/page.tsx | 0 .../{ => (app)}/workflows/[id]/runs/page.tsx | 0 web/app/{ => (app)}/workflows/page.tsx | 0 web/app/layout.tsx | 13 +- web/app/login/page.tsx | 109 +++++ web/app/setup/page.tsx | 169 ++++++++ web/components/AuthProvider.tsx | 70 +-- web/components/Sidebar.tsx | 61 ++- web/lib/api.ts | 160 +++++++ 30 files changed, 979 insertions(+), 55 deletions(-) rename web/app/{ => (app)}/audit/page.tsx (100%) rename web/app/{ => (app)}/keys/[id]/page.tsx (100%) rename web/app/{ => (app)}/keys/page.tsx (100%) create mode 100644 web/app/(app)/layout.tsx rename web/app/{ => (app)}/monitors/[id]/edit/page.tsx (100%) rename web/app/{ => (app)}/monitors/[id]/page.tsx (100%) rename web/app/{ => (app)}/monitors/new/page.tsx (100%) rename web/app/{ => (app)}/monitors/page.tsx (100%) rename web/app/{ => (app)}/page.tsx (100%) rename web/app/{ => (app)}/secrets/[group]/page.tsx (100%) rename web/app/{ => (app)}/secrets/page.tsx (100%) rename web/app/{ => (app)}/servers/[id]/console/page.tsx (100%) rename web/app/{ => (app)}/servers/[id]/page.tsx (100%) rename web/app/{ => (app)}/servers/new/page.tsx (100%) rename web/app/{ => (app)}/servers/page.tsx (100%) rename web/app/{ => (app)}/settings/notifications/page.tsx (100%) create mode 100644 web/app/(app)/settings/org/page.tsx rename web/app/{ => (app)}/settings/page.tsx (93%) rename web/app/{ => (app)}/steps/page.tsx (100%) rename web/app/{ => (app)}/workflows/[id]/page.tsx (100%) rename web/app/{ => (app)}/workflows/[id]/runs/[runId]/page.tsx (100%) rename web/app/{ => (app)}/workflows/[id]/runs/page.tsx (100%) rename web/app/{ => (app)}/workflows/page.tsx (100%) create mode 100644 web/app/login/page.tsx create mode 100644 web/app/setup/page.tsx diff --git a/server/internal/api/org.go b/server/internal/api/org.go index 0cf374f..448e966 100644 --- a/server/internal/api/org.go +++ b/server/internal/api/org.go @@ -64,10 +64,19 @@ func deleteOrgUser(c *gin.Context) { func getOrgOIDC(c *gin.Context) { cfg, err := services.GetOrgOIDC(auth.OrgID(c)) if err != nil { - c.JSON(http.StatusOK, gin.H{"enabled": false}) + c.JSON(http.StatusOK, gin.H{"enabled": false, "client_secret_set": false}) return } - c.JSON(http.StatusOK, cfg) + // 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) { 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..31e87b2 --- /dev/null +++ b/web/app/(app)/settings/org/page.tsx @@ -0,0 +1,400 @@ +"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 } = useMutation({ + mutationFn: ({ userId, next }: { userId: string; next: Role }) => api.updateOrgUserRole(userId, next), + onSuccess: invalidate, + }); + + const { mutate: removeUser } = useMutation({ + mutationFn: (userId: string) => api.deleteOrgUser(userId), + onSuccess: invalidate, + }); + + return ( + +
+
+

Members

+

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

+
+ +
+ + {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; + return ( + + + + + + + + ); + })} + +
EmailRoleSign-inLast loginActions
+ {u.email} + {isSelf && (you)} + + {isSelf ? ( + {u.role} + ) : ( + + )} + + {u.auth_source === "oidc" ? "SSO" : "Password"} + + {u.last_login ? new Date(u.last_login).toLocaleString() : "Never"} + + {!isSelf && ( + + )} +
+ )} + + 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..c669e2e --- /dev/null +++ b/web/app/login/page.tsx @@ -0,0 +1,109 @@ +"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 instance has no users yet, first-run setup is the only way in. + useEffect(() => { + auth + .bootstrapStatus() + .then((s) => { + if (s.needs_setup) window.location.href = "/setup"; + }) + .catch(() => { + // Status unavailable — let the login form stand. + }); + }, []); + + 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..36b2809 --- /dev/null +++ b/web/app/setup/page.tsx @@ -0,0 +1,169 @@ +"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. + */ +function orgUrlForSlug(slug: string): string { + if (typeof window === "undefined") return "/"; + const { protocol, host } = window.location; + const [hostname, port] = host.split(":"); + const parts = hostname.split("."); + + if (parts.length < 2 || parts[parts.length - 1] === "localhost") return "/"; + + const rest = parts[0] === "vantage" ? parts : parts.slice(1); + if (rest[0] !== "vantage") return "/"; + + const newHost = [slug, ...rest].join(".") + (port ? `:${port}` : ""); + return `${protocol}//${newHost}/`; +} + +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); + + // 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) => { + window.location.href = orgUrlForSlug(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"; + + 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..af151e6 100644 --- a/web/components/AuthProvider.tsx +++ b/web/components/AuthProvider.tsx @@ -1,49 +1,63 @@ "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); 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; } + // Backend unreachable or unexpected failure — don't trap the user on a spinner. setLoading(false); - }) - .catch(() => { - // Backend unreachable — don't block the UI - setLoading(false); - }); + } + })(); + + return () => { + cancelled = true; + }; }, []); if (loading) { @@ -54,9 +68,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { ); } + 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}} +