From 9e53f217468060766d34dfb688f6f335bc23d2ad Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 20 Jul 2026 11:16:19 +0100 Subject: [PATCH] docs: add Fleet Inventory and SaaS auth/orgs specs + plans --- .../plans/2026-07-20-fleet-inventory.md | 653 +++++++++ .../plans/2026-07-20-saas-auth-orgs.md | 1174 +++++++++++++++++ .../2026-07-20-fleet-inventory-design.md | 142 ++ .../specs/2026-07-20-saas-auth-orgs-design.md | 142 ++ 4 files changed, 2111 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-20-fleet-inventory.md create mode 100644 docs/superpowers/plans/2026-07-20-saas-auth-orgs.md create mode 100644 docs/superpowers/specs/2026-07-20-fleet-inventory-design.md create mode 100644 docs/superpowers/specs/2026-07-20-saas-auth-orgs-design.md diff --git a/docs/superpowers/plans/2026-07-20-fleet-inventory.md b/docs/superpowers/plans/2026-07-20-fleet-inventory.md new file mode 100644 index 0000000..eb436c2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-fleet-inventory.md @@ -0,0 +1,653 @@ +# Fleet Inventory 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:** Agents collect CPU/RAM/swap/disk/partition inventory and report it to the server via a new `ReportInventory` RPC; the server stores the latest snapshot per server and the UI displays it. + +**Architecture:** New unary gRPC `ReportInventory` (mirrors existing `ReportUpdates`). Agent runs a 30s metrics ticker (CPU/RAM/swap usage) and, every 15 min, a full static collection (disks, partitions, CPU model, kernel). Server upserts an embedded `inventory` sub-doc on the `servers` document with merge rules that preserve static fields between slow ticks. + +**Tech Stack:** Go (gin, mongo-driver v2, hand-written JSON-codec gRPC), `/proc` readers, Next.js 16 + react-query + Tailwind. + +## Global Constraints + +- **No tests this iteration.** Verify with `go build ./...`, `go vet ./...`, `npm run build`. +- gRPC uses a JSON codec: edit **both** `server/internal/grpc/pb/vantage.pb.go` and `agent/internal/grpc/pb/vantage.pb.go` identically, plus `proto/vantage/v1/vantage.proto` as documentation. No codegen. Mirror the existing `ReportUpdates` RPC wiring exactly (service interface, `_Vantage_*_Handler`, client method, `Vantage_ServiceDesc`). +- Mongo: `db.Col("servers")`, `context.WithTimeout`. Follow `server/internal/services/servers.go`. +- Agent already runs as root; `/proc` is readable. Linux is primary; Windows collectors may return empty. +- Module path `github.com/mrhid6/vantage`. +- Do not add heavy dependencies; implement `/proc` parsing directly. + +--- + +## Task 1: Inventory model + gRPC messages + +**Files:** +- Modify: `server/internal/models/server.go` +- Modify: `proto/vantage/v1/vantage.proto` +- Modify: `server/internal/grpc/pb/vantage.pb.go` +- Modify: `agent/internal/grpc/pb/vantage.pb.go` + +**Interfaces:** +- Produces: `models.Inventory` (+ `CPUInfo`, `MemInfo`, `Partition`) and `Server.Inventory *Inventory`. pb structs `InventoryReport`, `CPUReport`, `MemReport`, `PartitionReport`, `InventoryReportResponse`. Service method `ReportInventory` on both client and server interfaces. + +- [ ] **Step 1: Add model structs** + +In `server/internal/models/server.go` add (keep the existing `import "time"`): + +```go +type CPUInfo struct { + Model string `bson:"model,omitempty" json:"model,omitempty"` + Cores int `bson:"cores,omitempty" json:"cores,omitempty"` + UsagePct float64 `bson:"usage_pct" json:"usage_pct"` + Load1 float64 `bson:"load1,omitempty" json:"load1,omitempty"` +} + +type MemInfo struct { + TotalBytes uint64 `bson:"total_bytes" json:"total_bytes"` + UsedBytes uint64 `bson:"used_bytes" json:"used_bytes"` +} + +type Partition struct { + Device string `bson:"device" json:"device"` + Mountpoint string `bson:"mountpoint" json:"mountpoint"` + Fstype string `bson:"fstype,omitempty" json:"fstype,omitempty"` + TotalBytes uint64 `bson:"total_bytes" json:"total_bytes"` + UsedBytes uint64 `bson:"used_bytes" json:"used_bytes"` +} + +type Inventory struct { + CPU CPUInfo `bson:"cpu" json:"cpu"` + Memory MemInfo `bson:"memory" json:"memory"` + SwapTotalBytes uint64 `bson:"swap_total_bytes" json:"swap_total_bytes"` + SwapUsedBytes uint64 `bson:"swap_used_bytes" json:"swap_used_bytes"` + Partitions []Partition `bson:"partitions,omitempty" json:"partitions,omitempty"` + Kernel string `bson:"kernel,omitempty" json:"kernel,omitempty"` + MetricsAt *time.Time `bson:"metrics_at,omitempty" json:"metrics_at,omitempty"` + StaticAt *time.Time `bson:"static_at,omitempty" json:"static_at,omitempty"` +} +``` + +Add to the `Server` struct: `Inventory *Inventory \`bson:"inventory,omitempty" json:"inventory,omitempty"\``. + +- [ ] **Step 2: Document RPC in proto** + +In `proto/vantage/v1/vantage.proto`, add to the service: `rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);` and the messages `InventoryReport`, `CPUReport`, `MemReport`, `PartitionReport`, `InventoryReportResponse` per spec §4. + +- [ ] **Step 3: Add pb structs + RPC wiring (server pb)** + +In `server/internal/grpc/pb/vantage.pb.go` add the message structs: + +```go +type CPUReport struct { + Model string `json:"model,omitempty"` + Cores int `json:"cores,omitempty"` + UsagePct float64 `json:"usage_pct"` + Load1 float64 `json:"load1,omitempty"` +} +type MemReport struct { + TotalBytes uint64 `json:"total_bytes"` + UsedBytes uint64 `json:"used_bytes"` +} +type PartitionReport struct { + Device string `json:"device"` + Mountpoint string `json:"mountpoint"` + Fstype string `json:"fstype,omitempty"` + TotalBytes uint64 `json:"total_bytes"` + UsedBytes uint64 `json:"used_bytes"` +} +type InventoryReport struct { + ServerId string `json:"server_id"` + AgentToken string `json:"agent_token"` + IncludeStatic bool `json:"include_static"` + CPU *CPUReport `json:"cpu,omitempty"` + Memory *MemReport `json:"memory,omitempty"` + SwapTotal uint64 `json:"swap_total"` + SwapUsed uint64 `json:"swap_used"` + Partitions []PartitionReport `json:"partitions,omitempty"` + Kernel string `json:"kernel,omitempty"` +} +type InventoryReportResponse struct{} +``` + +Then mirror the `ReportUpdates` RPC plumbing for `ReportInventory`. Locate every `ReportUpdates` reference in this file and add the parallel `ReportInventory`: +- `VantageServer` interface: add `ReportInventory(context.Context, *InventoryReport) (*InventoryReportResponse, error)`. +- `UnimplementedVantageServer`: add the stub returning `Unimplemented`. +- `VantageClient` interface + `keyManagerClient`: add the client method `Invoke`-ing `/vantage.v1.Vantage/ReportInventory`. +- `Vantage_ServiceDesc.Methods`: add `{MethodName: "ReportInventory", Handler: _Vantage_ReportInventory_Handler}`. +- Add `_Vantage_ReportInventory_Handler` copied from `_Vantage_ReportUpdates_Handler` with types swapped. + +- [ ] **Step 4: Mirror pb structs + wiring (agent pb)** + +Apply the identical additions to `agent/internal/grpc/pb/vantage.pb.go`. + +- [ ] **Step 5: Verify build** + +Run: `cd server && go build ./... && cd ../agent && go build ./...` +Expected: both succeed. + +- [ ] **Step 6: Commit** + +```bash +git add server/internal/models/server.go proto/vantage/v1/vantage.proto server/internal/grpc/pb/vantage.pb.go agent/internal/grpc/pb/vantage.pb.go +git commit -m "feat(proto): add ReportInventory RPC and inventory model" +``` + +--- + +## Task 2: Server handler + store service + +**Files:** +- Create: `server/internal/services/inventory.go` +- Modify: `server/internal/grpc/server.go` + +**Interfaces:** +- Consumes: `pb.InventoryReport` (T1), `db.Col("servers")`. +- Produces: `services.StoreInventory(serverID string, r *pb.InventoryReport) error`; gRPC method `(*vantageServer).ReportInventory`. + +- [ ] **Step 1: Write the store service** + +```go +package services + +import ( + "context" + "time" + + "github.com/mrhid6/vantage/server/internal/db" + "github.com/mrhid6/vantage/server/internal/grpc/pb" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// StoreInventory upserts the latest inventory snapshot onto the server document. +// Metrics fields update every call; static fields only when r.IncludeStatic. +func StoreInventory(serverID string, r *pb.InventoryReport) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + now := time.Now() + set := bson.M{"inventory.metrics_at": now} + if r.CPU != nil { + set["inventory.cpu.usage_pct"] = r.CPU.UsagePct + set["inventory.cpu.load1"] = r.CPU.Load1 + } + if r.Memory != nil { + set["inventory.memory.used_bytes"] = r.Memory.UsedBytes + } + set["inventory.swap_used_bytes"] = r.SwapUsed + + if r.IncludeStatic { + set["inventory.static_at"] = now + set["inventory.swap_total_bytes"] = r.SwapTotal + set["inventory.kernel"] = r.Kernel + if r.CPU != nil { + set["inventory.cpu.model"] = r.CPU.Model + set["inventory.cpu.cores"] = r.CPU.Cores + } + if r.Memory != nil { + set["inventory.memory.total_bytes"] = r.Memory.TotalBytes + } + parts := make([]bson.M, 0, len(r.Partitions)) + for _, p := range r.Partitions { + parts = append(parts, bson.M{ + "device": p.Device, "mountpoint": p.Mountpoint, "fstype": p.Fstype, + "total_bytes": p.TotalBytes, "used_bytes": p.UsedBytes, + }) + } + set["inventory.partitions"] = parts + } + + _, err := db.Col("servers").UpdateOne(ctx, bson.M{"server_id": serverID}, bson.M{"$set": set}) + return err +} +``` + +- [ ] **Step 2: Add the gRPC handler** + +In `server/internal/grpc/server.go`, add (mirroring the existing `ReportUpdates` handler that validates the agent token): + +```go +func (s *vantageServer) ReportInventory(ctx context.Context, req *pb.InventoryReport) (*pb.InventoryReportResponse, error) { + srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken) + if err != nil { + return nil, status.Errorf(codes.Unauthenticated, "invalid agent token") + } + if err := services.StoreInventory(srv.ServerID, req); err != nil { + log.Printf("store inventory for %s: %v", srv.ServerID, err) + } + return &pb.InventoryReportResponse{}, nil +} +``` + +Confirm `status`, `codes`, `log` are already imported in the file (they are, used by other handlers). + +- [ ] **Step 3: Verify build** + +Run: `cd server && go build ./... && go vet ./...` +Expected: success. + +- [ ] **Step 4: Commit** + +```bash +git add server/internal/services/inventory.go server/internal/grpc/server.go +git commit -m "feat(server): store inventory and handle ReportInventory RPC" +``` + +--- + +## Task 3: Agent collectors + +**Files:** +- Create: `agent/internal/inventory/collect_linux.go` +- Create: `agent/internal/inventory/collect_other.go` +- Create: `agent/internal/inventory/inventory.go` + +**Interfaces:** +- Produces: `inventory.Collect(includeStatic bool) *pb.InventoryReport`. + +- [ ] **Step 1: Common entry (`inventory.go`)** + +```go +package inventory + +import "github.com/mrhid6/vantage/agent/internal/grpc/pb" + +// Collect gathers metrics always and static hardware info when includeStatic. +// Platform specifics are provided by collect_linux.go / collect_other.go. +func Collect(includeStatic bool) *pb.InventoryReport { + r := &pb.InventoryReport{IncludeStatic: includeStatic, CPU: &pb.CPUReport{}, Memory: &pb.MemReport{}} + collect(r, includeStatic) + return r +} +``` + +- [ ] **Step 2: Linux collector (`collect_linux.go`)** + +Build-tagged `//go:build linux`. Implement `collect(r *pb.InventoryReport, includeStatic bool)`: +- CPU usage: read `/proc/stat` first line twice ~100ms apart, compute `1 - idleDelta/totalDelta` × 100 → `r.CPU.UsagePct`. +- Load: first field of `/proc/loadavg` → `r.CPU.Load1`. +- Mem/swap: parse `/proc/meminfo` (`MemTotal`, `MemAvailable`, `SwapTotal`, `SwapFree`; used = total − available; swap used = swaptotal − swapfree) → `r.Memory.*`, `r.SwapUsed`, and on static `r.SwapTotal`. +- Static only: `/proc/cpuinfo` (`model name`, count `processor` lines) → `r.CPU.Model/Cores`; `/proc/meminfo MemTotal` → `r.Memory.TotalBytes`; kernel via `syscall.Uname` or read `/proc/sys/kernel/osrelease` → `r.Kernel`; partitions from `/proc/mounts` filtered to fstypes in {ext4,xfs,btrfs,zfs,vfat,ntfs} then `syscall.Statfs` for total/used → `r.Partitions`. + +```go +//go:build linux + +package inventory + +import ( + "bufio" + "os" + "strconv" + "strings" + "syscall" + "time" + + "github.com/mrhid6/vantage/agent/internal/grpc/pb" +) + +func collect(r *pb.InventoryReport, includeStatic bool) { + r.CPU.UsagePct = cpuUsage() + r.CPU.Load1 = load1() + memTotal, memAvail, swapTotal, swapFree := meminfo() + if memTotal > memAvail { + r.Memory.UsedBytes = memTotal - memAvail + } + if swapTotal > swapFree { + r.SwapUsed = swapTotal - swapFree + } + if includeStatic { + r.Memory.TotalBytes = memTotal + r.SwapTotal = swapTotal + r.CPU.Model, r.CPU.Cores = cpuStatic() + r.Kernel = kernel() + r.Partitions = partitions() + } +} + +func readProc(path string) string { b, _ := os.ReadFile(path); return string(b) } + +func cpuSample() (idle, total uint64) { + f, err := os.Open("/proc/stat") + if err != nil { + return + } + defer f.Close() + sc := bufio.NewScanner(f) + if sc.Scan() { + fields := strings.Fields(sc.Text()) // cpu user nice system idle iowait ... + for i, v := range fields[1:] { + n, _ := strconv.ParseUint(v, 10, 64) + total += n + if i == 3 { // idle + idle = n + } + } + } + return +} + +func cpuUsage() float64 { + i1, t1 := cpuSample() + time.Sleep(100 * time.Millisecond) + i2, t2 := cpuSample() + dt := float64(t2 - t1) + if dt <= 0 { + return 0 + } + return (1 - float64(i2-i1)/dt) * 100 +} + +func load1() float64 { + fields := strings.Fields(readProc("/proc/loadavg")) + if len(fields) > 0 { + v, _ := strconv.ParseFloat(fields[0], 64) + return v + } + return 0 +} + +func meminfo() (total, avail, swapTotal, swapFree uint64) { + f, err := os.Open("/proc/meminfo") + if err != nil { + return + } + defer f.Close() + sc := bufio.NewScanner(f) + for sc.Scan() { + fields := strings.Fields(sc.Text()) + if len(fields) < 2 { + continue + } + kb, _ := strconv.ParseUint(fields[1], 10, 64) + b := kb * 1024 + switch strings.TrimSuffix(fields[0], ":") { + case "MemTotal": + total = b + case "MemAvailable": + avail = b + case "SwapTotal": + swapTotal = b + case "SwapFree": + swapFree = b + } + } + return +} + +func cpuStatic() (model string, cores int) { + f, err := os.Open("/proc/cpuinfo") + if err != nil { + return + } + defer f.Close() + sc := bufio.NewScanner(f) + for sc.Scan() { + line := sc.Text() + if strings.HasPrefix(line, "processor") { + cores++ + } else if strings.HasPrefix(line, "model name") && model == "" { + if i := strings.Index(line, ":"); i >= 0 { + model = strings.TrimSpace(line[i+1:]) + } + } + } + return +} + +func kernel() string { + return strings.TrimSpace(readProc("/proc/sys/kernel/osrelease")) +} + +func partitions() []pb.PartitionReport { + allowed := map[string]bool{"ext4": true, "xfs": true, "btrfs": true, "zfs": true, "vfat": true, "ntfs": true, "ext3": true} + f, err := os.Open("/proc/mounts") + if err != nil { + return nil + } + defer f.Close() + var out []pb.PartitionReport + seen := map[string]bool{} + sc := bufio.NewScanner(f) + for sc.Scan() { + fields := strings.Fields(sc.Text()) + if len(fields) < 3 || !allowed[fields[2]] || seen[fields[1]] { + continue + } + seen[fields[1]] = true + var st syscall.Statfs_t + if syscall.Statfs(fields[1], &st) != nil { + continue + } + total := st.Blocks * uint64(st.Bsize) + free := st.Bavail * uint64(st.Bsize) + out = append(out, pb.PartitionReport{ + Device: fields[0], Mountpoint: fields[1], Fstype: fields[2], + TotalBytes: total, UsedBytes: total - free, + }) + } + return out +} +``` + +- [ ] **Step 3: Non-linux stub (`collect_other.go`)** + +```go +//go:build !linux + +package inventory + +import "github.com/mrhid6/vantage/agent/internal/grpc/pb" + +// collect is a no-op best-effort stub on non-Linux platforms. +func collect(r *pb.InventoryReport, includeStatic bool) {} +``` + +- [ ] **Step 4: Verify build** + +Run: `cd agent && go build ./... && go vet ./...` +Expected: success (build both native and, if convenient, `GOOS=windows go build ./...`). + +- [ ] **Step 5: Commit** + +```bash +git add agent/internal/inventory/ +git commit -m "feat(agent): /proc-based inventory collectors" +``` + +--- + +## Task 4: Agent client method + scheduler + +**Files:** +- Modify: `agent/internal/grpc/client.go` +- Modify: the agent main loop (`agent/cmd/main.go` or `agent/internal/sync/sync.go` — wherever the poll loop/tickers live). + +**Interfaces:** +- Consumes: `inventory.Collect` (T3), pb (T1). +- Produces: `(*Client).ReportInventory(report *pb.InventoryReport) error`; a running ticker that reports metrics every 30s and static every 15 min. + +- [ ] **Step 1: Add client method** + +In `agent/internal/grpc/client.go`, mirroring `ReportUpdates`: + +```go +func (c *Client) ReportInventory(report *pb.InventoryReport) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, err := c.client.ReportInventory(ctx, report) + return err +} +``` + +The report already carries `ServerId`/`AgentToken`; ensure the caller sets them (see Step 2). + +- [ ] **Step 2: Add the scheduler to the agent loop** + +Find where the agent starts its poll loop (the goroutine that calls `SyncKeys`/`ReportUpdates`). Add a parallel inventory ticker. `serverID`, `agentToken`, and the `*Client` are in scope there: + +```go + go func() { + tick := 0 + t := time.NewTicker(30 * time.Second) + defer t.Stop() + report := func(static bool) { + r := inventory.Collect(static) + r.ServerId = serverID + r.AgentToken = agentToken + if err := client.ReportInventory(r); err != nil { + log.Printf("report inventory: %v", err) + } + } + report(true) // send a full snapshot on startup + for range t.C { + tick++ + report(tick%30 == 0) // every 30th tick = 15 min → include static + } + }() +``` + +Add imports `"github.com/mrhid6/vantage/agent/internal/inventory"`, `time`, `log` if missing. Match variable names to the actual loop (e.g. the client may be named `c`). + +- [ ] **Step 3: Verify build** + +Run: `cd agent && go build ./... && go vet ./...` +Expected: success. + +- [ ] **Step 4: Commit** + +```bash +git add agent/internal/grpc/client.go agent/ +git commit -m "feat(agent): schedule inventory reporting (30s metrics, 15m static)" +``` + +--- + +## Task 5: Frontend — inventory panel on server detail + +**Files:** +- Modify: `web/lib/api.ts` (extend the `Server`/server-detail type with `inventory`) +- Modify: `web/app/servers/[id]/page.tsx` (add panel; enable polling) + +**Interfaces:** +- Consumes: server-detail query. + +- [ ] **Step 1: Add the inventory type** + +In `web/lib/api.ts`, add and attach to the server type used by the detail page: + +```ts +export interface Inventory { + cpu: { model?: string; cores?: number; usage_pct: number; load1?: number }; + memory: { total_bytes: number; used_bytes: number }; + swap_total_bytes: number; + swap_used_bytes: number; + partitions?: { device: string; mountpoint: string; fstype?: string; total_bytes: number; used_bytes: number }[]; + kernel?: string; + metrics_at?: string; + static_at?: string; +} +``` + +Add `inventory?: Inventory;` to the server detail interface. + +- [ ] **Step 2: Add a `formatBytes` helper + Inventory panel** + +In `web/app/servers/[id]/page.tsx`, add a helper and a panel component. Enable polling on the server-detail `useQuery` with `refetchInterval: 30000`. + +```tsx +function formatBytes(n: number): string { + if (!n) return "0 B"; + const u = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(n) / Math.log(1024)); + return `${(n / Math.pow(1024, i)).toFixed(1)} ${u[i]}`; +} + +function UsageBar({ used, total }: { used: number; total: number }) { + const pct = total > 0 ? Math.min(100, (used / total) * 100) : 0; + return ( +
+
90 ? "bg-danger" : "bg-accent"}`} style={{ width: `${pct}%` }} /> +
+ ); +} + +function InventoryPanel({ inv }: { inv: Inventory }) { + return ( + +

Inventory

+
+
+
CPU{inv.cpu.usage_pct.toFixed(0)}%
+ +

{inv.cpu.model} · {inv.cpu.cores} cores · load {inv.cpu.load1?.toFixed(2)}

+
+
+
Memory{formatBytes(inv.memory.used_bytes)} / {formatBytes(inv.memory.total_bytes)}
+ +
Swap{formatBytes(inv.swap_used_bytes)} / {formatBytes(inv.swap_total_bytes)}
+ +
+
+ {inv.partitions && inv.partitions.length > 0 && ( +
+

Partitions

+
+ {inv.partitions.map((p) => ( +
+
+ {p.mountpoint} + {formatBytes(p.used_bytes)} / {formatBytes(p.total_bytes)} · {p.fstype} +
+ +
+ ))} +
+
+ )} + {inv.kernel &&

Kernel {inv.kernel}

} +
+ ); +} +``` + +Render `{server.inventory && }` in the page body (ensure `Card`, `Inventory` are imported). Match how the page currently reads the server object. + +- [ ] **Step 3: Verify build** + +Run: `cd web && npm run build` +Expected: success. + +- [ ] **Step 4: Commit** + +```bash +git add web/lib/api.ts web/app/servers/[id]/page.tsx +git commit -m "feat(web): inventory panel on server detail" +``` + +--- + +## Task 6: End-to-end manual verification + +- [ ] **Step 1: Build all** + +Run: `cd server && go build ./... && cd ../agent && go build ./... && cd ../web && npm run build` +Expected: all succeed. + +- [ ] **Step 2: Smoke (if environment available)** + +With server + Mongo + a connected Linux agent: within ~30s the server detail page shows CPU %, RAM/swap bars; within 15 min (or on agent restart, which sends a full snapshot immediately) partitions, CPU model and kernel appear. Confirm metrics update roughly every 30s. + +- [ ] **Step 3: Commit any fixes** + +```bash +git add -A +git commit -m "fix: fleet inventory verification fixes" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** §3 model → T1; §4 RPC → T1; §5 collectors + scheduler → T3, T4; §6 handler/store → T2; §7 frontend → T5. Split cadence (30s metrics / 15m static) in T4 scheduler; merge rules preserving static in T2 `StoreInventory`. Tests omitted per Global Constraints. +- **Startup snapshot:** agent sends `Collect(true)` immediately so static fields populate without waiting 15 min. +- **Types consistent:** `InventoryReport` field names identical across proto, both pb files, store service, and TS interface (`usage_pct`, `used_bytes`, `total_bytes`, `swap_*`). +- **Follow-ups (out of scope):** time-series history, usage alerting, Windows collectors, servers-list CPU/RAM badges. diff --git a/docs/superpowers/plans/2026-07-20-saas-auth-orgs.md b/docs/superpowers/plans/2026-07-20-saas-auth-orgs.md new file mode 100644 index 0000000..a8a4203 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-saas-auth-orgs.md @@ -0,0 +1,1174 @@ +# SaaS: Auth + Organizations Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the global Authentik OIDC with local email/password accounts, introduce organizations that scope every domain object via `org_id`, and let org admins configure their own per-org OpenID provider. + +**Architecture:** Local accounts (bcrypt) are primary auth; sessions carry `{user_id, org_id, role, email}`. A per-org OIDC resolver builds providers on demand from an `org_oidc` collection. Every scoped service function takes an `orgID` and filters on it; handlers derive `orgID` from the session (never from the client). A one-shot idempotent migration backfills existing data into a "Default" org. + +**Tech Stack:** Go (gin, mongo-driver v2, Redis sessions, `go-oidc`/`oauth2`, `golang.org/x/crypto/bcrypt`), Next.js 16 + react-query + Tailwind. + +## Global Constraints + +- **No tests this iteration.** Verify with `go build ./...`, `go vet ./...`, `npm run build`. +- **Org isolation is a security boundary:** handlers MUST derive `org_id` from the session via `auth.OrgID(c)`; never accept it in a request body/query. Every scoped Mongo query includes `"org_id": orgID` in its filter and on insert. +- Reuse `server/internal/services/crypto.go` (`encryptString`/`decryptString`) for the org OIDC client secret. +- bcrypt cost ≥ 12. Passwords never serialized to JSON (`json:"-"`). +- Module path `github.com/mrhid6/vantage`. Sessions live in Redis (`server/internal/auth/session.go`). +- Scoped collections: `servers`, `keys`, `assignments`, `secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `audit`. +- Frontend: `@/lib/api`, `@/components/ui`, Tailwind tokens, react-query. Follow `web/app/secrets/page.tsx` conventions. + +--- + +## Task 1: Org + user + org-OIDC models + +**Files:** +- Create: `server/internal/models/org.go` + +**Interfaces:** +- Produces: `models.Org`, `models.User`, `models.OrgOIDC`. + +- [ ] **Step 1: Write models** + +```go +package models + +import "time" + +type Org struct { + ID string `bson:"_id,omitempty" json:"-"` + OrgID string `bson:"org_id" json:"org_id"` + Name string `bson:"name" json:"name"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` +} + +type User struct { + ID string `bson:"_id,omitempty" json:"-"` + UserID string `bson:"user_id" json:"user_id"` + OrgID string `bson:"org_id" json:"org_id"` + Email string `bson:"email" json:"email"` + PasswordHash string `bson:"password_hash,omitempty" json:"-"` + Role string `bson:"role" json:"role"` // owner|admin|member + AuthSource string `bson:"auth_source" json:"auth_source"` // local|oidc + CreatedAt time.Time `bson:"created_at" json:"created_at"` + LastLogin *time.Time `bson:"last_login,omitempty" json:"last_login,omitempty"` +} + +type OrgOIDC struct { + ID string `bson:"_id,omitempty" json:"-"` + OrgID string `bson:"org_id" json:"org_id"` + Issuer string `bson:"issuer" json:"issuer"` + ClientID string `bson:"client_id" json:"client_id"` + ClientSecretEnc string `bson:"client_secret_enc" json:"-"` + RedirectURL string `bson:"redirect_url" json:"redirect_url"` + Enabled bool `bson:"enabled" json:"enabled"` + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` +} +``` + +- [ ] **Step 2: Verify build** + +Run: `cd server && go build ./...` +Expected: success. + +- [ ] **Step 3: Commit** + +```bash +git add server/internal/models/org.go +git commit -m "feat(models): org, user, org-oidc models" +``` + +--- + +## Task 2: Org + user services (bcrypt) + +**Files:** +- Create: `server/internal/services/orgs.go` +- Create: `server/internal/services/users.go` + +**Interfaces:** +- Produces: + - orgs: `EnsureAuthIndexes() error`, `CreateOrg(name string) (*models.Org, error)`, `GetOrg(orgID string) (*models.Org, error)`, `CountUsers() (int64, error)` + - users: `CreateUser(orgID, email, password, role string) (*models.User, error)`, `GetUserByEmail(email string) (*models.User, error)`, `GetUserByID(userID string) (*models.User, error)`, `VerifyPassword(u *models.User, password string) bool`, `ListUsers(orgID string) ([]models.User, error)`, `UpdateUserRole(orgID, userID, role string) error`, `DeleteUser(orgID, userID string) error`, `ProvisionOIDCUser(orgID, email string) (*models.User, error)`, `TouchLastLogin(userID string)`. + +- [ ] **Step 1: Write orgs.go** + +```go +package services + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/mrhid6/vantage/server/internal/db" + "github.com/mrhid6/vantage/server/internal/models" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +func authCtx() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), 10*time.Second) +} + +func EnsureAuthIndexes() error { + ctx, cancel := authCtx() + defer cancel() + if _, err := db.Col("users").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "email", Value: 1}}, Options: options.Index().SetUnique(true), + }); err != nil { + return err + } + if _, err := db.Col("orgs").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "org_id", Value: 1}}, Options: options.Index().SetUnique(true), + }); err != nil { + return err + } + _, err := db.Col("org_oidc").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "org_id", Value: 1}}, Options: options.Index().SetUnique(true), + }) + return err +} + +func CreateOrg(name string) (*models.Org, error) { + ctx, cancel := authCtx() + defer cancel() + o := models.Org{OrgID: uuid.New().String(), Name: name, CreatedAt: time.Now()} + if _, err := db.Col("orgs").InsertOne(ctx, o); err != nil { + return nil, err + } + return &o, nil +} + +func GetOrg(orgID string) (*models.Org, error) { + ctx, cancel := authCtx() + defer cancel() + var o models.Org + err := db.Col("orgs").FindOne(ctx, bson.M{"org_id": orgID}).Decode(&o) + if err == mongo.ErrNoDocuments { + return nil, fmt.Errorf("org not found") + } + return &o, err +} + +func CountUsers() (int64, error) { + ctx, cancel := authCtx() + defer cancel() + return db.Col("users").CountDocuments(ctx, bson.M{}) +} +``` + +- [ ] **Step 2: Write users.go** + +```go +package services + +import ( + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/mrhid6/vantage/server/internal/db" + "github.com/mrhid6/vantage/server/internal/models" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + "golang.org/x/crypto/bcrypt" +) + +func normalizeEmail(e string) string { return strings.ToLower(strings.TrimSpace(e)) } + +func CreateUser(orgID, email, password, role string) (*models.User, error) { + ctx, cancel := authCtx() + defer cancel() + u := models.User{ + UserID: uuid.New().String(), OrgID: orgID, Email: normalizeEmail(email), + Role: role, AuthSource: "local", CreatedAt: time.Now(), + } + if password != "" { + h, err := bcrypt.GenerateFromPassword([]byte(password), 12) + if err != nil { + return nil, err + } + u.PasswordHash = string(h) + } + if _, err := db.Col("users").InsertOne(ctx, u); err != nil { + return nil, err + } + return &u, nil +} + +func GetUserByEmail(email string) (*models.User, error) { + ctx, cancel := authCtx() + defer cancel() + var u models.User + err := db.Col("users").FindOne(ctx, bson.M{"email": normalizeEmail(email)}).Decode(&u) + if err == mongo.ErrNoDocuments { + return nil, fmt.Errorf("user not found") + } + return &u, err +} + +func GetUserByID(userID string) (*models.User, error) { + ctx, cancel := authCtx() + defer cancel() + var u models.User + err := db.Col("users").FindOne(ctx, bson.M{"user_id": userID}).Decode(&u) + if err == mongo.ErrNoDocuments { + return nil, fmt.Errorf("user not found") + } + return &u, err +} + +func VerifyPassword(u *models.User, password string) bool { + if u.PasswordHash == "" { + return false + } + return bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil +} + +func ListUsers(orgID string) ([]models.User, error) { + ctx, cancel := authCtx() + defer cancel() + cur, err := db.Col("users").Find(ctx, bson.M{"org_id": orgID}, + options.Find().SetSort(bson.D{{Key: "email", Value: 1}})) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + users := []models.User{} + if err := cur.All(ctx, &users); err != nil { + return nil, err + } + return users, nil +} + +func UpdateUserRole(orgID, userID, role string) error { + ctx, cancel := authCtx() + defer cancel() + _, err := db.Col("users").UpdateOne(ctx, bson.M{"org_id": orgID, "user_id": userID}, + bson.M{"$set": bson.M{"role": role}}) + return err +} + +func DeleteUser(orgID, userID string) error { + ctx, cancel := authCtx() + defer cancel() + _, err := db.Col("users").DeleteOne(ctx, bson.M{"org_id": orgID, "user_id": userID}) + return err +} + +func ProvisionOIDCUser(orgID, email string) (*models.User, error) { + if existing, err := GetUserByEmail(email); err == nil { + if existing.OrgID != orgID { + return nil, fmt.Errorf("email belongs to another organization") + } + return existing, nil + } + ctx, cancel := authCtx() + defer cancel() + u := models.User{ + UserID: uuid.New().String(), OrgID: orgID, Email: normalizeEmail(email), + Role: "member", AuthSource: "oidc", CreatedAt: time.Now(), + } + if _, err := db.Col("users").InsertOne(ctx, u); err != nil { + return nil, err + } + return &u, nil +} + +func TouchLastLogin(userID string) { + ctx, cancel := authCtx() + defer cancel() + now := time.Now() + _, _ = db.Col("users").UpdateOne(ctx, bson.M{"user_id": userID}, bson.M{"$set": bson.M{"last_login": now}}) +} +``` + +- [ ] **Step 3: Add bcrypt dependency** + +Run: `cd server && go get golang.org/x/crypto/bcrypt && go mod tidy` +Expected: module added. + +- [ ] **Step 4: Register indexes at startup** + +In `server/cmd/main.go`, call `services.EnsureAuthIndexes()` next to the other `Ensure*Indexes()` calls, with the same error handling. + +- [ ] **Step 5: Verify build** + +Run: `cd server && go build ./... && go vet ./...` +Expected: success. + +- [ ] **Step 6: Commit** + +```bash +git add server/internal/services/orgs.go server/internal/services/users.go server/cmd/main.go server/go.mod server/go.sum +git commit -m "feat(server): org and user services with bcrypt auth" +``` + +--- + +## Task 3: Session carries user/org/role + +**Files:** +- Modify: `server/internal/auth/session.go` +- Modify: `server/internal/auth/middleware.go` + +**Interfaces:** +- Produces: `Session{UserID, OrgID, Email, Role}`; `auth.OrgID(c) string`, `auth.UserID(c) string`, `auth.Role(c) string`; `auth.CreateSession(ctx, *Session) (token string, err error)` (or extend the existing creator). + +- [ ] **Step 1: Read the current Session struct** + +Open `server/internal/auth/session.go`, find the `Session` struct and `GetSession`/save functions. Extend `Session`: + +```go +type Session struct { + UserID string `json:"user_id"` + OrgID string `json:"org_id"` + Email string `json:"email"` + Role string `json:"role"` +} +``` + +Keep existing fields if any are still used; add these. Ensure the create/save path (currently used by the OIDC callback) accepts a full `*Session`. If the current signature is `SaveSession(ctx, token, email)`, add `CreateSession(ctx context.Context, s *Session) (string, error)` that generates a token (reuse `randomHex`), stores the JSON under `sessionPrefix+token` with `sessionTTL`, and returns the token. + +- [ ] **Step 2: Add context helpers to middleware.go** + +```go +func OrgID(c *gin.Context) string { + if s := GetSessionFromContext(c); s != nil { + return s.OrgID + } + return "" +} +func UserID(c *gin.Context) string { + if s := GetSessionFromContext(c); s != nil { + return s.UserID + } + return "" +} +func Role(c *gin.Context) string { + if s := GetSessionFromContext(c); s != nil { + return s.Role + } + return "" +} + +// RequireRole aborts unless the session role is in allowed. +func RequireRole(allowed ...string) gin.HandlerFunc { + return func(c *gin.Context) { + role := Role(c) + for _, a := range allowed { + if role == a { + c.Next() + return + } + } + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"}) + } +} +``` + +- [ ] **Step 3: Verify build** + +Run: `cd server && go build ./...` +Expected: may fail where the old session creator is called (OIDC). That's fixed in Task 5. If the failure is only in `oidc.go`, proceed; otherwise fix references in session.go. + +- [ ] **Step 4: Commit** + +```bash +git add server/internal/auth/session.go server/internal/auth/middleware.go +git commit -m "feat(auth): session carries user/org/role + role middleware" +``` + +--- + +## Task 4: Local auth + bootstrap handlers + +**Files:** +- Create: `server/internal/auth/local.go` + +**Interfaces:** +- Consumes: user/org services (T2), session (T3). +- Produces: gin handlers `HandleLocalLogin`, `HandleLogout` (may reuse existing), `HandleMe`, `HandleBootstrapStatus`, `HandleBootstrap`. + +- [ ] **Step 1: Write local.go** + +```go +package auth + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/server/internal/services" +) + +func setSessionCookie(c *gin.Context, token string) { + http.SetCookie(c.Writer, &http.Cookie{ + Name: sessionCookieName, Value: token, Path: "/", + HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, + Expires: time.Now().Add(sessionTTL), + }) +} + +func HandleBootstrapStatus(c *gin.Context) { + n, err := services.CountUsers() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"needs_setup": n == 0}) +} + +// HandleBootstrap creates the first org + owner. Only allowed when no users exist. +func HandleBootstrap(c *gin.Context) { + n, err := services.CountUsers() + if err != nil || n > 0 { + c.JSON(http.StatusForbidden, gin.H{"error": "setup already completed"}) + return + } + var body struct { + OrgName string `json:"org_name" binding:"required"` + Email string `json:"email" binding:"required"` + Password string `json:"password" binding:"required"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + org, err := services.CreateOrg(body.OrgName) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + user, err := services.CreateUser(org.OrgID, body.Email, body.Password, "owner") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + token, err := CreateSession(c.Request.Context(), &Session{ + UserID: user.UserID, OrgID: org.OrgID, Email: user.Email, Role: user.Role, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + setSessionCookie(c, token) + c.JSON(http.StatusCreated, gin.H{"org": org, "user": user}) +} + +func HandleLocalLogin(c *gin.Context) { + var body struct { + Email string `json:"email" binding:"required"` + Password string `json:"password" binding:"required"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + u, err := services.GetUserByEmail(body.Email) + if err != nil || !services.VerifyPassword(u, body.Password) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + return + } + token, err := CreateSession(c.Request.Context(), &Session{ + UserID: u.UserID, OrgID: u.OrgID, Email: u.Email, Role: u.Role, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + services.TouchLastLogin(u.UserID) + setSessionCookie(c, token) + c.JSON(http.StatusOK, gin.H{"user": u}) +} + +func HandleMe(c *gin.Context) { + s := GetSessionFromContext(c) + if s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"}) + return + } + org, _ := services.GetOrg(s.OrgID) + c.JSON(http.StatusOK, gin.H{ + "user": gin.H{"user_id": s.UserID, "email": s.Email, "role": s.Role}, + "org": org, + }) +} +``` + +Note: if `HandleLogout` already exists in `oidc.go` and clears the cookie/session generically, reuse it; otherwise add one clearing `sessionCookieName` and deleting the Redis key. + +- [ ] **Step 2: Verify build** + +Run: `cd server && go build ./...` +Expected: success (aside from any not-yet-updated OIDC references from T3, addressed in T5). + +- [ ] **Step 3: Commit** + +```bash +git add server/internal/auth/local.go +git commit -m "feat(auth): local login, bootstrap, and me handlers" +``` + +--- + +## Task 5: Per-org OIDC resolver (replace global) + +**Files:** +- Modify: `server/internal/auth/oidc.go` +- Create: `server/internal/services/org_oidc.go` + +**Interfaces:** +- Consumes: `models.OrgOIDC`, crypto (`encryptString`/`decryptString`), session (T3), `ProvisionOIDCUser` (T2). +- Produces: `services.GetOrgOIDC(orgID) (*models.OrgOIDC, error)`, `services.SaveOrgOIDC(orgID string, in models.OrgOIDC, secret string) error`; handlers `HandleOIDCStart`, `HandleOIDCCallback`. + +- [ ] **Step 1: Write org_oidc.go service** + +```go +package services + +import ( + "context" + "fmt" + "time" + + "github.com/mrhid6/vantage/server/internal/db" + "github.com/mrhid6/vantage/server/internal/models" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +func GetOrgOIDC(orgID string) (*models.OrgOIDC, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var o models.OrgOIDC + err := db.Col("org_oidc").FindOne(ctx, bson.M{"org_id": orgID}).Decode(&o) + if err == mongo.ErrNoDocuments { + return nil, fmt.Errorf("no oidc config") + } + return &o, err +} + +// GetOrgOIDCSecret returns the decrypted client secret. +func GetOrgOIDCSecret(o *models.OrgOIDC) (string, error) { + return decryptString(o.ClientSecretEnc) +} + +// SaveOrgOIDC upserts config; if secret != "" it is encrypted and stored. +func SaveOrgOIDC(orgID string, in models.OrgOIDC, secret string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + set := bson.M{ + "issuer": in.Issuer, "client_id": in.ClientID, "redirect_url": in.RedirectURL, + "enabled": in.Enabled, "updated_at": time.Now(), + } + if secret != "" { + enc, err := encryptString(secret) + if err != nil { + return err + } + set["client_secret_enc"] = enc + } + _, err := db.Col("org_oidc").UpdateOne(ctx, bson.M{"org_id": orgID}, + bson.M{"$set": set}, options.UpdateOne().SetUpsert(true)) + return err +} +``` + +- [ ] **Step 2: Rewrite oidc.go around the per-org resolver** + +Replace the env-based `InitOIDC`/global `oidcProvider`/`oauth2Cfg`/`authEnabled` with an on-demand, per-org resolver. Keep `randomHex`, `SaveState`/state handling. Reference the current callback logic for the token-exchange + claims parsing, but bind to the org from state. + +```go +package auth + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sync" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/server/internal/services" + "golang.org/x/oauth2" +) + +type orgProvider struct { + provider *oidc.Provider + cfg *oauth2.Config +} + +var ( + provCache = map[string]*orgProvider{} + provMu sync.Mutex +) + +func providerForOrg(ctx context.Context, orgID string) (*orgProvider, error) { + cfg, err := services.GetOrgOIDC(orgID) + if err != nil || !cfg.Enabled { + return nil, fmt.Errorf("org sso not configured") + } + provMu.Lock() + defer provMu.Unlock() + if p, ok := provCache[orgID]; ok { + return p, nil + } + provider, err := oidc.NewProvider(ctx, cfg.Issuer) + if err != nil { + return nil, err + } + secret, _ := services.GetOrgOIDCSecret(cfg) + op := &orgProvider{ + provider: provider, + cfg: &oauth2.Config{ + ClientID: cfg.ClientID, ClientSecret: secret, RedirectURL: cfg.RedirectURL, + Endpoint: provider.Endpoint(), Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + }, + } + provCache[orgID] = op + return op, nil +} + +// InvalidateOrgProvider drops the cache after config changes. +func InvalidateOrgProvider(orgID string) { + provMu.Lock() + delete(provCache, orgID) + provMu.Unlock() +} + +// HandleOIDCStart resolves the org, stores state carrying org_id, redirects. +func HandleOIDCStart(c *gin.Context) { + orgID := c.Query("org") + if orgID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "org required"}) + return + } + op, err := providerForOrg(c.Request.Context(), orgID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + state, _ := randomHex(16) + // State value encodes org so the callback can rebuild the provider. + if err := SaveStateValue(c.Request.Context(), state, orgID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "state save failed"}) + return + } + c.Redirect(http.StatusFound, op.cfg.AuthCodeURL(state)) +} + +func HandleOIDCCallback(c *gin.Context) { + ctx := c.Request.Context() + state := c.Query("state") + orgID, err := ConsumeStateValue(ctx, state) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state"}) + return + } + op, err := providerForOrg(ctx, orgID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + oauth2Token, err := op.cfg.Exchange(ctx, c.Query("code")) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "token exchange failed"}) + return + } + rawID, _ := oauth2Token.Extra("id_token").(string) + idToken, err := op.provider.Verifier(&oidc.Config{ClientID: op.cfg.ClientID}).Verify(ctx, rawID) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "id token verify failed"}) + return + } + var claims struct { + Email string `json:"email"` + } + if err := idToken.Claims(&claims); err != nil || claims.Email == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "no email claim"}) + return + } + user, err := services.ProvisionOIDCUser(orgID, claims.Email) + if err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + token, err := CreateSession(ctx, &Session{UserID: user.UserID, OrgID: user.OrgID, Email: user.Email, Role: user.Role}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + services.TouchLastLogin(user.UserID) + setSessionCookie(c, token) + c.Redirect(http.StatusFound, "/") + _ = json.Marshal // keep import if unused elsewhere; remove if lint complains +} +``` + +- [ ] **Step 3: Add state-with-value helpers** + +The current code has `SaveState(ctx, state)`. Add value-carrying variants in `session.go` (Redis, `statePrefix+state` → orgID, short TTL ~10 min): + +```go +func SaveStateValue(ctx context.Context, state, value string) error { + return rdb.Set(ctx, statePrefix+state, value, 10*time.Minute).Err() +} +func ConsumeStateValue(ctx context.Context, state string) (string, error) { + v, err := rdb.GetDel(ctx, statePrefix+state).Result() + if err != nil { + return "", err + } + return v, nil +} +``` + +Match the actual Redis client variable name used in `session.go` (shown as `rdb`). + +- [ ] **Step 4: Remove global-auth bypass** + +In `middleware.go`, delete the `if !authEnabled { c.Next(); return }` bypass so auth is always enforced (login/register/bootstrap routes stay outside the protected group — Task 7). Remove references to the deleted `authEnabled`/`InitOIDC` from `server/cmd/main.go` (drop the `InitOIDC` call). + +- [ ] **Step 5: Verify build** + +Run: `cd server && go build ./... && go vet ./...` +Expected: success. Resolve any leftover references to removed globals. + +- [ ] **Step 6: Commit** + +```bash +git add server/internal/auth/oidc.go server/internal/auth/session.go server/internal/auth/middleware.go server/internal/services/org_oidc.go server/cmd/main.go +git commit -m "feat(auth): per-org OIDC resolver, remove global Authentik" +``` + +--- + +## Task 6: Org-scope existing services + +**Files:** +- Modify: `server/internal/services/servers.go`, `keys.go`, `secrets.go`, `sync.go`, `audit.go`, `workflows.go`, `workflow_runner.go` (workflows only if the Workflows plan has already been executed — otherwise skip those two and note it). + +**Interfaces:** +- Produces: each scoped read/list/create/get/delete function gains a leading `orgID string` param and includes `"org_id": orgID` in filters and inserts. + +This is the isolation boundary. Work one collection at a time. + +- [ ] **Step 1: Scope `servers.go`** + +Add `org_id` to inserts in `CreateServer` (accept `orgID string`), and `"org_id": orgID` to filters in `ListServers`, `GetServer`, `DeleteServer`, `GetAssignmentsWithKeysForServer`, etc. Example transform: + +```go +func ListServers(orgID string) ([]models.Server, error) { + // ... + cur, err := db.Col("servers").Find(ctx, bson.M{"org_id": orgID}, /* sort */) + // ... +} +``` + +For agent-facing lookups keyed by `server_id` (e.g. `ValidateAgentToken`, `UpdateServerLastSeen`): these do NOT get an `orgID` param — they resolve by `server_id` alone. Instead, ensure `CreateServer` stamps `org_id` so the server record carries it; downstream org queries then work. + +- [ ] **Step 2: Scope `keys.go`, `secrets.go`, `audit.go`, `sync.go`** + +Same treatment. `sync.go` builds desired state per server: it can read the server's own `org_id` (from the server doc) and query keys/assignments within that org. `LogEvent` gains an `orgID` first param and stamps `org_id` on the audit doc; `ListAuditEvents(orgID, limit)` filters by it. + +- [ ] **Step 3: Scope workflow services (only if Workflows plan already merged)** + +If `services/workflows.go` exists, add `orgID` to all list/get/create/update/delete and to run queries. If not yet implemented, add a note in the Workflows plan to include `org_id` when it is built, and skip here. + +- [ ] **Step 4: Verify build** + +Run: `cd server && go build ./...` +Expected: FAILS at call sites in `api/` (handlers not yet passing orgID). That is expected and fixed in Task 7. Confirm the only errors are missing-argument at handler call sites. + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/services/ +git commit -m "feat(server): org-scope domain services" +``` + +--- + +## Task 7: Wire handlers + routes to org + auth + +**Files:** +- Modify: `server/internal/api/handlers.go` (+ `secrets.go`, `console.go`, `workflows.go` handlers as needed) + +**Interfaces:** +- Consumes: `auth.OrgID(c)`, scoped services (T6), local/oidc/bootstrap handlers (T4, T5). + +- [ ] **Step 1: Update route registration** + +In `RegisterRoutes`, replace the auth endpoints block: + +```go + // Unauthenticated auth endpoints + r.GET("/auth/bootstrap-status", auth.HandleBootstrapStatus) + r.POST("/auth/bootstrap", auth.HandleBootstrap) + r.POST("/auth/login", auth.HandleLocalLogin) + r.GET("/auth/logout", auth.HandleLogout) + r.GET("/auth/oidc/start", auth.HandleOIDCStart) + r.GET("/auth/oidc/callback", auth.HandleOIDCCallback) +``` + +Inside the session-protected `apiGroup`, add org-admin routes: + +```go + orgAdmin := apiGroup.Group("/org") + orgAdmin.GET("/users", listOrgUsers) + orgAdmin.POST("/users", auth.RequireRole("owner", "admin"), createOrgUser) + orgAdmin.PUT("/users/:id/role", auth.RequireRole("owner", "admin"), updateOrgUserRole) + orgAdmin.DELETE("/users/:id", auth.RequireRole("owner", "admin"), deleteOrgUser) + orgAdmin.GET("/oidc", auth.RequireRole("owner", "admin"), getOrgOIDC) + orgAdmin.PUT("/oidc", auth.RequireRole("owner", "admin"), putOrgOIDC) + apiGroup.GET("/me", auth.HandleMe) +``` + +- [ ] **Step 2: Pass `auth.OrgID(c)` into every scoped service call** + +Update each existing handler (`listServers`, `createServer`, `getServer`, `deleteServer`, `listKeys`, `createKey`, secrets handlers, `listAuditEvents`, etc.) to pass `auth.OrgID(c)` as the new first argument. Also update every `services.LogEvent(...)` call to pass `auth.OrgID(c)` first. Example: + +```go +func listServers(c *gin.Context) { + servers, err := services.ListServers(auth.OrgID(c)) + // ... +} +``` + +- [ ] **Step 3: Add org-user + org-oidc handlers** + +Create `server/internal/api/org.go`: + +```go +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/server/internal/auth" + "github.com/mrhid6/vantage/server/internal/models" + "github.com/mrhid6/vantage/server/internal/services" +) + +func listOrgUsers(c *gin.Context) { + users, err := services.ListUsers(auth.OrgID(c)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, users) +} + +func createOrgUser(c *gin.Context) { + var body struct { + Email string `json:"email" binding:"required"` + Password string `json:"password" binding:"required"` + Role string `json:"role"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if body.Role == "" { + body.Role = "member" + } + u, err := services.CreateUser(auth.OrgID(c), body.Email, body.Password, body.Role) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + services.LogEvent(auth.OrgID(c), "org.user_created", actorFromCtx(c), "", u.UserID, "user "+u.Email+" created") + c.JSON(http.StatusCreated, u) +} + +func updateOrgUserRole(c *gin.Context) { + var body struct{ Role string `json:"role" binding:"required"` } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := services.UpdateUserRole(auth.OrgID(c), c.Param("id"), body.Role); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"updated": true}) +} + +func deleteOrgUser(c *gin.Context) { + if err := services.DeleteUser(auth.OrgID(c), c.Param("id")); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +func getOrgOIDC(c *gin.Context) { + cfg, err := services.GetOrgOIDC(auth.OrgID(c)) + if err != nil { + c.JSON(http.StatusOK, gin.H{}) // no config yet + return + } + c.JSON(http.StatusOK, cfg) +} + +func putOrgOIDC(c *gin.Context) { + var body struct { + Issuer string `json:"issuer"` + ClientID string `json:"client_id"` + Secret string `json:"client_secret"` + RedirectURL string `json:"redirect_url"` + Enabled bool `json:"enabled"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + orgID := auth.OrgID(c) + if err := services.SaveOrgOIDC(orgID, models.OrgOIDC{ + Issuer: body.Issuer, ClientID: body.ClientID, RedirectURL: body.RedirectURL, Enabled: body.Enabled, + }, body.Secret); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + auth.InvalidateOrgProvider(orgID) + c.JSON(http.StatusOK, gin.H{"saved": true}) +} +``` + +- [ ] **Step 4: Verify build** + +Run: `cd server && go build ./... && go vet ./...` +Expected: success once every scoped call passes `auth.OrgID(c)`. Fix remaining arity errors. + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/api/ +git commit -m "feat(api): org-scoped handlers, org admin routes, auth wiring" +``` + +--- + +## Task 8: One-shot migration (backfill org_id) + +**Files:** +- Create: `server/internal/services/migrate.go` + +**Interfaces:** +- Produces: `MigrateToOrgs() error` — idempotent; run once at startup. + +- [ ] **Step 1: Write the migration** + +```go +package services + +import ( + "context" + "time" + + "github.com/mrhid6/vantage/server/internal/db" + "go.mongodb.org/mongo-driver/v2/bson" +) + +var scopedCollections = []string{ + "servers", "keys", "assignments", "secrets", + "workflows", "workflow_steps", "workflow_runs", "audit", +} + +// MigrateToOrgs backfills a default org onto legacy documents lacking org_id. +// Idempotent via a marker in the `migrations` collection. +func MigrateToOrgs() error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + marker := db.Col("migrations").FindOne(ctx, bson.M{"_id": "orgs-backfill"}) + if marker.Err() == nil { + return nil // already done + } + + // Only create a default org if there is legacy data but no org yet. + orgCount, _ := db.Col("orgs").CountDocuments(ctx, bson.M{}) + if orgCount > 0 { + _, _ = db.Col("migrations").InsertOne(ctx, bson.M{"_id": "orgs-backfill", "at": time.Now()}) + return nil + } + + hasLegacy := false + for _, col := range scopedCollections { + n, _ := db.Col(col).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": false}}) + if n > 0 { + hasLegacy = true + break + } + } + if !hasLegacy { + _, _ = db.Col("migrations").InsertOne(ctx, bson.M{"_id": "orgs-backfill", "at": time.Now()}) + return nil + } + + org, err := CreateOrg("Default") + if err != nil { + return err + } + for _, col := range scopedCollections { + if _, err := db.Col(col).UpdateMany(ctx, + bson.M{"org_id": bson.M{"$exists": false}}, + bson.M{"$set": bson.M{"org_id": org.OrgID}}); err != nil { + return err + } + } + _, err = db.Col("migrations").InsertOne(ctx, bson.M{"_id": "orgs-backfill", "org_id": org.OrgID, "at": time.Now()}) + return err +} +``` + +- [ ] **Step 2: Call at startup** + +In `server/cmd/main.go`, after DB connect + `EnsureAuthIndexes()`, call `services.MigrateToOrgs()` with error logging. + +- [ ] **Step 3: Verify build** + +Run: `cd server && go build ./... && go vet ./...` +Expected: success. + +- [ ] **Step 4: Commit** + +```bash +git add server/internal/services/migrate.go server/cmd/main.go +git commit -m "feat(server): idempotent org_id backfill migration" +``` + +--- + +## Task 9: Frontend — login, setup, auth gating + +**Files:** +- Create: `web/app/login/page.tsx` +- Create: `web/app/setup/page.tsx` +- Modify: `web/lib/api.ts` (auth methods + types) +- Modify: `web/components/AuthProvider.tsx` (gate on `/auth/me`, redirect to `/login` or `/setup`) + +**Interfaces:** +- Consumes: `api.bootstrapStatus`, `api.bootstrap`, `api.login`, `api.me`, `api.oidcStartUrl`. + +- [ ] **Step 1: Add auth API methods/types** + +In `web/lib/api.ts`: + +```ts +export interface Me { user: { user_id: string; email: string; role: string }; org: { org_id: string; name: string } | null; } + +// add to api object: + bootstrapStatus: () => req<{ needs_setup: boolean }>("/auth/bootstrap-status"), + bootstrap: (org_name: string, email: string, password: string) => + req("/auth/bootstrap", { method: "POST", body: JSON.stringify({ org_name, email, password }) }), + login: (email: string, password: string) => + req("/auth/login", { method: "POST", body: JSON.stringify({ email, password }) }), + me: () => req("/api/me"), +``` + +- [ ] **Step 2: Setup page** + +`web/app/setup/page.tsx` — client form (org name, email, password) → `api.bootstrap` → on success `router.push("/")`. Use `inputClass` + `Button`/`Card` styling from secrets page. + +- [ ] **Step 3: Login page** + +`web/app/login/page.tsx` — email/password form → `api.login` → `router.push("/")`. Plus an "Organization SSO" section: an org-id input and a "Sign in with SSO" button that navigates to `/auth/oidc/start?org=`. On mount, call `api.bootstrapStatus()`; if `needs_setup`, redirect to `/setup`. + +- [ ] **Step 4: Gate the app** + +In `web/components/AuthProvider.tsx`, query `api.me()`; on 401 redirect to `/login` (or `/setup` when bootstrap needed). Show a loader while resolving. Leave `/login` and `/setup` routes ungated. Show current org name + user email in the sidebar/header (pass via context). + +- [ ] **Step 5: Verify build** + +Run: `cd web && npm run build` +Expected: success. + +- [ ] **Step 6: Commit** + +```bash +git add web/app/login/page.tsx web/app/setup/page.tsx web/lib/api.ts web/components/AuthProvider.tsx +git commit -m "feat(web): login, first-run setup, and auth gating" +``` + +--- + +## Task 10: Frontend — org settings (members + OIDC) + +**Files:** +- Create: `web/app/settings/org/page.tsx` + +**Interfaces:** +- Consumes: `api.listOrgUsers`, `api.createOrgUser`, `api.updateOrgUserRole`, `api.deleteOrgUser`, `api.getOrgOIDC`, `api.putOrgOIDC` (add these to `web/lib/api.ts` following the same pattern). + +- [ ] **Step 1: Add org API methods** + +```ts + listOrgUsers: () => req("/api/org/users"), + createOrgUser: (email: string, password: string, role: string) => + req("/api/org/users", { method: "POST", body: JSON.stringify({ email, password, role }) }), + updateOrgUserRole: (id: string, role: string) => + req(`/api/org/users/${id}/role`, { method: "PUT", body: JSON.stringify({ role }) }), + deleteOrgUser: (id: string) => req(`/api/org/users/${id}`, { method: "DELETE" }), + getOrgOIDC: () => req("/api/org/oidc"), + putOrgOIDC: (cfg: { issuer: string; client_id: string; client_secret?: string; redirect_url: string; enabled: boolean }) => + req("/api/org/oidc", { method: "PUT", body: JSON.stringify(cfg) }), +``` + +- [ ] **Step 2: Build the page** + +Two cards: **Members** (table of users with role select + remove, an "Add user" inline form: email/password/role) and **Organization SSO** (form: issuer, client_id, client_secret, redirect_url, enabled toggle → `api.putOrgOIDC`). Admin-only actions; hide mutations if `me.user.role === "member"`. Reuse secrets-page styling. + +- [ ] **Step 3: Verify build** + +Run: `cd web && npm run build` +Expected: success. + +- [ ] **Step 4: Commit** + +```bash +git add web/app/settings/org/page.tsx web/lib/api.ts +git commit -m "feat(web): org settings — members and SSO config" +``` + +--- + +## Task 11: End-to-end manual verification + +- [ ] **Step 1: Build all** + +Run: `cd server && go build ./... && cd ../web && npm run build` +Expected: success. + +- [ ] **Step 2: Smoke (if environment available)** + +1. Fresh DB → visiting the app redirects to `/setup`; create org + owner → land logged in. +2. Existing DB with legacy servers/keys → migration stamps them into "Default" org; owner (created via setup) sees them. +3. Create a `member` user in org settings; log in as them in a private window; confirm they see the same org's resources and cannot access org-admin mutations (403). +4. Configure org OIDC (issuer/client id/secret/redirect, enabled); from `/login` enter the org id, click SSO; complete provider login; confirm a user is provisioned in that org and logged in. +5. Confirm a user cannot see another org's data (create a second org via a second setup only possible on empty DB — verify at the query level or by inspecting that all queries carry `org_id`). + +- [ ] **Step 3: Commit fixes** + +```bash +git add -A +git commit -m "fix: saas auth/org verification fixes" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** §3 models → T1; §4 flows (local login/bootstrap/me → T4; org user mgmt → T7; per-org OIDC → T5) ; §5 scoping → T6/T7 (`auth.OrgID`, `RequireRole`); §6 remove Authentik → T5 step 4; §7 migration → T8; §8 frontend → T9/T10; §9 security (bcrypt T2, secret encryption T5, org derived from session T7, state binds org T5). Tests omitted per Global Constraints. +- **Isolation invariant:** every scoped handler passes `auth.OrgID(c)`; agent-facing lookups resolve by `server_id` and rely on `CreateServer` stamping `org_id`. +- **Ordering caveat:** Task 6 intentionally leaves the build broken at API call sites until Task 7 — the two must land together (or as one review unit) for a green build. Workflows services are scoped only if that plan already merged (T6 step 3). +- **Follow-ups (out of scope):** billing/plan limits, email-based invites, org switching, SAML/SCIM, second-org creation UX (currently only via empty-DB bootstrap). diff --git a/docs/superpowers/specs/2026-07-20-fleet-inventory-design.md b/docs/superpowers/specs/2026-07-20-fleet-inventory-design.md new file mode 100644 index 0000000..b449c7a --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-fleet-inventory-design.md @@ -0,0 +1,142 @@ +# Fleet Inventory — Design + +**Date:** 2026-07-20 +**Status:** Approved (design) — ready for implementation planning +**Scope:** Fleet Inventory only. Server Workflows and SaaS/auth are separate sub-projects. + +--- + +## 1. Summary + +Each agent collects hardware/OS inventory about its host and reports it to the server, which stores the latest snapshot per server and surfaces it in the UI. Two cadences: + +- **Metrics (near-real-time):** CPU load/usage, RAM used/total, swap used/total — every **30s** (aligned with existing poll rhythm). +- **Static inventory (slow):** disks, partitions and their usage, CPU model/cores, total RAM, OS details — every **15 min**. + +Transport: a **new unary gRPC `ReportInventory` RPC** (mirrors the existing `ReportUpdates` pattern). No streaming. + +--- + +## 2. Locked decisions + +| Topic | Decision | +|-------|----------| +| Transport | New `ReportInventory` unary RPC. | +| Cadence | Metrics every 30s; static inventory every 15 min. One RPC carries both, but static fields are only populated on the 15-min tick (empty/omitted otherwise → server keeps prior static snapshot). | +| Storage | Latest snapshot embedded on the `servers` document (`inventory` sub-doc). No history/time-series in v1. | +| Collection | Pure-Go where practical (`/proc`, `gopsutil`-style). Agent already runs as root. | +| Platform | Linux primary; Windows agent populates what it can, leaves the rest empty. | + +--- + +## 3. Data model + +Add an `Inventory` sub-document to the existing `Server` model (`server/internal/models/server.go`): + +```go +type CPUInfo struct { + Model string `bson:"model,omitempty" json:"model,omitempty"` + Cores int `bson:"cores,omitempty" json:"cores,omitempty"` + UsagePct float64 `bson:"usage_pct" json:"usage_pct"` // metrics tick + Load1 float64 `bson:"load1,omitempty" json:"load1,omitempty"` +} + +type MemInfo struct { + TotalBytes uint64 `bson:"total_bytes" json:"total_bytes"` + UsedBytes uint64 `bson:"used_bytes" json:"used_bytes"` // metrics tick +} + +type Partition struct { + Device string `bson:"device" json:"device"` + Mountpoint string `bson:"mountpoint" json:"mountpoint"` + Fstype string `bson:"fstype,omitempty" json:"fstype,omitempty"` + TotalBytes uint64 `bson:"total_bytes" json:"total_bytes"` + UsedBytes uint64 `bson:"used_bytes" json:"used_bytes"` +} + +type Inventory struct { + CPU CPUInfo `bson:"cpu" json:"cpu"` + Memory MemInfo `bson:"memory" json:"memory"` + SwapTotalBytes uint64 `bson:"swap_total_bytes" json:"swap_total_bytes"` + SwapUsedBytes uint64 `bson:"swap_used_bytes" json:"swap_used_bytes"` + Partitions []Partition `bson:"partitions,omitempty" json:"partitions,omitempty"` + Kernel string `bson:"kernel,omitempty" json:"kernel,omitempty"` + MetricsAt *time.Time `bson:"metrics_at,omitempty" json:"metrics_at,omitempty"` + StaticAt *time.Time `bson:"static_at,omitempty" json:"static_at,omitempty"` +} +``` + +Add `Inventory *Inventory` field to `Server`. + +Server-side update rules: +- Metrics fields (`cpu.usage_pct`, `cpu.load1`, `memory.used_bytes`, swap used) always updated + `metrics_at`. +- Static fields (`cpu.model/cores`, `memory.total_bytes`, `partitions`, `kernel`, swap total) updated only when the report includes them (non-zero/non-empty) + `static_at`. + +--- + +## 4. gRPC protocol (`proto/vantage/v1/vantage.proto` + both `pb.go` files) + +```protobuf +rpc ReportInventory(InventoryReport) returns (InventoryReportResponse); + +message InventoryReport { + string server_id = 1; + string agent_token = 2; + bool include_static = 3; // true on the 15-min tick + CPUReport cpu = 4; + MemReport memory = 5; + uint64 swap_total = 6; + uint64 swap_used = 7; + repeated PartitionReport partitions = 8; // only when include_static + string kernel = 9; // only when include_static +} +message CPUReport { string model = 1; int32 cores = 2; double usage_pct = 3; double load1 = 4; } +message MemReport { uint64 total_bytes = 1; uint64 used_bytes = 2; } +message PartitionReport { string device = 1; string mountpoint = 2; string fstype = 3; uint64 total_bytes = 4; uint64 used_bytes = 5; } +message InventoryReportResponse {} +``` + +Hand-written JSON-codec structs added to `server/internal/grpc/pb/vantage.pb.go` and `agent/internal/grpc/pb/vantage.pb.go`, plus the RPC method wiring (service interface, client method, handler registration) mirroring `ReportUpdates`. + +--- + +## 5. Agent collection (`agent/internal/inventory/`) + +- `Collect(includeStatic bool) *pb.InventoryReport` — reads: + - CPU usage: sample `/proc/stat` delta; load from `/proc/loadavg`; model/cores from `/proc/cpuinfo` (static). + - Memory/swap: `/proc/meminfo`. + - Partitions: `/proc/mounts` filtered to real filesystems + `statfs` for total/used (static). + - Kernel: `uname` / `/proc/version` (static). + - Windows: best-effort via `wmic`/PS or leave empty. +- Scheduler in the agent main loop: a 30s ticker calls `Collect(false)` and `ReportInventory`; every 30th tick (15 min) calls `Collect(true)`. +- Reuse existing gRPC client; add `Client.ReportInventory(...)` like `ReportUpdates`. + +Prefer implementing the `/proc` readers directly (no new heavy deps) unless a `gopsutil` dependency is already vendored. + +--- + +## 6. Server handler + service + +- gRPC handler `ReportInventory` in `server/internal/grpc/server.go`: validate agent token (`ValidateAgentToken`), then call `services.StoreInventory(serverID, report)`. +- `services.StoreInventory` (in `server/internal/services/inventory.go`): builds the `$set` per the update rules in §3 and `UpdateOne` on `servers`. + +--- + +## 7. Frontend + +Surface inventory on the existing server detail page (`web/app/servers/[id]/page.tsx`) — add an "Inventory" panel: +- CPU usage gauge + model/cores, load. +- RAM used/total bar, swap bar. +- Partitions table: device, mount, fstype, used/total with a usage bar. +- "Updated Xs ago" from `metrics_at`/`static_at`. + +Optionally add compact CPU/RAM badges to the servers list (`web/app/servers/page.tsx`). Reuse `@/components/ui` + Tailwind tokens. Poll the server detail query while the page is open (react-query `refetchInterval` ~30s) so metrics stay fresh. + +--- + +## 8. Out of scope + +- Time-series history / graphs (only latest snapshot stored). +- Alerting thresholds on usage (settings/alerts is a separate concern). +- Per-process / network / GPU inventory. +- Tests (skipped, consistent with the Workflows iteration). 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 new file mode 100644 index 0000000..8c774b8 --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-saas-auth-orgs-design.md @@ -0,0 +1,142 @@ +# SaaS: Auth + Organizations — Design + +**Date:** 2026-07-20 +**Status:** Approved (design) — ready for implementation planning +**Scope:** Local auth + organizations + per-org OIDC, and org-scoping of existing data. Billing/plan-limits explicitly deferred. Fleet Inventory and Server Workflows are separate sub-projects. + +--- + +## 1. Summary + +Turn Vantage from a single-admin, single global-OIDC tool into a multi-tenant app: + +1. **Replace** the global Authentik/env-based OIDC with **local email/password accounts** as the primary login. +2. **Organizations** — every user belongs to an org; every domain object (servers, keys, secrets, assignments, workflows, steps, runs, audit) carries an `org_id` and all queries are scoped to the caller's org. +3. **Per-org OpenID** — an org admin can configure their own OIDC provider (issuer/client id/secret); users in that org can then sign in through it. + +No billing, no seat/server limits this iteration (schema leaves room). + +--- + +## 2. Locked decisions + +| Topic | Decision | +|-------|----------| +| Primary auth | Local email + password (bcrypt). Replaces global Authentik. | +| Org SSO | Per-org OIDC provider, configured by org admin, resolved dynamically at login. | +| Isolation | `org_id` on every collection; every service query filtered by org. Enforced in the request layer via session→org. | +| Roles | `owner`, `admin`, `member` (v1: owner/admin can manage users + org OIDC + all resources; member can use resources). Keep minimal. | +| Bootstrapping | First-run creates the initial org + owner account (setup flow) when no users exist. | +| Sessions | Keep existing Redis session store; session now carries `user_id`, `org_id`, `role`, `email`. | +| Agent auth | Unchanged (per-server agent tokens). Servers gain `org_id`; agent RPCs resolve org from the server record. | + +--- + +## 3. Data model + +### `orgs` +```json +{ "_id":"ObjectId", "org_id":"uuid", "name":"Acme", "created_at":"ISODate" } +``` + +### `users` +```json +{ + "_id":"ObjectId", "user_id":"uuid", "org_id":"uuid", + "email":"a@b.com", "password_hash":"bcrypt...", "role":"owner|admin|member", + "auth_source":"local|oidc", "created_at":"ISODate", "last_login":"ISODate|null" +} +``` +Unique index on `email` (global — email identifies the account and its org). + +### `org_oidc` (per-org provider config) +```json +{ + "_id":"ObjectId", "org_id":"uuid", + "issuer":"https://id.acme.com", "client_id":"...", + "client_secret_enc":"AES...", // encrypted with existing crypto.go + "redirect_url":"https://vantage.../auth/oidc/callback", + "enabled": true, "updated_at":"ISODate" +} +``` + +### Existing collections — add `org_id` +`servers`, `keys`, `assignments`, `secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `audit` each gain `org_id string`. A **migration** backfills all existing documents into a default org (see §7). + +--- + +## 4. Auth flows + +### Local +- `POST /auth/register` — only allowed during first-run bootstrap (creates org + owner) OR by an org admin inviting a user (see below). Not open self-serve. +- `POST /auth/login` — email + password → verify bcrypt → create session with `{user_id, org_id, role, email}`. +- `POST /auth/logout` — destroy session. +- `GET /auth/me` — returns current user + org. + +### Org-admin user management +- `GET /api/org/users` / `POST /api/org/users` (create local user in caller's org) / `PUT /api/org/users/:id/role` / `DELETE /api/org/users/:id`. + +### Per-org OIDC +- `GET/PUT /api/org/oidc` — read/save the caller org's provider config (admin only). Secret stored encrypted. +- `GET /auth/oidc/start?org=` — look up org's `org_oidc`, build the OIDC provider on demand (cache per org), redirect to authorize. +- `GET /auth/oidc/callback` — exchange code, match/provision the user by email **within that org**, create session. + - If the email exists in the org → log in. If not → provision a `member` with `auth_source=oidc` (org admin can promote). Reject if email belongs to a different org. + +### First-run bootstrap +- `GET /auth/bootstrap-status` → `{ needs_setup: bool }` (true when `users` is empty). +- Setup page collects org name + owner email/password → creates org + owner → session. + +--- + +## 5. Request scoping + +- `auth.Middleware` already loads the session; extend `Session` to include `OrgID`, `UserID`, `Role`. Add helper `auth.OrgID(c) string`. +- **Every service function that reads/writes a scoped collection takes an `orgID` argument** and adds `"org_id": orgID` to its filter and on insert. Handlers pass `auth.OrgID(c)`. +- Add a `requireRole(role)` gin middleware for admin-only routes (org user mgmt, org OIDC). +- Agent-facing gRPC: resolve `org_id` from the `servers` record (already tied to `server_id`); inventory/keys/sync operate on that org implicitly. + +--- + +## 6. Removing global Authentik + +- Delete/retire env-driven `InitOIDC` global provider (`OIDC_ISSUER` etc.). Keep the `go-oidc`/`oauth2` machinery but move it behind the per-org resolver. +- `authEnabled` global replaced by "auth always on" (there is always local auth). Update `middleware.go` accordingly (no more `if !authEnabled { next }` bypass — except the bootstrap endpoints and login/register which are unauthenticated). +- Login page (`web/app/login` or existing) offers: email/password form + "Sign in with your organization's SSO" (enter org, redirect to `/auth/oidc/start`). + +--- + +## 7. Migration + +One-shot migration run at startup (idempotent): +1. If `orgs` is empty AND `servers`/`keys`/etc. contain documents without `org_id`: create a **default org** ("Default"). +2. Set `org_id = ` on all existing `servers`, `keys`, `assignments`, `secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `audit` documents missing it. +3. If `OIDC_ISSUER` env was set previously and an admin email is known, optionally seed an owner user (documented manual step) — otherwise first-run bootstrap handles owner creation. +Guard with a marker (e.g. a `migrations` collection entry) so it runs once. + +--- + +## 8. Frontend + +- **Login/Setup:** `web/app/login/page.tsx` (email/password + org SSO entry) and `web/app/setup/page.tsx` (first-run). Redirect logic based on `bootstrap-status` and `auth/me`. +- **Org settings:** `web/app/settings/org/` — members list + invite/create user + role management; OIDC provider form (issuer/client id/secret/enabled). +- Existing pages unchanged functionally but now implicitly org-scoped by the backend. Show current org + user in the sidebar/header. + +--- + +## 9. Security + +- Passwords: bcrypt (cost ≥ 12). Never returned. +- Org OIDC client secret encrypted at rest (reuse `services/crypto.go` AES). +- Cross-org access prevented at the service layer (org_id in every filter) — the primary isolation boundary. Handlers must never accept an `org_id` from the client; always derive from session. +- OIDC callback must bind the returned identity to the org that initiated the flow (state carries org_id) to prevent org-mixing. +- Role checks on all org-admin mutations. + +--- + +## 10. Out of scope + +- Billing, plans, seat/server limits. +- Cross-org resource sharing, org switching for a single user (one user = one org in v1). +- SCIM / directory sync, SAML. +- Email delivery for invites (create-user sets a password or invite token; email sending deferred — document as manual/console output). +- Tests (skipped, consistent with prior iterations).