Compare commits

..
25 Commits
Author SHA1 Message Date
mrhid6 cbb66c63f6 feat: multi-tenant SaaS — orgs, auth, per-org isolation
Server Deploy / deploy (push) Successful in 1m31s
Converts Vantage from a single-admin self-hosted app into a multi-tenant
SaaS. Org isolation is org_id row-scoping in one shared deployment and
database; each org gets a subdomain via wildcard DNS, but the hostname is
a routing hint and never an authorization boundary.

- Orgs, users and roles (owner|admin|member) with local password auth,
  first-run bootstrap, and per-org OIDC replacing the global provider
- Redis sessions carrying org and role; host/session mismatch guard
- Every tenant-scoped collection carries org_id; handlers take org from
  the session only, never from client input
- Agent gRPC path resolves org from the servers record, so agent configs
  and the shared gRPC host are unchanged
- Per-org settings and ESO read token, replacing global singletons
- Frontend: login, setup and org settings pages; org-aware AuthProvider
- Migrations 0001-0003 backfill an existing single-tenant instance

Reviewed per task plus a whole-branch and a migration-focused pass.
2026-07-22 11:13:28 +01:00
mrhid6 0d4fb896bb docs(spec): correct collection names and per-org caveats
The spec named the audit collection "audit" and the channels collection
"channels"; the code uses audit_logs and notification_channels. The
migration followed the spec, which is how it came to backfill two
collections that do not exist.
2026-07-22 11:13:19 +01:00
mrhid6 0b7e55d301 fix(server): adopt the migrated org on bootstrap; survive fresh install
Two failures found by tracing the migration path against a real upgrade.

Bootstrap orphaned the entire dataset. On upgrade, 0001 creates the
Default org and stamps every legacy document with it, but the instance
has no users, so the operator must run /auth/bootstrap to get in — and
that unconditionally created a SECOND org and put the owner in it. Every
org-scoped read then filtered on the new org, so the operator would log
into an empty Vantage while all their data sat under "default". Nothing
errored and agents kept syncing, so it presented as total data loss.
Bootstrap now adopts the sole existing org, renaming and re-slugging it,
and only creates one when no org exists. More than one org with no users
is refused rather than guessed.

Fresh installs crash-looped. Nothing creates the settings collection
before EnsureSettingsIndexes, so DropOne returned NamespaceNotFound (26),
isIndexNotFound matched only IndexNotFound (27), and that check is fatal.
The same early return also skipped index creation in the secrets and
workflow ensures.

Also: only insert the backfill org on ErrNoDocuments, so a transient read
error can't race the fatal unique slug index; run 0002 before 0003 so the
settings migration can't be pushed into its ambiguous branch; fail 0002's
ambiguous case with a remedy instead of continuing into a fatal index
build; and skip non-string ids in the owner backfill rather than aborting.
2026-07-22 10:51:42 +01:00
mrhid6 77a92787fb fix: repair migration collection names and cross-cutting scoping gaps
Findings from the final whole-branch review.

- scopedCollections named "audit" and "channels", but the code writes to
  audit_logs and notification_channels. On upgrade from single-tenant,
  legacy audit events and channels would never get org_id, becoming
  invisible to org-filtered reads while channels silently stopped firing
  — and the detection loop counted the wrong names, so the 0001 marker
  could be written having migrated nothing. Names fixed, plus migration
  0003 so an incorrectly-migrated instance converges with a fresh one.
- EnsureAuthIndexes failure is now fatal. GetUserByEmail is unscoped and
  the OIDC cross-org guard compares against whichever duplicate Mongo
  returns first, so users.email uniqueness is a security invariant, and a
  legacy collection with duplicate emails is the realistic upgrade case.
- Evict the per-org OIDC provider cache on save; rotating away from a
  compromised IdP previously had no effect until restart.
- Build the oauth2 config per request instead of mutating a shared cached
  pointer outside the mutex, which raced on RedirectURL between
  concurrent logins for the same org.
- Stamp org_id on console_sessions, incidents and monitor_rollups, the
  last collections with no tenant column. 0003 derives their org from the
  owning server/monitor rather than defaulting, so one org's console
  history and incident timeline cannot merge into another's.
- Seed default steps when an org is created, not only at boot.
- Reject an empty session OrgID at the middleware.
- Derive the app root label from APP_ROOT_LABEL instead of hardcoding
  "vantage", which silently disabled the host guard off that domain.
- Stop caching negative slug lookups, so a new org's subdomain resolves
  immediately.
2026-07-22 10:44:17 +01:00
mrhid6 aa31cd8a10 fix: validate roles, guard last owner, scope bootstrap status
Security review of e70b2f0. The UI gating was correctly backed by
RequireRole everywhere; these are the missing validation gaps behind it.

- UpdateUserRole and createOrgUser accepted any role string verbatim, so
  an admin could self-promote to owner, create an owner outright, or set
  a junk role that silently stripped a user's access. Roles are now
  whitelisted, only an owner may grant or remove the owner role, and an
  actor cannot change their own.
- Neither demote nor delete guarded the last owner, so an org could reach
  zero owners. Both now refuse when no owner would remain, returning 409.
  Self-delete rejected.
- CountUsers counted across all orgs, so a locked-out org could never
  re-bootstrap once another tenant existed, and the unauthenticated
  bootstrap-status endpoint reported instance-wide state. It now answers
  per-org on an org host, falling back to global only on the apex.
- HandleMe repeats the middleware's host/org check; it sits outside the
  middleware so it can still return its own 401.
- Post-bootstrap now sends the new owner to their org host's login page.
  The session cookie is deliberately scoped to the exact host, so the old
  redirect landed them unauthenticated.
- AuthProvider renders an error state instead of mounting the shell with
  a null user when /auth/me fails for a reason other than 401.
- api.ts unwraps {"error": ...} so these messages render as text.
2026-07-22 10:26:21 +01:00
mrhid6 e70b2f0e67 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.
2026-07-22 10:17:12 +01:00
mrhid6 156c5354de 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.
2026-07-22 10:05:37 +01:00
mrhid6 e2f5f1fa8c 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.
2026-07-22 10:00:30 +01:00
mrhid6 4f512d01f1 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 "".
2026-07-22 09:41:51 +01:00
mrhid6 e5363a64ee 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.
2026-07-22 09:35:47 +01:00
mrhid6 5a701acc82 fix: Removed tests 2026-07-22 09:31:29 +01:00
mrhid6 dff3668a25 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
2026-07-22 09:28:43 +01:00
mrhid6 850aa0ed05 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.
2026-07-21 16:56:39 +01:00
mrhid6 d0ed9885e7 feat(auth): per-org OIDC resolver replaces global provider 2026-07-21 16:41:50 +01:00
mrhid6 ff22340561 feat(auth): host-based org resolution + session/host match guard 2026-07-21 16:38:24 +01:00
mrhid6 2038e86b53 feat(auth): local email/password login + first-run bootstrap 2026-07-21 16:36:13 +01:00
mrhid6 404214d82e fix(services): keep original step Slugify; org reuses it 2026-07-21 16:33:31 +01:00
mrhid6 01fd2e201e feat(services): org create+slug and user service with bcrypt 2026-07-21 16:30:42 +01:00
mrhid6 066095ffca feat(auth): session carries org_id/role; add context helpers 2026-07-21 16:28:06 +01:00
mrhid6 022b1ef8ec feat(server): auth indexes + default-org backfill migration 2026-07-21 16:25:37 +01:00
mrhid6 a8771a6e4d style(models): gofmt org_id field alignment 2026-07-21 16:24:16 +01:00
mrhid6 502045d3af feat(models): add org/user/org_oidc models and org_id on scoped collections 2026-07-21 16:23:14 +01:00
mrhid6 ea73fc3a18 docs(plan): saas auth + orgs implementation plan 2026-07-21 16:20:20 +01:00
mrhid6 75b86e3843 docs(spec): add org slug, host-based resolution, single gRPC endpoint 2026-07-21 16:15:29 +01:00
mrhid6 9767e18123 chore: removed old plans 2026-07-21 15:54:11 +01:00
87 changed files with 4696 additions and 1945 deletions
@@ -1,866 +0,0 @@
# 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 (
<div className="h-2 w-full overflow-hidden rounded-full bg-surface-2">
<div className={`h-full rounded-full ${pct > 90 ? "bg-danger" : "bg-accent"}`} style={{ width: `${pct}%` }} />
</div>
);
}
function InventoryPanel({ inv }: { inv: Inventory }) {
return (
<Card>
<h2 className="mb-4 text-lg font-semibold text-text-primary">Inventory</h2>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<div className="mb-1 flex justify-between text-sm"><span className="text-text-secondary">CPU</span><span className="text-text-primary">{inv.cpu.usage_pct.toFixed(0)}%</span></div>
<UsageBar used={inv.cpu.usage_pct} total={100} />
<p className="mt-1 text-xs text-text-secondary">{inv.cpu.model} · {inv.cpu.cores} cores · load {inv.cpu.load1?.toFixed(2)}</p>
</div>
<div>
<div className="mb-1 flex justify-between text-sm"><span className="text-text-secondary">Memory</span><span className="text-text-primary">{formatBytes(inv.memory.used_bytes)} / {formatBytes(inv.memory.total_bytes)}</span></div>
<UsageBar used={inv.memory.used_bytes} total={inv.memory.total_bytes} />
<div className="mb-1 mt-3 flex justify-between text-sm"><span className="text-text-secondary">Swap</span><span className="text-text-primary">{formatBytes(inv.swap_used_bytes)} / {formatBytes(inv.swap_total_bytes)}</span></div>
<UsageBar used={inv.swap_used_bytes} total={inv.swap_total_bytes} />
</div>
</div>
{inv.partitions && inv.partitions.length > 0 && (
<div className="mt-5">
<h3 className="mb-2 text-sm font-medium text-text-secondary">Partitions</h3>
<div className="space-y-3">
{inv.partitions.map((p) => (
<div key={p.mountpoint}>
<div className="mb-1 flex justify-between text-xs">
<span className="font-mono text-text-primary">{p.mountpoint}</span>
<span className="text-text-secondary">{formatBytes(p.used_bytes)} / {formatBytes(p.total_bytes)} · {p.fstype}</span>
</div>
<UsageBar used={p.used_bytes} total={p.total_bytes} />
</div>
))}
</div>
</div>
)}
{inv.kernel && <p className="mt-4 text-xs text-text-secondary">Kernel {inv.kernel}</p>}
</Card>
);
}
```
Render `{server.inventory && <InventoryPanel inv={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"
```
---
# Service Monitoring (uptime-kuma replacement)
Extends the fleet work: in-app service monitors replacing uptime-kuma. Monitors (HTTP/TCP/ICMP/TLS) run **server-side** (public endpoints) or **agent-side** (agent probes its own host). Both runners feed one server-side ingest pipeline: state → incidents → rollups → notifications.
**Design:** validated in brainstorm 2026-07-21. Hybrid runners, all 4 check types, latest+incidents+rollups history, multi-channel notify (webhook/SMTP/Discord/Slack/Telegram), dedicated `SyncMonitors`/`ReportChecks` RPCs.
**Build order — 3 phases, each shippable:**
- **P1 (Tasks 710):** data model, checker pkg, server scheduler, ingest pipeline, `/monitors` UI. Server-run only. No agent, no notify.
- **P2 (Tasks 1112):** `SyncMonitors` + `ReportChecks` RPCs, agent checker + scheduler, agent-run monitors bound to a server.
- **P3 (Tasks 1314):** notification channels + dispatch + settings UI.
## Monitoring Global Constraints
- Same as fleet: no tests this iteration; verify with `go build ./...`, `go vet ./...`, `npm run build`. JSON-codec gRPC — edit both pb files identically, mirror `ReportUpdates` wiring. Separate Go modules, so the checker pkg is **duplicated** in `server/` and `agent/` (same convention as pb files).
- Reuse existing patterns: REST handlers like `server/internal/api`, services like `server/internal/services/servers.go`, `db.Col(...)`, react-query + Tailwind UI like `web/app/servers`.
---
## Task 7: Monitoring data model + checker package (server)
**Files:**
- Create: `server/internal/models/monitor.go`
- Create: `server/internal/checker/checker.go` (+ `http.go`, `tcp.go`, `icmp.go`, `tls.go`)
**Interfaces:**
- Produces: `models.Monitor` (+ `MonitorState`, `MonitorTarget`), `models.Incident`, `models.Rollup`. `checker.Run(ctx, models.Monitor) checker.Result` where `Result{Up bool; LatencyMs int; Message string; CertExpiry *time.Time}`.
- [ ] **Step 1: Model**
```go
type MonitorTarget struct {
URL string `bson:"url,omitempty" json:"url,omitempty"`
Host string `bson:"host,omitempty" json:"host,omitempty"`
Port int `bson:"port,omitempty" json:"port,omitempty"`
Method string `bson:"method,omitempty" json:"method,omitempty"`
ExpectedStatus int `bson:"expected_status,omitempty" json:"expected_status,omitempty"`
Keyword string `bson:"keyword,omitempty" json:"keyword,omitempty"`
TLSWarnDays int `bson:"tls_warn_days,omitempty" json:"tls_warn_days,omitempty"`
}
type MonitorState struct {
Status string `bson:"status" json:"status"` // up|down|pending
LastCheckAt *time.Time `bson:"last_check_at,omitempty" json:"last_check_at,omitempty"`
LatencyMs int `bson:"latency_ms" json:"latency_ms"`
Message string `bson:"message,omitempty" json:"message,omitempty"`
CertExpiryAt *time.Time `bson:"cert_expiry_at,omitempty" json:"cert_expiry_at,omitempty"`
Fails int `bson:"fails" json:"fails"` // consecutive failures
}
type Monitor struct {
MonitorID string `bson:"monitor_id" json:"monitor_id"`
Name string `bson:"name" json:"name"`
Type string `bson:"type" json:"type"` // http|tcp|icmp|tls
Target MonitorTarget `bson:"target" json:"target"`
IntervalSec int `bson:"interval_sec" json:"interval_sec"`
Runner string `bson:"runner" json:"runner"` // "server" or a server_id
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"`
State MonitorState `bson:"state" json:"state"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
type Incident struct {
IncidentID string `bson:"incident_id" json:"incident_id"`
MonitorID string `bson:"monitor_id" json:"monitor_id"`
StartedAt time.Time `bson:"started_at" json:"started_at"`
ResolvedAt *time.Time `bson:"resolved_at,omitempty" json:"resolved_at,omitempty"`
Cause string `bson:"cause,omitempty" json:"cause,omitempty"`
}
type Rollup struct {
MonitorID string `bson:"monitor_id" json:"monitor_id"`
PeriodStart time.Time `bson:"period_start" json:"period_start"` // hour bucket
Checks int `bson:"checks" json:"checks"`
UpCount int `bson:"up_count" json:"up_count"`
SumLatency int64 `bson:"sum_latency" json:"sum_latency"`
}
```
- [ ] **Step 2: Checker package** — `Run(ctx, m)` switches on `m.Type`:
- **http**: `http.Client` GET/HEAD `m.Target.URL`, assert status == ExpectedStatus (default 200), optional `Keyword` body contains; capture TLS peer cert expiry when https.
- **tcp**: `net.DialTimeout("tcp", host:port)`, latency = dial time.
- **icmp**: raw ICMP echo (agent/server run as root). Fall back to `net.Dial("ip4:icmp")`; on permission error return down with message.
- **tls**: `tls.Dial`, read `ConnectionState().PeerCertificates[0].NotAfter` → `CertExpiry`; down if within `TLSWarnDays` or expired.
- All: wrap with per-check timeout (min(IntervalSec, 10s)); `Result.Message` = short reason on failure.
- [ ] **Step 3: Verify build** — `cd server && go build ./... && go vet ./...`
- [ ] **Step 4: Commit** — `feat(server): monitor model + checker package`
---
## Task 8: Ingest pipeline + rollups service
**Files:**
- Create: `server/internal/services/monitors.go`
**Interfaces:**
- Produces: `IngestResult(monitorID string, res checker.Result) error` — the single entry both runners use. `ListMonitors`, `GetMonitor`, `CreateMonitor`, `UpdateMonitor`, `DeleteMonitor`, `ListIncidents(monitorID)`, `UptimeRollups(monitorID, since)`.
- [ ] **Step 1: `IngestResult`** — load monitor; compute new status with `Retries` threshold (increment `state.Fails` on failure, flip to `down` only when `Fails >= Retries`; reset + flip `up` on success). On **transition**: open incident (`down`) or resolve open incident (`up`), and enqueue notification (P3 — leave a `// TODO(P3): dispatch` hook now). Always `$set` state fields. Upsert current-hour `Rollup` (`$inc` checks/up_count/sum_latency). Use `db.Col("monitors")`, `db.Col("incidents")`, `db.Col("monitor_rollups")`, `context.WithTimeout`.
- [ ] **Step 2: CRUD + queries** — standard service funcs mirroring `services/servers.go`. `UptimeRollups` aggregates buckets since a cutoff → uptime % + avg latency series.
- [ ] **Step 3: Verify build** — `go build ./... && go vet ./...`
- [ ] **Step 4: Commit** — `feat(server): monitor ingest pipeline, incidents, rollups`
---
## Task 9: Server scheduler + REST API
**Files:**
- Create: `server/internal/monitorsched/scheduler.go`
- Create: `server/internal/api/monitors.go`
- Modify: server bootstrap (wherever services/gRPC start) to launch the scheduler; router registration where `api` routes are mounted.
**Interfaces:**
- Produces: a scheduler that ticks enabled `runner=="server"` monitors on their `IntervalSec` and calls `checker.Run` → `services.IngestResult`. REST: `GET/POST /api/monitors`, `GET/PUT/DELETE /api/monitors/:id`, `GET /api/monitors/:id/incidents`, `GET /api/monitors/:id/uptime`.
- [ ] **Step 1: Scheduler** — on boot load monitors; per-monitor goroutine or a min-heap wheel keyed on next-run. Only `runner=="server"`. Reload on CRUD (simplest: re-read every N sec, or a reload channel fired by the service). Skip disabled.
- [ ] **Step 2: REST handlers** — mirror an existing `server/internal/api` handler file for style + auth middleware. JSON in/out of `models.Monitor`.
- [ ] **Step 3: Verify build** — `go build ./... && go vet ./...`
- [ ] **Step 4: Commit** — `feat(server): server-run monitor scheduler + REST API`
---
## Task 10: Frontend — monitors UI (P1)
**Files:**
- Modify: `web/lib/api.ts` (Monitor types + bindings)
- Create: `web/app/monitors/page.tsx` (list), `web/app/monitors/[id]/page.tsx` (detail), `web/app/monitors/new/page.tsx` (create/edit form)
- Modify: main nav to add **Monitors** (same place Steps was added)
**Interfaces:**
- Consumes: `/api/monitors*` (T9).
- [ ] **Step 1: Types + api bindings** — `Monitor`, `MonitorState`, `Incident`, uptime series; `api.monitors.list/get/create/update/remove/incidents/uptime`.
- [ ] **Step 2: List page** — table: name, type, status badge (up/down/pending), uptime % (24h), latency, last check. `refetchInterval: 30000`.
- [ ] **Step 3: Detail page** — status header, heartbeat/uptime bars (24h + 30d from rollups), latency chart, incident timeline, cert expiry, assigned channels (read-only until P3).
- [ ] **Step 4: Create/edit form** — type-dependent fields (URL vs host/port), interval, retries, runner select (`server` or a registered server for agent-run — server option only wired in P2), enabled.
- [ ] **Step 5: Verify build** — `cd web && npm run build`
- [ ] **Step 6: Commit** — `feat(web): monitors list/detail/form UI`
---
## Task 11: SyncMonitors + ReportChecks RPCs (P2)
**Files:**
- Modify: `proto/vantage/v1/vantage.proto`, `server/internal/grpc/pb/vantage.pb.go`, `agent/internal/grpc/pb/vantage.pb.go`, `server/internal/grpc/server.go`, `agent/internal/grpc/client.go`
**Interfaces:**
- Produces: `SyncMonitors(server_id, agent_token) -> repeated MonitorSpec`; `ReportChecks(server_id, agent_token, repeated CheckResult) -> ReportChecksResponse`. `MonitorSpec{monitor_id, type, target fields, interval_sec, retries}`. `CheckResult{monitor_id, up, latency_ms, message, cert_expiry_unix}`.
- [ ] **Step 1: pb structs + proto** — add messages to both pb files + proto doc.
- [ ] **Step 2: Wire both RPCs** — mirror `ReportUpdates` plumbing (interface, Unimplemented stub, client method, `Vantage_ServiceDesc.Methods`, `_Vantage_*_Handler`) in both pb files. Server handlers on `vantageServer` (after `ReportUpdates` at server.go:78): `SyncMonitors` returns monitors where `runner==req.ServerId && enabled`; `ReportChecks` validates token then loops `services.IngestResult`. Client methods on `*Client` in client.go (after `ReportUpdates` at client.go:117).
- [ ] **Step 3: Verify build** — both modules `go build ./... && go vet ./...`
- [ ] **Step 4: Commit** — `feat(proto): SyncMonitors + ReportChecks RPCs`
---
## Task 12: Agent checker + scheduler (P2)
**Files:**
- Create: `agent/internal/checker/` (duplicate of server checker pkg)
- Create: `agent/internal/monitors/monitors.go` (poll + run + report loop)
- Modify: agent main loop to start it (alongside the sync loop in `agent/internal/sync` / the inventory ticker from Task 4)
**Interfaces:**
- Consumes: `client.SyncMonitors`, `client.ReportChecks`, agent `checker`.
- [ ] **Step 1: Duplicate checker pkg** into agent module (identical logic; imports agent pb).
- [ ] **Step 2: Monitor loop** — poll `SyncMonitors` every 30s for assigned specs; per-spec ticker on `IntervalSec` runs `checker.Run`; batch `CheckResult`s and `ReportChecks`. `serverID`/`agentToken`/`*Client` in scope from the existing loop.
- [ ] **Step 3: Verify build** — `cd agent && go build ./... && go vet ./...` (+ `GOOS=windows go build ./...`; icmp may no-op on Windows).
- [ ] **Step 4: Commit** — `feat(agent): agent-run monitor scheduler`
---
## Task 13: Notification channels + dispatch (P3)
**Files:**
- Create: `server/internal/models/channel.go`, `server/internal/services/channels.go`, `server/internal/notify/` (`dispatch.go`, `webhook.go`, `smtp.go`, `discord.go`, `slack.go`, `telegram.go`), `server/internal/api/channels.go`
- Modify: `server/internal/services/monitors.go` (replace the P2 `// TODO(P3): dispatch` hook)
**Interfaces:**
- Produces: `models.NotificationChannel{channel_id, name, type, config map, enabled}`. `notify.Dispatch(channel, event)` where `event` = monitor + old/new status + message. `notify.Test(channel)`.
- [ ] **Step 1: Model + CRUD service + REST** (`/api/channels*`, incl. `POST /api/channels/:id/test`).
- [ ] **Step 2: Dispatch abstraction** — webhook/discord/slack/telegram are HTTP POST with per-type JSON payload; SMTP via `net/smtp`. Per-monitor routing via `monitor.ChannelIDs`; resend interval so an ongoing `down` re-alerts at most every N min (track `last_notified_at` on monitor state).
- [ ] **Step 3: Fire on transition** — in `IngestResult`, on up/down flip resolve channels and `notify.Dispatch` each (goroutine, best-effort, log failures).
- [ ] **Step 4: Verify build** — `go build ./... && go vet ./...`
- [ ] **Step 5: Commit** — `feat(server): multi-channel monitor notifications`
---
## Task 14: Frontend — notification settings (P3)
**Files:**
- Modify: `web/lib/api.ts` (channel types + bindings), `web/app/settings/` (add notifications section/page)
- Modify: monitor create/edit form (Task 10) to select channels
**Interfaces:**
- Consumes: `/api/channels*`.
- [ ] **Step 1: Channel types + api bindings.**
- [ ] **Step 2: Settings UI** — list/add/edit channels, type-dependent config fields, **Test** button hitting `/api/channels/:id/test`.
- [ ] **Step 3: Wire channel multi-select** into the monitor form.
- [ ] **Step 4: Verify build** — `cd web && npm run build`
- [ ] **Step 5: Commit** — `feat(web): notification channel settings UI`
---
## 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.
- **Monitoring (Tasks 714):** hybrid runner service-monitor replacing uptime-kuma, added 2026-07-21. 3 phases — P1 server-run engine+UI (T710), P2 agent-run RPCs (T1112), P3 multi-channel notify (T1314). Single `IngestResult` pipeline for both runners; checker pkg duplicated per module (pb convention). Design: brainstorm 2026-07-21. Follow-ups out of scope: status pages, maintenance windows, per-check auth headers, ICMP on Windows.
File diff suppressed because it is too large Load Diff
@@ -1,142 +0,0 @@
# 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).
@@ -11,7 +11,7 @@
Turn Vantage from a single-admin, single global-OIDC tool into a multi-tenant app:
1. **Replace** the global Authentik/env-based OIDC with **local email/password accounts** as the primary login.
2. **Organizations** — every user belongs to an org; every domain object (servers, keys, secrets, assignments, workflows, steps, runs, audit) carries an `org_id` and all queries are scoped to the caller's org.
2. **Organizations** — every user belongs to an org; every domain object (servers, keys, secrets, assignments, workflows, steps, runs, audit logs, monitors, notification channels, console sessions, incidents, uptime rollups) carries an `org_id` and all queries are scoped to the caller's org.
3. **Per-org OpenID** — an org admin can configure their own OIDC provider (issuer/client id/secret); users in that org can then sign in through it.
No billing, no seat/server limits this iteration (schema leaves room).
@@ -29,6 +29,7 @@ No billing, no seat/server limits this iteration (schema leaves room).
| Bootstrapping | First-run creates the initial org + owner account (setup flow) when no users exist. |
| Sessions | Keep existing Redis session store; session now carries `user_id`, `org_id`, `role`, `email`. |
| Agent auth | Unchanged (per-server agent tokens). Servers gain `org_id`; agent RPCs resolve org from the server record. |
| gRPC endpoint | Single shared `grpc-vantage.hostxtra.co.uk` — no per-org subdomain. Org resolved from `server_id`/token, never from host. Agent configs unchanged. |
---
@@ -36,8 +37,12 @@ No billing, no seat/server limits this iteration (schema leaves room).
### `orgs`
```json
{ "_id":"ObjectId", "org_id":"uuid", "name":"Acme", "created_at":"ISODate" }
{ "_id":"ObjectId", "org_id":"uuid", "name":"Doms Org", "slug":"doms-org", "created_at":"ISODate" }
```
- `slug` derived from `name` at creation: lowercase, spaces/underscores → `-`, strip non `[a-z0-9-]`, collapse repeat `-`, trim leading/trailing `-`. `Doms Org``doms-org`.
- **Unique index on `slug`** (global). On collision append `-2`, `-3`, … or reject and ask user to pick.
- Length 340. Reserved slugs blocked: `www`, `api`, `app`, `admin`, `auth`, `install`, `static`, `_next`, plus the bare apex.
- Slug is the DNS label → `doms-org.vantage.hostxtra.co.uk`. Treat as **immutable in v1** (rename breaks bookmarks, cookies, OIDC redirect URLs). Renaming deferred.
### `users`
```json
@@ -55,13 +60,14 @@ Unique index on `email` (global — email identifies the account and its org).
"_id":"ObjectId", "org_id":"uuid",
"issuer":"https://id.acme.com", "client_id":"...",
"client_secret_enc":"AES...", // encrypted with existing crypto.go
"redirect_url":"https://vantage.../auth/oidc/callback",
"enabled": true, "updated_at":"ISODate"
}
```
### Existing collections — add `org_id`
`servers`, `keys`, `assignments`, `secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `audit` each gain `org_id string`. A **migration** backfills all existing documents into a default org (see §7).
`servers`, `keys`, `assignments`, `secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `audit_logs`, `monitors`, `notification_channels`, `console_sessions`, `incidents`, `monitor_rollups` each gain `org_id string`. A **migration** backfills all existing documents into a default org (see §7).
> The audit collection is named `audit_logs` and the notification channel collection `notification_channels`. Earlier drafts of this document called them `audit` and `channels`; those names were copied verbatim into the migration's scoped-collection list and silently skipped both collections. Use the real names.
---
@@ -77,8 +83,8 @@ Unique index on `email` (global — email identifies the account and its org).
- `GET /api/org/users` / `POST /api/org/users` (create local user in caller's org) / `PUT /api/org/users/:id/role` / `DELETE /api/org/users/:id`.
### Per-org OIDC
- `GET/PUT /api/org/oidc` — read/save the caller org's provider config (admin only). Secret stored encrypted.
- `GET /auth/oidc/start?org=<org_id or slug>` — 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://<slug>.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://<host>/auth/oidc/callback`), not stored. State carries `org_id`. The per-org provider cache is evicted when the org's OIDC config is saved, so a rotated issuer takes effect without a restart; the oauth2 config is built per request (its redirect URL is host-derived) and never cached.
- `GET /auth/oidc/callback` — exchange code, match/provision the user by email **within that org**, create session.
- If the email exists in the org → log in. If not → provision a `member` with `auth_source=oidc` (org admin can promote). Reject if email belongs to a different org.
@@ -95,6 +101,14 @@ Unique index on `email` (global — email identifies the account and its org).
- Add a `requireRole(role)` gin middleware for admin-only routes (org user mgmt, org OIDC).
- Agent-facing gRPC: resolve `org_id` from the `servers` record (already tied to `server_id`); inventory/keys/sync operate on that org implicitly.
### Host-based org resolution (per-org subdomain)
- Wildcard DNS `*.vantage.hostxtra.co.uk` + wildcard TLS cert (Let's Encrypt DNS-01). One record, one cert, no per-org ops.
- Middleware extracts subdomain label from `Host` header → look up `orgs.slug` → org. Cache slug→org_id (hits only; misses are never cached so a freshly bootstrapped org resolves immediately). The app root label (the `vantage` in `<slug>.vantage.<tld>`) is read from `APP_ROOT_LABEL`, defaulting to `vantage` — deployments on another root must set it or no host resolves to an org.
- **Hostname is a routing/UX hint, NOT an authorization boundary.** Authorization stays session `org_id` (spec §9). If session org ≠ host org → reject (or redirect to correct host). Never trust `Host` to grant access.
- `/auth/oidc/start` reads org from host — drops the "type your org" box.
- Session cookie set on the **exact host** (`doms-org.vantage...`), not parent `.vantage...`, so cookies don't leak across orgs.
- Apex `vantage.hostxtra.co.uk` (no subdomain): serves bootstrap + login-by-email fallback; after login redirect to the user's org host.
---
## 6. Removing global Authentik
@@ -108,8 +122,10 @@ Unique index on `email` (global — email identifies the account and its org).
## 7. Migration
One-shot migration run at startup (idempotent):
1. If `orgs` is empty AND `servers`/`keys`/etc. contain documents without `org_id`: create a **default org** ("Default").
2. Set `org_id = <default>` on all existing `servers`, `keys`, `assignments`, `secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `audit` documents missing it.
1. If any scoped collection contains documents without `org_id`: reuse the org with slug `default`, creating it ("Default") if absent. Note this is looser than "`orgs` is empty AND ..." — the implementation is the authoritative and safer form, since an instance can have an org already (created by first-run bootstrap) while legacy documents still lack `org_id`; the stricter condition would skip the backfill and strand that data.
2. Set `org_id = <default>` on all existing `servers`, `keys`, `assignments`, `secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `audit_logs`, `monitors`, `notification_channels` documents missing it.
- `console_sessions`, `incidents` and `monitor_rollups` are also scoped, but their org is derived from the owning `servers`/`monitors` record rather than defaulted (migration `0003`), since defaulting them would mix one org's console history and incident timeline into another's.
- Migration `0003` also re-runs step 2 for `audit_logs` and `notification_channels`: the original `0001` listed them under the wrong names (`audit`, `channels`) and wrote its marker regardless, so those documents need a second, separately-markered pass to converge.
3. If `OIDC_ISSUER` env was set previously and an admin email is known, optionally seed an owner user (documented manual step) — otherwise first-run bootstrap handles owner creation.
Guard with a marker (e.g. a `migrations` collection entry) so it runs once.
+36 -7
View File
@@ -24,18 +24,51 @@ func main() {
}
log.Println("connected to MongoDB")
// The unique indexes are a security property: GetUserByEmail does an
// unscoped FindOne, so duplicate (or blank) emails let the OIDC callback's
// cross-org guard compare against an arbitrary user, and duplicate org slugs
// make host-based org resolution pick one at random.
if err := services.EnsureAuthIndexes(); err != nil {
log.Fatalf("failed to ensure auth indexes: %v", err)
}
if err := services.RunMigrations(); err != nil {
log.Fatalf("migration failed: %v", err)
}
// Must run before the unique settings indexes are built, and before 0003:
// 0003 can create a "default" org, which would push 0002 into its ambiguous
// multi-org branch and leave the settings doc unstamped.
if err := services.MigrateSettingsOrg(); err != nil {
log.Fatalf("settings org migration failed: %v", err)
}
if err := services.MigrateMissedOrgScopes(); err != nil {
log.Fatalf("missed org scope migration failed: %v", err)
}
if err := services.EnsureSecretIndexes(); err != nil {
log.Printf("warning: failed to ensure secret indexes: %v", err)
}
// The unique indexes are a security property: duplicate settings docs make
// GetSettings return an arbitrary one, and duplicate ESO token hashes make
// ResolveSecretsReadToken pick an arbitrary org.
if err := services.EnsureSettingsIndexes(); err != nil {
log.Fatalf("failed to ensure settings indexes: %v", err)
}
if err := services.EnsureWorkflowIndexes(); err != nil {
log.Printf("warning: failed to ensure workflow indexes: %v", err)
}
if created, updated, err := services.SeedDefaultSteps(); err != nil {
log.Printf("warning: failed to seed default steps: %v", err)
if orgIDs, err := services.ListOrgIDs(); err != nil {
log.Printf("warning: failed to list orgs for default step seeding: %v", err)
} else {
log.Printf("default steps seeded: %d created, %d updated", created, updated)
for _, orgID := range orgIDs {
if created, updated, err := services.SeedDefaultSteps(orgID); err != nil {
log.Printf("warning: failed to seed default steps for org %s: %v", orgID, err)
} else {
log.Printf("default steps seeded for org %s: %d created, %d updated", orgID, created, updated)
}
}
}
services.StartLogSweeper()
@@ -46,10 +79,6 @@ func main() {
}
log.Println("connected to Redis")
if err := auth.InitOIDC(context.Background()); err != nil {
log.Fatalf("failed to initialise OIDC: %v", err)
}
// Background goroutine to mark offline servers
go func() {
ticker := time.NewTicker(2 * time.Minute)
+6 -5
View File
@@ -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
}
+13 -11
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/services"
"github.com/wwt/guac"
)
@@ -29,13 +30,13 @@ func consoleConnect(c *gin.Context) {
return
}
srv, err := services.GetServer(body.ServerID)
srv, err := services.GetServer(auth.OrgID(c), body.ServerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
sess, err := services.CreateConsoleSession(body.ServerID, body.Protocol, body.KeyID, actorFromCtx(c), c.ClientIP())
sess, err := services.CreateConsoleSession(auth.OrgID(c), body.ServerID, body.Protocol, body.KeyID, actorFromCtx(c), c.ClientIP())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -47,20 +48,20 @@ func consoleConnect(c *gin.Context) {
}
if (body.Protocol == "rdp" || body.Protocol == "vnc") && (body.RDPUsername != "" || body.RDPPassword != "") {
if err := services.StashConsoleRDPCreds(sess.SessionID, body.RDPUsername, body.RDPPassword); err != nil {
if err := services.StashConsoleRDPCreds(auth.OrgID(c), sess.SessionID, body.RDPUsername, body.RDPPassword); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
if body.Protocol == "ssh" {
if err := services.SetConsoleSSHUser(sess.SessionID, body.SSHUsername); err != nil {
if err := services.SetConsoleSSHUser(auth.OrgID(c), sess.SessionID, body.SSHUsername); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
services.LogEvent("console.opened", actorFromCtx(c), srv.ServerID, "",
services.LogEvent(auth.OrgID(c), "console.opened", actorFromCtx(c), srv.ServerID, "",
"console session opened ("+body.Protocol+")")
c.JSON(http.StatusOK, gin.H{
@@ -88,7 +89,8 @@ func consoleTunnel(c *gin.Context) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
sess, err := services.GetConsoleSession(sessionID)
orgID := auth.OrgID(c)
sess, err := services.GetConsoleSession(orgID, sessionID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
return
@@ -102,12 +104,12 @@ func consoleTunnel(c *gin.Context) {
}
// Single-use: atomically spend the token so a replay within its TTL is rejected.
if err := services.ConsumeSessionToken(sessionID); err != nil {
if err := services.ConsumeSessionToken(orgID, sessionID); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "token already used"})
return
}
srv, err := services.GetServer(sess.ServerID)
srv, err := services.GetServer(auth.OrgID(c), sess.ServerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -116,7 +118,7 @@ func consoleTunnel(c *gin.Context) {
// Decrypt private key + passphrase in-memory only (ssh).
var privKey, passphrase string
if sess.Protocol == "ssh" && sess.KeyID != "" {
privKey, err = services.GetPrivateKey(sess.KeyID)
privKey, err = services.GetPrivateKey(auth.OrgID(c), sess.KeyID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "selected key has no private material"})
return
@@ -126,7 +128,7 @@ func consoleTunnel(c *gin.Context) {
var rdpUser, rdpPass string
if sess.Protocol == "rdp" || sess.Protocol == "vnc" {
rdpUser, rdpPass, err = services.ConsumeConsoleRDPCreds(sessionID)
rdpUser, rdpPass, err = services.ConsumeConsoleRDPCreds(orgID, sessionID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not load credentials"})
return
@@ -171,7 +173,7 @@ func consoleTunnel(c *gin.Context) {
wsServer := guac.NewWebsocketServer(connect)
wsServer.OnDisconnect = func(id string, r *http.Request, t guac.Tunnel) {
_ = services.EndConsoleSession(sessionID)
_ = services.EndConsoleSession(orgID, sessionID)
}
wsServer.ServeHTTP(c.Writer, c.Request)
}
+57 -39
View File
@@ -32,11 +32,14 @@ func RegisterRoutes(r *gin.Engine) {
// group as flat JSON.
r.GET("/api/secrets/:group/values", secretsReadAuth(), esoGetGroup)
// Auth endpoints (no session required)
r.GET("/auth/login", auth.HandleLogin)
r.GET("/auth/callback", auth.HandleCallback)
r.GET("/auth/logout", auth.HandleLogout)
// Unauthenticated auth endpoints
r.GET("/auth/bootstrap-status", auth.HandleBootstrapStatus)
r.POST("/auth/bootstrap", auth.HandleBootstrap)
r.POST("/auth/login", auth.HandleLocalLogin)
r.POST("/auth/logout", auth.HandleLogout)
r.GET("/auth/me", auth.HandleMe)
r.GET("/auth/oidc/start", auth.HandleOIDCStart)
r.GET("/auth/oidc/callback", auth.HandleOIDCCallback)
// API endpoints protected by session middleware
apiGroup := r.Group("/api")
@@ -56,9 +59,13 @@ func RegisterRoutes(r *gin.Engine) {
apiGroup.GET("/audit", listAuditEvents)
apiGroup.GET("/settings", getSettings)
apiGroup.PUT("/settings", saveSettings)
apiGroup.POST("/settings/secrets-token", rotateSecretsToken)
settings := apiGroup.Group("/settings")
settings.Use(auth.RequireRole("owner", "admin"))
{
settings.GET("", getSettings)
settings.PUT("", saveSettings)
settings.POST("/secrets-token", rotateSecretsToken)
}
apiGroup.GET("/secrets", listSecretGroups)
apiGroup.POST("/secrets", createSecretGroup)
@@ -82,11 +89,22 @@ func RegisterRoutes(r *gin.Engine) {
registerWorkflowRoutes(apiGroup)
registerMonitorRoutes(apiGroup)
registerChannelRoutes(apiGroup)
org := apiGroup.Group("/org")
org.Use(auth.RequireRole("owner", "admin"))
{
org.GET("/users", listOrgUsers)
org.POST("/users", createOrgUser)
org.PUT("/users/:id/role", updateOrgUserRole)
org.DELETE("/users/:id", deleteOrgUser)
org.GET("/oidc", getOrgOIDC)
org.PUT("/oidc", putOrgOIDC)
}
}
}
func listServers(c *gin.Context) {
servers, err := services.ListServers()
servers, err := services.ListServers(auth.OrgID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -95,7 +113,7 @@ func listServers(c *gin.Context) {
}
func createServer(c *gin.Context) {
s, token, err := services.CreateServer()
s, token, err := services.CreateServer(auth.OrgID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -108,12 +126,12 @@ func createServer(c *gin.Context) {
}
func newServer(c *gin.Context) {
s, token, err := services.CreateServer()
s, token, err := services.CreateServer(auth.OrgID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("server.created", actorFromCtx(c), s.ServerID, "", "pre-registration token issued")
services.LogEvent(auth.OrgID(c), "server.created", actorFromCtx(c), s.ServerID, "", "pre-registration token issued")
giteaHost := os.Getenv("GITEA_HOST")
if giteaHost == "" {
@@ -144,13 +162,13 @@ func newServer(c *gin.Context) {
func getServer(c *gin.Context) {
id := c.Param("id")
s, err := services.GetServer(id)
s, err := services.GetServer(auth.OrgID(c), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
assignments, _ := services.GetAssignmentsWithKeysForServer(id)
assignments, _ := services.GetAssignmentsWithKeysForServer(auth.OrgID(c), id)
// Build response matching ServerWithKeys shape expected by frontend
type serverResponse struct {
@@ -165,8 +183,8 @@ func getServer(c *gin.Context) {
func deleteServer(c *gin.Context) {
id := c.Param("id")
s, _ := services.GetServer(id)
if err := services.DeleteServer(id); err != nil {
s, _ := services.GetServer(auth.OrgID(c), id)
if err := services.DeleteServer(auth.OrgID(c), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -174,7 +192,7 @@ func deleteServer(c *gin.Context) {
if s != nil {
hostname = s.Hostname
}
services.LogEvent("server.deleted", actorFromCtx(c), id, "", fmt.Sprintf("server %s deleted", hostname))
services.LogEvent(auth.OrgID(c), "server.deleted", actorFromCtx(c), id, "", fmt.Sprintf("server %s deleted", hostname))
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
@@ -193,7 +211,7 @@ func generateKey(c *gin.Context) {
body.Label = "generated"
}
s, err := services.GetServer(id)
s, err := services.GetServer(auth.OrgID(c), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -211,7 +229,7 @@ func generateKey(c *gin.Context) {
return
}
services.LogEvent("key.generation_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("key generation dispatched (label=%s type=%s)", body.Label, body.KeyType))
services.LogEvent(auth.OrgID(c), "key.generation_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("key generation dispatched (label=%s type=%s)", body.Label, body.KeyType))
c.JSON(http.StatusAccepted, gin.H{
"message": "key generation command sent to agent",
"command_id": cmdID,
@@ -220,7 +238,7 @@ func generateKey(c *gin.Context) {
}
func listKeys(c *gin.Context) {
keys, err := services.ListKeys()
keys, err := services.ListKeys(auth.OrgID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -240,18 +258,18 @@ func createKey(c *gin.Context) {
return
}
key, err := services.CreateKey(body.Label, body.PublicKey, "uploaded", "", body.PrivateKey, body.Passphrase)
key, err := services.CreateKey(auth.OrgID(c), body.Label, body.PublicKey, "uploaded", "", body.PrivateKey, body.Passphrase)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("key.uploaded", actorFromCtx(c), "", key.KeyID, fmt.Sprintf("key '%s' uploaded", key.Label))
services.LogEvent(auth.OrgID(c), "key.uploaded", actorFromCtx(c), "", key.KeyID, fmt.Sprintf("key '%s' uploaded", key.Label))
c.JSON(http.StatusCreated, key)
}
func getPrivateKey(c *gin.Context) {
id := c.Param("id")
plaintext, err := services.GetPrivateKey(id)
plaintext, err := services.GetPrivateKey(auth.OrgID(c), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
@@ -261,13 +279,13 @@ func getPrivateKey(c *gin.Context) {
func getKey(c *gin.Context) {
id := c.Param("id")
key, err := services.GetKey(id)
key, err := services.GetKey(auth.OrgID(c), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "key not found"})
return
}
assignments, _ := services.GetAssignmentsWithServers(id)
assignments, _ := services.GetAssignmentsWithServers(auth.OrgID(c), id)
type keyResponse struct {
*models.Key
@@ -281,8 +299,8 @@ func getKey(c *gin.Context) {
func deleteKey(c *gin.Context) {
id := c.Param("id")
k, _ := services.GetKey(id)
if err := services.DeleteKey(id); err != nil {
k, _ := services.GetKey(auth.OrgID(c), id)
if err := services.DeleteKey(auth.OrgID(c), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -290,7 +308,7 @@ func deleteKey(c *gin.Context) {
if k != nil {
label = k.Label
}
services.LogEvent("key.deleted", actorFromCtx(c), "", id, fmt.Sprintf("key '%s' deleted", label))
services.LogEvent(auth.OrgID(c), "key.deleted", actorFromCtx(c), "", id, fmt.Sprintf("key '%s' deleted", label))
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
@@ -304,12 +322,12 @@ func assignKey(c *gin.Context) {
return
}
a, err := services.AssignKey(keyID, body.ServerID)
a, err := services.AssignKey(auth.OrgID(c), keyID, body.ServerID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("key.assigned", actorFromCtx(c), body.ServerID, keyID, fmt.Sprintf("key %s assigned to server %s", keyID, body.ServerID))
services.LogEvent(auth.OrgID(c), "key.assigned", actorFromCtx(c), body.ServerID, keyID, fmt.Sprintf("key %s assigned to server %s", keyID, body.ServerID))
c.JSON(http.StatusCreated, a)
}
@@ -317,11 +335,11 @@ func revokeAssignment(c *gin.Context) {
keyID := c.Param("id")
serverID := c.Param("serverId")
if err := services.RevokeAssignment(keyID, serverID); err != nil {
if err := services.RevokeAssignment(auth.OrgID(c), keyID, serverID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("key.revoked", actorFromCtx(c), serverID, keyID, fmt.Sprintf("key %s revoked from server %s", keyID, serverID))
services.LogEvent(auth.OrgID(c), "key.revoked", actorFromCtx(c), serverID, keyID, fmt.Sprintf("key %s revoked from server %s", keyID, serverID))
c.JSON(http.StatusOK, gin.H{"revoked": true})
}
@@ -336,7 +354,7 @@ func getLatestAgentVersion(c *gin.Context) {
func updateAgent(c *gin.Context) {
id := c.Param("id")
s, err := services.GetServer(id)
s, err := services.GetServer(auth.OrgID(c), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -347,7 +365,7 @@ func updateAgent(c *gin.Context) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
services.LogEvent("agent.update_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("agent update dispatched to %s (version %s)", s.Hostname, version))
services.LogEvent(auth.OrgID(c), "agent.update_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("agent update dispatched to %s (version %s)", s.Hostname, version))
c.JSON(http.StatusAccepted, gin.H{
"message": "update command sent to agent",
"version": version,
@@ -356,7 +374,7 @@ func updateAgent(c *gin.Context) {
func applyUpdates(c *gin.Context) {
id := c.Param("id")
s, err := services.GetServer(id)
s, err := services.GetServer(auth.OrgID(c), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -366,7 +384,7 @@ func applyUpdates(c *gin.Context) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
services.LogEvent("updates.applied", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("package update command dispatched to %s", s.Hostname))
services.LogEvent(auth.OrgID(c), "updates.applied", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("package update command dispatched to %s", s.Hostname))
c.JSON(http.StatusAccepted, gin.H{"message": "apply updates command sent to agent"})
}
@@ -433,7 +451,7 @@ func listAuditEvents(c *gin.Context) {
limit = n
}
}
events, err := services.ListAuditEvents(limit)
events, err := services.ListAuditEvents(auth.OrgID(c), limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -442,7 +460,7 @@ func listAuditEvents(c *gin.Context) {
}
func getSettings(c *gin.Context) {
s, err := services.GetSettings()
s, err := services.GetSettings(auth.OrgID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -460,11 +478,11 @@ func saveSettings(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := services.SaveSettings(body.Alerts, body.Email, body.WorkflowLogRetentionDays); err != nil {
if err := services.SaveSettings(auth.OrgID(c), body.Alerts, body.Email, body.WorkflowLogRetentionDays); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("settings.updated", actorFromCtx(c), "", "", "alert settings updated")
services.LogEvent(auth.OrgID(c), "settings.updated", actorFromCtx(c), "", "", "alert settings updated")
c.JSON(http.StatusOK, gin.H{"saved": true})
}
+26 -7
View File
@@ -5,6 +5,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/services"
"go.mongodb.org/mongo-driver/v2/bson"
@@ -21,7 +22,7 @@ func registerMonitorRoutes(g *gin.RouterGroup) {
}
func listMonitors(c *gin.Context) {
monitors, err := services.ListMonitors()
monitors, err := services.ListMonitors(auth.OrgID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -39,7 +40,7 @@ func createMonitor(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "name and type are required"})
return
}
created, err := services.CreateMonitor(&m)
created, err := services.CreateMonitor(auth.OrgID(c), &m)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -48,7 +49,7 @@ func createMonitor(c *gin.Context) {
}
func getMonitor(c *gin.Context) {
m, err := services.GetMonitor(c.Param("id"))
m, err := services.GetMonitor(auth.OrgID(c), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -104,7 +105,7 @@ func updateMonitor(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
if err := services.UpdateMonitor(c.Param("id"), upd); err != nil {
if err := services.UpdateMonitor(auth.OrgID(c), c.Param("id"), upd); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -112,7 +113,7 @@ func updateMonitor(c *gin.Context) {
}
func deleteMonitor(c *gin.Context) {
if err := services.DeleteMonitor(c.Param("id")); err != nil {
if err := services.DeleteMonitor(auth.OrgID(c), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -120,7 +121,16 @@ func deleteMonitor(c *gin.Context) {
}
func getMonitorIncidents(c *gin.Context) {
incidents, err := services.ListIncidents(c.Param("id"), 50)
m, err := services.GetMonitor(auth.OrgID(c), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if m == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "monitor not found"})
return
}
incidents, err := services.ListIncidents(auth.OrgID(c), c.Param("id"), 50)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -129,8 +139,17 @@ func getMonitorIncidents(c *gin.Context) {
}
func getMonitorUptime(c *gin.Context) {
m, err := services.GetMonitor(auth.OrgID(c), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if m == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "monitor not found"})
return
}
since := time.Now().Add(-30 * 24 * time.Hour)
rollups, err := services.UptimeRollups(c.Param("id"), since)
rollups, err := services.UptimeRollups(auth.OrgID(c), c.Param("id"), since)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
+162
View File
@@ -0,0 +1,162 @@
package api
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/services"
)
func listOrgUsers(c *gin.Context) {
users, err := services.ListUsers(auth.OrgID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, users)
}
// Granting or removing the owner role is reserved to owners: an admin must
// never be able to mint an owner (and log in as it) or strip the owners above
// them. Everything below derives the actor from the session, never the body.
func actorMayGrantOwner(c *gin.Context) bool {
return auth.Role(c) == models.RoleOwner
}
func createOrgUser(c *gin.Context) {
var body struct {
Email string `json:"email"`
Password string `json:"password"`
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Email == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "email required"})
return
}
if body.Role == "" {
body.Role = models.RoleMember
}
if !models.ValidRole(body.Role) {
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
return
}
if body.Role == models.RoleOwner && !actorMayGrantOwner(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can create another owner"})
return
}
u, err := services.CreateUser(auth.OrgID(c), body.Email, body.Password, body.Role, "local")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, u)
}
func updateOrgUserRole(c *gin.Context) {
var body struct {
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Role == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "role required"})
return
}
if !models.ValidRole(body.Role) {
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
return
}
orgID, targetID := auth.OrgID(c), c.Param("id")
if targetID == auth.UserID(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "you cannot change your own role"})
return
}
target, err := services.GetUserInOrg(orgID, targetID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
if (body.Role == models.RoleOwner || target.Role == models.RoleOwner) && !actorMayGrantOwner(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can change owner roles"})
return
}
if err := services.UpdateUserRole(orgID, targetID, body.Role); err != nil {
c.JSON(orgUserErrStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func deleteOrgUser(c *gin.Context) {
orgID, targetID := auth.OrgID(c), c.Param("id")
if targetID == auth.UserID(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "you cannot remove your own account"})
return
}
target, err := services.GetUserInOrg(orgID, targetID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
if target.Role == models.RoleOwner && !actorMayGrantOwner(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can remove another owner"})
return
}
if err := services.DeleteUser(orgID, targetID); err != nil {
c.JSON(orgUserErrStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
// The last-owner guard is a rejected request, not a server fault — surface it
// as 409 so the UI shows the message rather than a generic failure.
func orgUserErrStatus(err error) int {
if errors.Is(err, services.ErrLastOwner) {
return http.StatusConflict
}
return http.StatusInternalServerError
}
func getOrgOIDC(c *gin.Context) {
cfg, err := services.GetOrgOIDC(auth.OrgID(c))
if err != nil {
c.JSON(http.StatusOK, gin.H{"enabled": false, "client_secret_set": false})
return
}
// The client secret itself is write-only (never serialized); expose only
// whether one is stored so the UI can say so without leaking it.
c.JSON(http.StatusOK, gin.H{
"org_id": cfg.OrgID,
"issuer": cfg.Issuer,
"client_id": cfg.ClientID,
"enabled": cfg.Enabled,
"updated_at": cfg.UpdatedAt,
"client_secret_set": cfg.ClientSecretEnc != "",
})
}
func putOrgOIDC(c *gin.Context) {
var body struct {
Issuer string `json:"issuer"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := services.SaveOrgOIDC(auth.OrgID(c), body.Issuer, body.ClientID, body.ClientSecret, body.Enabled); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Drop the cached provider so a rotated issuer takes effect immediately —
// an admin moving off a compromised IdP must not keep authenticating there.
auth.EvictOIDCProvider(auth.OrgID(c))
c.JSON(http.StatusOK, gin.H{"saved": true})
}
+37 -19
View File
@@ -7,6 +7,7 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/services"
)
@@ -18,19 +19,29 @@ func validName(s string) bool {
return s != "" && len(s) <= 128 && groupNamePattern.MatchString(s)
}
// secretsReadAuth validates the ESO bearer token on the public read endpoint.
// ctxSecretsOrgKey carries the org resolved from the ESO bearer token.
const ctxSecretsOrgKey = "km_secrets_org"
// secretsReadAuth validates the ESO bearer token on the public read endpoint
// and stashes the org the token belongs to.
//
// This is the one endpoint whose org does NOT come from the session or the
// host: External Secrets Operator calls it machine-to-machine with no session,
// so the token itself is the org-bearing credential.
func secretsReadAuth() gin.HandlerFunc {
return func(c *gin.Context) {
const prefix = "Bearer "
auth := c.GetHeader("Authorization")
if len(auth) <= len(prefix) || !strings.EqualFold(auth[:len(prefix)], prefix) {
authHeader := c.GetHeader("Authorization")
if len(authHeader) <= len(prefix) || !strings.EqualFold(authHeader[:len(prefix)], prefix) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
return
}
if !services.VerifySecretsReadToken(auth[len(prefix):]) {
orgID, ok := services.ResolveSecretsReadToken(authHeader[len(prefix):])
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
c.Set(ctxSecretsOrgKey, orgID)
c.Next()
}
}
@@ -40,7 +51,14 @@ func secretsReadAuth() gin.HandlerFunc {
// (ESO treats 404 as "deleted").
func esoGetGroup(c *gin.Context) {
group := c.Param("group")
values, err := services.GetSecretGroupDecrypted(group)
// Org comes from the bearer token (set by secretsReadAuth), not a session.
orgID := c.GetString(ctxSecretsOrgKey)
if orgID == "" {
// Defence in depth: never query the store unscoped.
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
values, err := services.GetSecretGroupDecrypted(orgID, group)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "store error"})
return
@@ -53,7 +71,7 @@ func esoGetGroup(c *gin.Context) {
}
func listSecretGroups(c *gin.Context) {
groups, err := services.ListSecretGroups()
groups, err := services.ListSecretGroups(auth.OrgID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -86,17 +104,17 @@ func createSecretGroup(c *gin.Context) {
return
}
}
if err := services.UpsertSecrets(body.Group, body.Values); err != nil {
if err := services.UpsertSecrets(auth.OrgID(c), body.Group, body.Values); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' created with keys: %s", body.Group, strings.Join(services.SortedKeys(body.Values), ", ")))
services.LogEvent(auth.OrgID(c), "secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' created with keys: %s", body.Group, strings.Join(services.SortedKeys(body.Values), ", ")))
c.JSON(http.StatusCreated, gin.H{"group": body.Group})
}
func getSecretGroup(c *gin.Context) {
group := c.Param("group")
secrets, err := services.GetSecretGroup(group)
secrets, err := services.GetSecretGroup(auth.OrgID(c), group)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -130,11 +148,11 @@ func putSecretGroup(c *gin.Context) {
return
}
}
if err := services.UpsertSecrets(group, values); err != nil {
if err := services.UpsertSecrets(auth.OrgID(c), group, values); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' keys updated: %s", group, strings.Join(services.SortedKeys(values), ", ")))
services.LogEvent(auth.OrgID(c), "secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' keys updated: %s", group, strings.Join(services.SortedKeys(values), ", ")))
c.JSON(http.StatusOK, gin.H{"saved": true})
}
@@ -147,42 +165,42 @@ func revealSecret(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
value, err := services.RevealSecret(group, body.Key)
value, err := services.RevealSecret(auth.OrgID(c), group, body.Key)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
services.LogEvent("secret.revealed", actorFromCtx(c), "", "", fmt.Sprintf("value of '%s/%s' revealed", group, body.Key))
services.LogEvent(auth.OrgID(c), "secret.revealed", actorFromCtx(c), "", "", fmt.Sprintf("value of '%s/%s' revealed", group, body.Key))
c.JSON(http.StatusOK, gin.H{"value": value})
}
func deleteSecretKey(c *gin.Context) {
group := c.Param("group")
key := c.Param("key")
if err := services.DeleteSecret(group, key); err != nil {
if err := services.DeleteSecret(auth.OrgID(c), group, key); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("secret.deleted", actorFromCtx(c), "", "", fmt.Sprintf("key '%s' deleted from group '%s'", key, group))
services.LogEvent(auth.OrgID(c), "secret.deleted", actorFromCtx(c), "", "", fmt.Sprintf("key '%s' deleted from group '%s'", key, group))
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
func deleteSecretGroup(c *gin.Context) {
group := c.Param("group")
if err := services.DeleteSecretGroup(group); err != nil {
if err := services.DeleteSecretGroup(auth.OrgID(c), group); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("secretgroup.deleted", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' deleted", group))
services.LogEvent(auth.OrgID(c), "secretgroup.deleted", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' deleted", group))
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
func rotateSecretsToken(c *gin.Context) {
token, err := services.RotateSecretsReadToken()
token, err := services.RotateSecretsReadToken(auth.OrgID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("secrets.token_rotated", actorFromCtx(c), "", "", "ESO read token rotated")
services.LogEvent(auth.OrgID(c), "secrets.token_rotated", actorFromCtx(c), "", "", "ESO read token rotated")
c.JSON(http.StatusOK, gin.H{"token": token})
}
+33 -31
View File
@@ -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})
}
+165
View File
@@ -0,0 +1,165 @@
package auth
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/services"
)
func SetSessionCookie(c *gin.Context, sessionID string) {
secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
http.SetCookie(c.Writer, &http.Cookie{
Name: sessionCookieName,
Value: sessionID,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
MaxAge: int(sessionTTL.Seconds()),
})
}
func HandleLocalLogin(c *gin.Context) {
var body struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password required"})
return
}
u, err := services.GetUserByEmail(body.Email)
if err != nil || !services.VerifyPassword(u, body.Password) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
sessionID, err := SaveSession(c.Request.Context(), &Session{
UserID: u.UserID, OrgID: u.OrgID, Role: u.Role, Email: u.Email,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
_ = services.TouchLastLogin(u.UserID)
SetSessionCookie(c, sessionID)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// HandleBootstrapStatus answers "does the caller need to run setup". It is
// unauthenticated, so it must not report instance-wide state to whoever asks:
// on an org host the answer is scoped to that org, and only the apex — the
// genuine first-run entry point — gets the global "no users anywhere" answer.
func HandleBootstrapStatus(c *gin.Context) {
var (
n int64
err error
)
if org, ok := OrgFromHost(c); ok {
n, err = services.CountOrgUsers(org.OrgID)
} else {
n, err = services.CountUsers()
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"needs_setup": n == 0})
}
// HandleBootstrap creates the very first org and its owner, so its guard stays
// deliberately global: it may run once on an empty instance and never again,
// regardless of which host it is called on.
func HandleBootstrap(c *gin.Context) {
n, err := services.CountUsers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if n > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "setup already complete"})
return
}
var body struct {
OrgName string `json:"org_name"`
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.OrgName == "" || body.Email == "" || len(body.Password) < 8 {
c.JSON(http.StatusBadRequest, gin.H{"error": "org_name, email, and password (>=8 chars) required"})
return
}
// An upgrade from single-tenant arrives here with no users but with the org
// the migrations created and stamped onto every legacy document. Creating a
// second org would put the owner somewhere else entirely, and since every
// org-scoped read filters on org_id, the operator would land in an empty
// Vantage with all their real data still under the migrated org — silent,
// total-looking data loss. So adopt the existing org instead, and only
// create when there genuinely is none. Same `switch orgCount` shape as
// migration 0002.
orgCount, err := services.CountOrgs()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var org *models.Org
switch orgCount {
case 0:
org, err = services.CreateOrg(body.OrgName)
case 1:
var existing *models.Org
existing, err = services.FirstOrg()
if err == nil {
org, err = services.AdoptOrg(existing.OrgID, body.OrgName)
}
default:
c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf(
"cannot bootstrap: %d organizations already exist but no users do; "+
"create the owner against the intended org rather than through setup, "+
"or remove the unintended orgs and retry", orgCount)})
return
}
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
u, err := services.CreateUser(org.OrgID, body.Email, body.Password, "owner", "local")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
sessionID, err := SaveSession(c.Request.Context(), &Session{
UserID: u.UserID, OrgID: u.OrgID, Role: u.Role, Email: u.Email,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
SetSessionCookie(c, sessionID)
c.JSON(http.StatusCreated, gin.H{"org": org, "slug": org.Slug})
}
func HandleMe(c *gin.Context) {
cookie, err := c.Request.Cookie(sessionCookieName)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
return
}
sess, err := GetSession(c.Request.Context(), cookie.Value)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "session expired"})
return
}
// /auth/me is registered outside Middleware, so it repeats the middleware's
// host/org match itself. Without this, a session for org A presented on org
// B's host would render the shell while every /api call 403s.
if hostOrg, ok := OrgFromHost(c); ok && hostOrg.OrgID != sess.OrgID {
c.JSON(http.StatusForbidden, gin.H{"error": "org host mismatch"})
return
}
org, _ := services.GetOrg(sess.OrgID)
c.JSON(http.StatusOK, gin.H{"user": sess, "org": org})
}
+47 -5
View File
@@ -14,13 +14,42 @@ func GetSessionFromContext(c *gin.Context) *Session {
return sess
}
func OrgID(c *gin.Context) string {
if s := GetSessionFromContext(c); s != nil {
return s.OrgID
}
return ""
}
func Role(c *gin.Context) string {
if s := GetSessionFromContext(c); s != nil {
return s.Role
}
return ""
}
func UserID(c *gin.Context) string {
if s := GetSessionFromContext(c); s != nil {
return s.UserID
}
return ""
}
func RequireRole(roles ...string) gin.HandlerFunc {
return func(c *gin.Context) {
r := Role(c)
for _, want := range roles {
if r == want {
c.Next()
return
}
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "insufficient role"})
}
}
func Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
if !authEnabled {
c.Next()
return
}
cookie, err := c.Request.Cookie(sessionCookieName)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
@@ -33,7 +62,20 @@ func Middleware() gin.HandlerFunc {
return
}
// An org-less session would turn every downstream scope into
// {"org_id": ""} — fail closed rather than query across tenants.
if sess.OrgID == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session has no organization"})
return
}
c.Set(ctxSessionKey, sess)
if hostOrg, ok := OrgFromHost(c); ok && hostOrg.OrgID != sess.OrgID {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "org host mismatch"})
return
}
c.Next()
}
}
+97 -83
View File
@@ -2,123 +2,155 @@ package auth
import (
"context"
"log"
"fmt"
"net/http"
"os"
"strings"
"sync"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/services"
"golang.org/x/oauth2"
)
var (
oidcProvider *oidc.Provider
oauth2Cfg *oauth2.Config
authEnabled bool
provMu sync.Mutex
provCache = map[string]*oidc.Provider{}
)
func InitOIDC(ctx context.Context) error {
issuer := os.Getenv("OIDC_ISSUER")
if issuer == "" {
log.Println("OIDC_ISSUER not set; authentication disabled")
return nil
}
p, err := oidc.NewProvider(ctx, issuer)
if err != nil {
return err
}
oidcProvider = p
oauth2Cfg = &oauth2.Config{
ClientID: os.Getenv("OIDC_CLIENT_ID"),
ClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
RedirectURL: os.Getenv("OIDC_REDIRECT_URL"),
Endpoint: p.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
authEnabled = true
log.Println("OIDC authentication enabled")
return nil
// EvictOIDCProvider drops an org's cached provider so the next login rediscovers
// it from the (possibly changed) issuer. Called by the API layer after the org's
// OIDC config is saved — services cannot import auth, so the handler wires it.
func EvictOIDCProvider(orgID string) {
provMu.Lock()
delete(provCache, orgID)
provMu.Unlock()
}
func Enabled() bool { return authEnabled }
func redirectURL(c *gin.Context) string {
scheme := "https"
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
scheme = "http"
}
return fmt.Sprintf("%s://%s/auth/oidc/callback", scheme, c.Request.Host)
}
func HandleLogin(c *gin.Context) {
state, err := randomHex(16)
// providerForOrg returns the org's (cached) OIDC provider plus a request-local
// oauth2 config. The config is never stored on the cached entry: RedirectURL is
// derived from this request's Host, so sharing it would let one in-flight login
// overwrite another's redirect URI.
func providerForOrg(ctx context.Context, c *gin.Context, orgID string) (*oidc.Provider, *oauth2.Config, error) {
cfg, err := services.GetOrgOIDC(orgID)
if err != nil || !cfg.Enabled {
return nil, nil, fmt.Errorf("org SSO not configured")
}
secret, err := services.GetOrgOIDCSecret(orgID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "state generation failed"})
return nil, nil, err
}
provMu.Lock()
p := provCache[orgID]
provMu.Unlock()
if p == nil {
p, err = oidc.NewProvider(ctx, cfg.Issuer)
if err != nil {
return nil, nil, err
}
provMu.Lock()
provCache[orgID] = p
provMu.Unlock()
}
return p, &oauth2.Config{
ClientID: cfg.ClientID, ClientSecret: secret,
RedirectURL: redirectURL(c), Endpoint: p.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}, nil
}
func HandleOIDCStart(c *gin.Context) {
org, ok := OrgFromHost(c)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown organization host"})
return
}
if err := SaveState(c.Request.Context(), state); err != nil {
ctx := c.Request.Context()
_, oauthCfg, err := providerForOrg(ctx, c, org.OrgID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
state, err := randomHex(16)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "state gen failed"})
return
}
if err := SaveStateOrg(ctx, state, org.OrgID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "state save failed"})
return
}
c.Redirect(http.StatusFound, oauth2Cfg.AuthCodeURL(state))
c.Redirect(http.StatusFound, oauthCfg.AuthCodeURL(state))
}
func HandleCallback(c *gin.Context) {
func HandleOIDCCallback(c *gin.Context) {
ctx := c.Request.Context()
if !ConsumeState(ctx, c.Query("state")) {
orgID, ok := ConsumeStateOrg(ctx, c.Query("state"))
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state"})
return
}
token, err := oauth2Cfg.Exchange(ctx, c.Query("code"))
provider, oauthCfg, err := providerForOrg(ctx, c, orgID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
token, err := oauthCfg.Exchange(ctx, c.Query("code"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "token exchange failed"})
return
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "missing id_token"})
return
}
verifier := oidcProvider.Verifier(&oidc.Config{ClientID: oauth2Cfg.ClientID})
idToken, err := verifier.Verify(ctx, rawIDToken)
idToken, err := provider.Verifier(&oidc.Config{ClientID: oauthCfg.ClientID}).Verify(ctx, rawIDToken)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "token verification failed"})
return
}
var claims struct {
Sub string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
}
if err := idToken.Claims(&claims); err != nil {
if err := idToken.Claims(&claims); err != nil || claims.Email == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "claims extraction failed"})
return
}
email := strings.ToLower(claims.Email)
u, err := services.GetUserByEmail(email)
if err != nil {
// provision new member in THIS org
u, err = services.CreateUser(orgID, email, "", "member", "oidc")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "provisioning failed"})
return
}
} else if u.OrgID != orgID {
c.JSON(http.StatusForbidden, gin.H{"error": "email belongs to a different organization"})
return
}
sessionID, err := SaveSession(ctx, &Session{
UserID: claims.Sub,
Email: claims.Email,
Name: claims.Name,
UserID: u.UserID, OrgID: u.OrgID, Role: u.Role, Email: u.Email, Name: claims.Name,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
http.SetCookie(c.Writer, &http.Cookie{
Name: sessionCookieName,
Value: sessionID,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
MaxAge: int(sessionTTL.Seconds()),
})
frontendURL := os.Getenv("PUBLIC_HOST")
if frontendURL == "" {
frontendURL = "/"
}
c.Redirect(http.StatusFound, frontendURL)
_ = services.TouchLastLogin(u.UserID)
SetSessionCookie(c, sessionID)
c.Redirect(http.StatusFound, "/")
}
func HandleLogout(c *gin.Context) {
@@ -134,21 +166,3 @@ func HandleLogout(c *gin.Context) {
})
c.Redirect(http.StatusFound, "/")
}
func HandleMe(c *gin.Context) {
if !authEnabled {
c.JSON(http.StatusOK, gin.H{"auth_enabled": false})
return
}
cookie, err := c.Request.Cookie(sessionCookieName)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
return
}
sess, err := GetSession(c.Request.Context(), cookie.Value)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "session expired"})
return
}
c.JSON(http.StatusOK, sess)
}
+80
View File
@@ -0,0 +1,80 @@
package auth
import (
"os"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/services"
)
type cachedOrg struct {
org *models.Org
at time.Time
}
var (
orgCacheMu sync.Mutex
orgCache = map[string]cachedOrg{}
)
const orgCacheTTL = 60 * time.Second
// appRootLabel is the DNS label the app is deployed under, i.e. the "vantage"
// in <slug>.vantage.<tld>. Deployments on another root must set APP_ROOT_LABEL
// or every host resolves to no org, disabling the host/session mismatch guard.
func appRootLabel() string {
if v := os.Getenv("APP_ROOT_LABEL"); v != "" {
return strings.ToLower(v)
}
return "vantage"
}
// hostSlug extracts the leftmost DNS label if the host is a subdomain of the
// app root. Returns "" for the apex or an unknown host shape.
func hostSlug(host string) string {
host = strings.ToLower(host)
if i := strings.IndexByte(host, ':'); i >= 0 {
host = host[:i]
}
root := appRootLabel()
// Expect <slug>.<root>.<...>; apex is <root>.<...>
parts := strings.Split(host, ".")
if len(parts) < 3 {
return ""
}
if parts[1] != root {
return ""
}
if parts[0] == root || parts[0] == "www" {
return ""
}
return parts[0]
}
func OrgFromHost(c *gin.Context) (*models.Org, bool) {
slug := hostSlug(c.Request.Host)
if slug == "" {
return nil, false
}
orgCacheMu.Lock()
if e, ok := orgCache[slug]; ok && time.Since(e.at) < orgCacheTTL {
orgCacheMu.Unlock()
return e.org, e.org != nil
}
orgCacheMu.Unlock()
org, err := services.GetOrgBySlug(slug)
if err != nil || org == nil {
// Never cache a miss: a just-bootstrapped org would otherwise 404 on its
// own subdomain for the rest of the TTL. Misses are cheap and rare.
return nil, false
}
orgCacheMu.Lock()
orgCache[slug] = cachedOrg{org: org, at: time.Now()}
orgCacheMu.Unlock()
return org, true
}
+10 -5
View File
@@ -17,6 +17,8 @@ const statePrefix = "km:state:"
type Session struct {
UserID string `json:"user_id"`
OrgID string `json:"org_id"`
Role string `json:"role"`
Email string `json:"email"`
Name string `json:"name"`
}
@@ -69,11 +71,14 @@ func DeleteSession(ctx context.Context, id string) error {
return rdb.Del(ctx, sessionPrefix+id).Err()
}
func SaveState(ctx context.Context, state string) error {
return rdb.Set(ctx, statePrefix+state, "1", 10*time.Minute).Err()
func SaveStateOrg(ctx context.Context, state, orgID string) error {
return rdb.Set(ctx, statePrefix+state, orgID, 10*time.Minute).Err()
}
func ConsumeState(ctx context.Context, state string) bool {
n, err := rdb.Del(ctx, statePrefix+state).Result()
return err == nil && n > 0
func ConsumeStateOrg(ctx context.Context, state string) (string, bool) {
orgID, err := rdb.GetDel(ctx, statePrefix+state).Result()
if err != nil || orgID == "" {
return "", false
}
return orgID, true
}
+8 -5
View File
@@ -63,13 +63,13 @@ func (s *vantageServer) UploadGeneratedKey(ctx context.Context, req *pb.UploadKe
}
// Agent-generated keys carry no passphrase over the wire (proto has no field).
key, err := services.CreateKey(req.Label, req.PublicKey, "generated", srv.ServerID, req.PrivateKey, "")
key, err := services.CreateKey(srv.OrgID, req.Label, req.PublicKey, "generated", srv.ServerID, req.PrivateKey, "")
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to store key: %v", err)
}
// Auto-assign to the generating server
if _, err := services.AssignKey(key.KeyID, srv.ServerID); err != nil {
if _, err := services.AssignKey(srv.OrgID, key.KeyID, srv.ServerID); err != nil {
log.Printf("failed to auto-assign generated key: %v", err)
}
@@ -112,7 +112,7 @@ func (s *vantageServer) SyncMonitors(ctx context.Context, req *pb.SyncMonitorsRe
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
}
monitors, err := services.ListMonitorsForRunner(srv.ServerID)
monitors, err := services.ListMonitorsForRunner(srv.OrgID, srv.ServerID)
if err != nil {
return nil, status.Errorf(codes.Internal, "list monitors")
}
@@ -137,7 +137,8 @@ func (s *vantageServer) SyncMonitors(ctx context.Context, req *pb.SyncMonitorsRe
}
func (s *vantageServer) ReportChecks(ctx context.Context, req *pb.ReportChecksRequest) (*pb.ReportChecksResponse, error) {
if _, err := services.ValidateAgentToken(req.ServerId, req.AgentToken); err != nil {
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
}
for _, r := range req.Results {
@@ -146,7 +147,9 @@ func (s *vantageServer) ReportChecks(ctx context.Context, req *pb.ReportChecksRe
t := time.Unix(r.CertExpiryUnix, 0)
res.CertExpiry = &t
}
if err := services.IngestResult(r.MonitorId, res); err != nil {
// A rejected monitor (wrong org, or not run by this agent) is skipped,
// not fatal — the rest of the batch is still legitimate.
if err := services.IngestResult(srv.OrgID, srv.ServerID, r.MonitorId, res); err != nil {
log.Printf("ingest check %s: %v", r.MonitorId, err)
}
}
+5 -4
View File
@@ -8,8 +8,9 @@ import (
type Assignment struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
KeyID string `bson:"key_id" json:"key_id"`
ServerID string `bson:"server_id" json:"server_id"`
AssignedAt time.Time `bson:"assigned_at" json:"assigned_at"`
RevokedAt *time.Time `bson:"revoked_at,omitempty" json:"revoked_at,omitempty"`
OrgID string `bson:"org_id" json:"org_id"`
KeyID string `bson:"key_id" json:"key_id"`
ServerID string `bson:"server_id" json:"server_id"`
AssignedAt time.Time `bson:"assigned_at" json:"assigned_at"`
RevokedAt *time.Time `bson:"revoked_at,omitempty" json:"revoked_at,omitempty"`
}
+1
View File
@@ -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"`
+1
View File
@@ -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"`
@@ -8,6 +8,7 @@ import (
type ConsoleSession struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
OrgID string `bson:"org_id" json:"org_id"`
SessionID string `bson:"session_id" json:"session_id"`
ServerID string `bson:"server_id" json:"server_id"`
Protocol string `bson:"protocol" json:"protocol"` // ssh | rdp | vnc
+1
View File
@@ -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"`
+4 -1
View File
@@ -48,12 +48,13 @@ type MonitorState struct {
type Monitor struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
OrgID string `bson:"org_id" json:"org_id"`
MonitorID string `bson:"monitor_id" json:"monitor_id"`
Name string `bson:"name" json:"name"`
Type string `bson:"type" json:"type"` // http|tcp|icmp|tls
Target MonitorTarget `bson:"target" json:"target"`
IntervalSec int `bson:"interval_sec" json:"interval_sec"`
Runner string `bson:"runner" json:"runner"` // "server" or a server_id
Runner string `bson:"runner" json:"runner"` // "server" or a server_id
Retries int `bson:"retries" json:"retries"` // consecutive fails before down
Enabled bool `bson:"enabled" json:"enabled"`
ChannelIDs []string `bson:"channel_ids,omitempty" json:"channel_ids,omitempty"`
@@ -62,6 +63,7 @@ type Monitor struct {
}
type Incident struct {
OrgID string `bson:"org_id" json:"org_id"`
IncidentID string `bson:"incident_id" json:"incident_id"`
MonitorID string `bson:"monitor_id" json:"monitor_id"`
StartedAt time.Time `bson:"started_at" json:"started_at"`
@@ -70,6 +72,7 @@ type Incident struct {
}
type Rollup struct {
OrgID string `bson:"org_id" json:"org_id"`
MonitorID string `bson:"monitor_id" json:"monitor_id"`
PeriodStart time.Time `bson:"period_start" json:"period_start"` // hour bucket
Checks int `bson:"checks" json:"checks"`
+15
View File
@@ -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"`
}
+17
View File
@@ -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"`
}
+1
View File
@@ -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:"-"`
+19 -18
View File
@@ -44,23 +44,24 @@ type Inventory struct {
}
type Server struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
ServerID string `bson:"server_id" json:"server_id"`
Hostname string `bson:"hostname" json:"hostname"`
IPAddress string `bson:"ip_address" json:"ip_address"`
OSInfo string `bson:"os_info" json:"os_info"`
OSType string `bson:"os_type,omitempty" json:"os_type,omitempty"`
ConsoleProtocols []string `bson:"console_protocols,omitempty" json:"console_protocols,omitempty"`
SSHPort int `bson:"ssh_port,omitempty" json:"ssh_port,omitempty"`
RDPPort int `bson:"rdp_port,omitempty" json:"rdp_port,omitempty"`
PreRegToken string `bson:"pre_reg_token,omitempty" json:"pre_reg_token,omitempty"`
PreRegExpires *time.Time `bson:"pre_reg_expires,omitempty" json:"pre_reg_expires,omitempty"`
AgentTokenHash string `bson:"agent_token_hash,omitempty" json:"-"`
Status string `bson:"status" json:"status"`
AgentVersion string `bson:"agent_version,omitempty" json:"agent_version,omitempty"`
LastSeen *time.Time `bson:"last_seen,omitempty" json:"last_seen,omitempty"`
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
OrgID string `bson:"org_id" json:"org_id"`
ServerID string `bson:"server_id" json:"server_id"`
Hostname string `bson:"hostname" json:"hostname"`
IPAddress string `bson:"ip_address" json:"ip_address"`
OSInfo string `bson:"os_info" json:"os_info"`
OSType string `bson:"os_type,omitempty" json:"os_type,omitempty"`
ConsoleProtocols []string `bson:"console_protocols,omitempty" json:"console_protocols,omitempty"`
SSHPort int `bson:"ssh_port,omitempty" json:"ssh_port,omitempty"`
RDPPort int `bson:"rdp_port,omitempty" json:"rdp_port,omitempty"`
PreRegToken string `bson:"pre_reg_token,omitempty" json:"pre_reg_token,omitempty"`
PreRegExpires *time.Time `bson:"pre_reg_expires,omitempty" json:"pre_reg_expires,omitempty"`
AgentTokenHash string `bson:"agent_token_hash,omitempty" json:"-"`
Status string `bson:"status" json:"status"`
AgentVersion string `bson:"agent_version,omitempty" json:"agent_version,omitempty"`
LastSeen *time.Time `bson:"last_seen,omitempty" json:"last_seen,omitempty"`
AvailableUpdates []PackageUpdate `bson:"available_updates,omitempty" json:"available_updates,omitempty"`
UpdatesCheckedAt *time.Time `bson:"updates_checked_at,omitempty" json:"updates_checked_at,omitempty"`
Inventory *Inventory `bson:"inventory,omitempty" json:"inventory,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatesCheckedAt *time.Time `bson:"updates_checked_at,omitempty" json:"updates_checked_at,omitempty"`
Inventory *Inventory `bson:"inventory,omitempty" json:"inventory,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
+1
View File
@@ -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"`
+35
View File
@@ -0,0 +1,35 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Org membership roles. These are the only values ever written to User.Role;
// anything arriving from a client must be checked with ValidRole first.
const (
RoleOwner = "owner"
RoleAdmin = "admin"
RoleMember = "member"
)
func ValidRole(role string) bool {
switch role {
case RoleOwner, RoleAdmin, RoleMember:
return true
}
return false
}
type User struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
UserID string `bson:"user_id" json:"user_id"`
OrgID string `bson:"org_id" json:"org_id"`
Email string `bson:"email" json:"email"`
PasswordHash string `bson:"password_hash,omitempty" json:"-"`
Role string `bson:"role" json:"role"` // owner|admin|member
AuthSource string `bson:"auth_source" json:"auth_source"` // local|oidc
CreatedAt time.Time `bson:"created_at" json:"created_at"`
LastLogin *time.Time `bson:"last_login,omitempty" json:"last_login,omitempty"`
}
+7 -4
View File
@@ -14,6 +14,7 @@ type InputParam struct {
type WorkflowStep struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
OrgID string `bson:"org_id" json:"org_id"`
StepID string `bson:"step_id" json:"step_id"`
Name string `bson:"name" json:"name"`
Description string `bson:"description" json:"description"`
@@ -45,6 +46,7 @@ type StepOverride struct {
type Workflow struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
OrgID string `bson:"org_id" json:"org_id"`
WorkflowID string `bson:"workflow_id" json:"workflow_id"`
Name string `bson:"name" json:"name"`
TargetServerIDs []string `bson:"target_server_ids" json:"target_server_ids"`
@@ -55,10 +57,10 @@ type Workflow struct {
// ResolvedStep is a step frozen into a run snapshot (library step + overrides applied).
type ResolvedStep struct {
Order int `bson:"order" json:"order"`
Name string `bson:"name" json:"name"`
Interpreter string `bson:"interpreter" json:"interpreter"`
Script string `bson:"script" json:"script"`
Order int `bson:"order" json:"order"`
Name string `bson:"name" json:"name"`
Interpreter string `bson:"interpreter" json:"interpreter"`
Script string `bson:"script" json:"script"`
SecretRefs []string `bson:"secret_refs" json:"secret_refs"`
OnFailure string `bson:"on_failure" json:"on_failure"`
MaxRetries int `bson:"max_retries" json:"max_retries"`
@@ -89,6 +91,7 @@ type ServerRun struct {
type WorkflowRun struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
OrgID string `bson:"org_id" json:"org_id"`
RunID string `bson:"run_id" json:"run_id"`
WorkflowID string `bson:"workflow_id" json:"workflow_id"`
Name string `bson:"name" json:"name"`
+2 -2
View File
@@ -35,7 +35,7 @@ func loop(ctx context.Context) {
var mu sync.Mutex
sync := func() {
monitors, err := services.ListMonitorsForRunner(models.RunnerServer)
monitors, err := services.ListServerScheduledMonitors()
if err != nil {
log.Printf("monitorsched: list monitors: %v", err)
return
@@ -88,7 +88,7 @@ func runMonitor(ctx context.Context, m models.Monitor) {
run := func() {
res := checker.Run(ctx, spec)
if err := services.IngestResult(m.MonitorID, res); err != nil {
if err := services.IngestServerScheduledResult(m.MonitorID, res); err != nil {
log.Printf("monitorsched: ingest %s: %v", m.MonitorID, err)
}
}
+4 -3
View File
@@ -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
}
+30 -14
View File
@@ -13,10 +13,10 @@ import (
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func ListChannels() ([]models.NotificationChannel, error) {
func ListChannels(orgID string) ([]models.NotificationChannel, error) {
ctx, cancel := monCtx()
defer cancel()
cur, err := db.Col("notification_channels").Find(ctx, bson.M{}, options.Find().SetSort(bson.M{"created_at": 1}))
cur, err := db.Col("notification_channels").Find(ctx, bson.M{"org_id": orgID}, options.Find().SetSort(bson.M{"created_at": 1}))
if err != nil {
return nil, err
}
@@ -27,11 +27,11 @@ func ListChannels() ([]models.NotificationChannel, error) {
return out, nil
}
func GetChannel(channelID string) (*models.NotificationChannel, error) {
func GetChannel(orgID, channelID string) (*models.NotificationChannel, error) {
ctx, cancel := monCtx()
defer cancel()
var ch models.NotificationChannel
err := db.Col("notification_channels").FindOne(ctx, bson.M{"channel_id": channelID}).Decode(&ch)
err := db.Col("notification_channels").FindOne(ctx, bson.M{"channel_id": channelID, "org_id": orgID}).Decode(&ch)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, nil
}
@@ -41,14 +41,14 @@ func GetChannel(channelID string) (*models.NotificationChannel, error) {
return &ch, nil
}
// GetChannels loads multiple channels by ID, skipping any not found.
func GetChannels(channelIDs []string) ([]models.NotificationChannel, error) {
// GetChannels loads multiple channels by ID within an org, skipping any not found.
func GetChannels(orgID string, channelIDs []string) ([]models.NotificationChannel, error) {
if len(channelIDs) == 0 {
return nil, nil
}
ctx, cancel := monCtx()
defer cancel()
cur, err := db.Col("notification_channels").Find(ctx, bson.M{"channel_id": bson.M{"$in": channelIDs}})
cur, err := db.Col("notification_channels").Find(ctx, bson.M{"org_id": orgID, "channel_id": bson.M{"$in": channelIDs}})
if err != nil {
return nil, err
}
@@ -59,9 +59,25 @@ func GetChannels(channelIDs []string) ([]models.NotificationChannel, error) {
return out, nil
}
func CreateChannel(ch *models.NotificationChannel) (*models.NotificationChannel, error) {
// validateChannelIDs rejects any channel that does not belong to the org.
// Channel IDs arrive from the client as data on monitor writes.
func validateChannelIDs(orgID string, channelIDs []string) error {
for _, id := range channelIDs {
ch, err := GetChannel(orgID, id)
if err != nil {
return err
}
if ch == nil {
return errors.New("channel " + id + " not found")
}
}
return nil
}
func CreateChannel(orgID string, ch *models.NotificationChannel) (*models.NotificationChannel, error) {
ctx, cancel := monCtx()
defer cancel()
ch.OrgID = orgID
ch.ChannelID = uuid.NewString()
ch.CreatedAt = time.Now()
if ch.Config == nil {
@@ -73,23 +89,23 @@ func CreateChannel(ch *models.NotificationChannel) (*models.NotificationChannel,
return ch, nil
}
func UpdateChannel(channelID string, upd bson.M) error {
func UpdateChannel(orgID, channelID string, upd bson.M) error {
ctx, cancel := monCtx()
defer cancel()
_, err := db.Col("notification_channels").UpdateOne(ctx, bson.M{"channel_id": channelID}, bson.M{"$set": upd})
_, err := db.Col("notification_channels").UpdateOne(ctx, bson.M{"channel_id": channelID, "org_id": orgID}, bson.M{"$set": upd})
return err
}
func DeleteChannel(channelID string) error {
func DeleteChannel(orgID, channelID string) error {
ctx, cancel := monCtx()
defer cancel()
_, err := db.Col("notification_channels").DeleteOne(ctx, bson.M{"channel_id": channelID})
_, err := db.Col("notification_channels").DeleteOne(ctx, bson.M{"channel_id": channelID, "org_id": orgID})
return err
}
// TestChannel sends a synthetic alert to verify configuration.
func TestChannel(channelID string) error {
ch, err := GetChannel(channelID)
func TestChannel(orgID, channelID string) error {
ch, err := GetChannel(orgID, channelID)
if err != nil {
return err
}
+15 -14
View File
@@ -129,11 +129,12 @@ func BuildGuacParams(srv *models.Server, protocol, sshUser, privateKey, passphra
}
}
func CreateConsoleSession(serverID, protocol, keyID, user, clientIP string) (*models.ConsoleSession, error) {
func CreateConsoleSession(orgID, serverID, protocol, keyID, user, clientIP string) (*models.ConsoleSession, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
s := &models.ConsoleSession{
OrgID: orgID,
SessionID: uuid.NewString(),
ServerID: serverID,
Protocol: protocol,
@@ -148,11 +149,11 @@ func CreateConsoleSession(serverID, protocol, keyID, user, clientIP string) (*mo
return s, nil
}
func GetConsoleSession(sessionID string) (*models.ConsoleSession, error) {
func GetConsoleSession(orgID, sessionID string) (*models.ConsoleSession, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var s models.ConsoleSession
if err := db.Col("console_sessions").FindOne(ctx, bson.M{"session_id": sessionID}).Decode(&s); err != nil {
if err := db.Col("console_sessions").FindOne(ctx, bson.M{"session_id": sessionID, "org_id": orgID}).Decode(&s); err != nil {
return nil, err
}
return &s, nil
@@ -160,7 +161,7 @@ func GetConsoleSession(sessionID string) (*models.ConsoleSession, error) {
// StashConsoleRDPCreds encrypts and stores single-use RDP credentials on the
// session document. They are consumed (and cleared) when the tunnel opens.
func StashConsoleRDPCreds(sessionID, username, password string) error {
func StashConsoleRDPCreds(orgID, sessionID, username, password string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
u, err := encryptString(username)
@@ -172,7 +173,7 @@ func StashConsoleRDPCreds(sessionID, username, password string) error {
return err
}
_, err = db.Col("console_sessions").UpdateOne(ctx,
bson.M{"session_id": sessionID},
bson.M{"session_id": sessionID, "org_id": orgID},
bson.M{"$set": bson.M{"rdp_user_enc": u, "rdp_pass_enc": p}},
)
return err
@@ -181,8 +182,8 @@ func StashConsoleRDPCreds(sessionID, username, password string) error {
// ConsumeConsoleRDPCreds decrypts and returns the stored RDP credentials, then
// clears them from the session document (single-use). Returns empty strings if
// none were stored.
func ConsumeConsoleRDPCreds(sessionID string) (username, password string, err error) {
s, err := GetConsoleSession(sessionID)
func ConsumeConsoleRDPCreds(orgID, sessionID string) (username, password string, err error) {
s, err := GetConsoleSession(orgID, sessionID)
if err != nil {
return "", "", err
}
@@ -202,18 +203,18 @@ func ConsumeConsoleRDPCreds(sessionID string) (username, password string, err er
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, _ = db.Col("console_sessions").UpdateOne(ctx,
bson.M{"session_id": sessionID},
bson.M{"session_id": sessionID, "org_id": orgID},
bson.M{"$unset": bson.M{"rdp_user_enc": "", "rdp_pass_enc": ""}},
)
return username, password, nil
}
// SetConsoleSSHUser persists the SSH username to use on the session doc.
func SetConsoleSSHUser(sessionID, username string) error {
func SetConsoleSSHUser(orgID, sessionID, username string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := db.Col("console_sessions").UpdateOne(ctx,
bson.M{"session_id": sessionID},
bson.M{"session_id": sessionID, "org_id": orgID},
bson.M{"$set": bson.M{"ssh_username": username}})
return err
}
@@ -221,12 +222,12 @@ func SetConsoleSSHUser(sessionID, username string) error {
// ConsumeSessionToken atomically marks a session's one-time token as spent.
// It returns an error if the token was already consumed (replay) or the session
// does not exist, so the tunnel can be opened at most once per issued token.
func ConsumeSessionToken(sessionID string) error {
func ConsumeSessionToken(orgID, sessionID string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
now := time.Now()
res, err := db.Col("console_sessions").UpdateOne(ctx,
bson.M{"session_id": sessionID, "token_consumed_at": nil},
bson.M{"session_id": sessionID, "org_id": orgID, "token_consumed_at": nil},
bson.M{"$set": bson.M{"token_consumed_at": now}},
)
if err != nil {
@@ -238,12 +239,12 @@ func ConsumeSessionToken(sessionID string) error {
return nil
}
func EndConsoleSession(sessionID string) error {
func EndConsoleSession(orgID, sessionID string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
now := time.Now()
_, err := db.Col("console_sessions").UpdateOne(ctx,
bson.M{"session_id": sessionID, "ended_at": nil},
bson.M{"session_id": sessionID, "org_id": orgID, "ended_at": nil},
bson.M{"$set": bson.M{"ended_at": now}},
)
return err
-111
View File
@@ -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)
}
}
+3 -2
View File
@@ -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",
-38
View File
@@ -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])
}
}
+37 -23
View File
@@ -36,8 +36,9 @@ func setKeyMeta(k *models.Key) {
k.HasPassphrase = k.PassphraseEncrypted != ""
}
func CreateKey(label, publicKey, source, generatedByServerID, privateKey, passphrase string) (*models.Key, error) {
func CreateKey(orgID, label, publicKey, source, generatedByServerID, privateKey, passphrase string) (*models.Key, error) {
key := &models.Key{
OrgID: orgID,
KeyID: uuid.NewString(),
Label: label,
PublicKey: publicKey,
@@ -71,12 +72,12 @@ func CreateKey(label, publicKey, source, generatedByServerID, privateKey, passph
return key, nil
}
func GetKey(keyID string) (*models.Key, error) {
func GetKey(orgID, keyID string) (*models.Key, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var key models.Key
err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID}).Decode(&key)
err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "org_id": orgID}).Decode(&key)
if err != nil {
return nil, err
}
@@ -84,12 +85,12 @@ func GetKey(keyID string) (*models.Key, error) {
return &key, nil
}
func GetPrivateKey(keyID string) (string, error) {
func GetPrivateKey(orgID, keyID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var key models.Key
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID}).Decode(&key); err != nil {
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "org_id": orgID}).Decode(&key); err != nil {
return "", err
}
if key.PrivateKeyEncrypted == "" {
@@ -99,7 +100,8 @@ func GetPrivateKey(keyID string) (string, error) {
}
// GetPassphrase returns the decrypted passphrase for a key, or an empty string
// if the key has none stored.
// if the key has none stored. Agent-path (keyed by unique key_id from an
// assignment lookup) — no org filter.
func GetPassphrase(keyID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -119,11 +121,11 @@ type KeyWithCount struct {
AssignedCount int `bson:"-" json:"assigned_count"`
}
func ListKeys() ([]KeyWithCount, error) {
func ListKeys(orgID string) ([]KeyWithCount, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cursor, err := db.Col("keys").Find(ctx, bson.M{})
cursor, err := db.Col("keys").Find(ctx, bson.M{"org_id": orgID})
if err != nil {
return nil, err
}
@@ -138,6 +140,7 @@ func ListKeys() ([]KeyWithCount, error) {
for _, k := range keys {
setKeyMeta(&k)
count, _ := db.Col("assignments").CountDocuments(ctx, bson.M{
"org_id": orgID,
"key_id": k.KeyID,
"revoked_at": nil,
})
@@ -146,19 +149,19 @@ func ListKeys() ([]KeyWithCount, error) {
return result, nil
}
func DeleteKey(keyID string) error {
func DeleteKey(orgID, keyID string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var key models.Key
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID}).Decode(&key); err != nil {
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "org_id": orgID}).Decode(&key); err != nil {
return err
}
if _, err := db.Col("keys").DeleteOne(ctx, bson.M{"key_id": keyID}); err != nil {
if _, err := db.Col("keys").DeleteOne(ctx, bson.M{"key_id": keyID, "org_id": orgID}); err != nil {
return err
}
if _, err := db.Col("assignments").DeleteMany(ctx, bson.M{"key_id": keyID}); err != nil {
if _, err := db.Col("assignments").DeleteMany(ctx, bson.M{"key_id": keyID, "org_id": orgID}); err != nil {
return err
}
@@ -168,13 +171,23 @@ func DeleteKey(keyID string) error {
return nil
}
func AssignKey(keyID, serverID string) (*models.Assignment, error) {
func AssignKey(orgID, keyID, serverID string) (*models.Assignment, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Both sides must belong to the caller's org — the IDs arrive from the
// client as data and are consumed by unscoped agent-path queries later.
if _, err := GetKey(orgID, keyID); err != nil {
return nil, fmt.Errorf("key not found")
}
if _, err := GetServer(orgID, serverID); err != nil {
return nil, fmt.Errorf("server not found")
}
// Check if already assigned and active
var existing models.Assignment
err := db.Col("assignments").FindOne(ctx, bson.M{
"org_id": orgID,
"key_id": keyID,
"server_id": serverID,
"revoked_at": nil,
@@ -184,6 +197,7 @@ func AssignKey(keyID, serverID string) (*models.Assignment, error) {
}
a := &models.Assignment{
OrgID: orgID,
KeyID: keyID,
ServerID: serverID,
AssignedAt: time.Now(),
@@ -195,23 +209,23 @@ func AssignKey(keyID, serverID string) (*models.Assignment, error) {
return a, nil
}
func RevokeAssignment(keyID, serverID string) error {
func RevokeAssignment(orgID, keyID, serverID string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
now := time.Now()
_, err := db.Col("assignments").UpdateOne(ctx,
bson.M{"key_id": keyID, "server_id": serverID, "revoked_at": nil},
bson.M{"org_id": orgID, "key_id": keyID, "server_id": serverID, "revoked_at": nil},
bson.M{"$set": bson.M{"revoked_at": now}},
)
return err
}
func GetAssignmentsForKey(keyID string) ([]models.Assignment, error) {
func GetAssignmentsForKey(orgID, keyID string) ([]models.Assignment, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cursor, err := db.Col("assignments").Find(ctx, bson.M{"key_id": keyID, "revoked_at": nil})
cursor, err := db.Col("assignments").Find(ctx, bson.M{"org_id": orgID, "key_id": keyID, "revoked_at": nil})
if err != nil {
return nil, err
}
@@ -229,11 +243,11 @@ type AssignmentWithServer struct {
Server *models.Server `json:"server,omitempty"`
}
func GetAssignmentsWithServers(keyID string) ([]AssignmentWithServer, error) {
func GetAssignmentsWithServers(orgID, keyID string) ([]AssignmentWithServer, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cursor, err := db.Col("assignments").Find(ctx, bson.M{"key_id": keyID})
cursor, err := db.Col("assignments").Find(ctx, bson.M{"org_id": orgID, "key_id": keyID})
if err != nil {
return nil, err
}
@@ -248,7 +262,7 @@ func GetAssignmentsWithServers(keyID string) ([]AssignmentWithServer, error) {
for _, a := range assignments {
item := AssignmentWithServer{Assignment: a}
var srv models.Server
if err := db.Col("servers").FindOne(ctx, bson.M{"server_id": a.ServerID}).Decode(&srv); err == nil {
if err := db.Col("servers").FindOne(ctx, bson.M{"server_id": a.ServerID, "org_id": orgID}).Decode(&srv); err == nil {
item.Server = &srv
}
result = append(result, item)
@@ -261,11 +275,11 @@ type AssignmentWithKey struct {
Key *models.Key `json:"key,omitempty"`
}
func GetAssignmentsWithKeysForServer(serverID string) ([]AssignmentWithKey, error) {
func GetAssignmentsWithKeysForServer(orgID, serverID string) ([]AssignmentWithKey, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cursor, err := db.Col("assignments").Find(ctx, bson.M{"server_id": serverID})
cursor, err := db.Col("assignments").Find(ctx, bson.M{"org_id": orgID, "server_id": serverID})
if err != nil {
return nil, err
}
@@ -279,7 +293,7 @@ func GetAssignmentsWithKeysForServer(serverID string) ([]AssignmentWithKey, erro
result := make([]AssignmentWithKey, 0, len(assignments))
for _, a := range assignments {
var key models.Key
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID}).Decode(&key); err != nil {
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID, "org_id": orgID}).Decode(&key); err != nil {
continue
}
setKeyMeta(&key)
+269
View File
@@ -0,0 +1,269 @@
package services
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
var scopedCollections = []string{
"servers", "keys", "assignments", "secrets",
"workflows", "workflow_steps", "workflow_runs",
"audit_logs", "monitors", "notification_channels",
"console_sessions", "incidents", "monitor_rollups",
}
// EnsureAuthIndexes creates unique indexes for the new auth collections.
func EnsureAuthIndexes() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := db.Col("users").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "email", Value: 1}},
Options: options.Index().SetUnique(true),
}); err != nil {
return err
}
if _, err := db.Col("orgs").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "slug", Value: 1}},
Options: options.Index().SetUnique(true),
}); err != nil {
return err
}
if _, err := db.Col("org_oidc").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "org_id", Value: 1}},
Options: options.Index().SetUnique(true),
}); err != nil {
return err
}
return nil
}
// defaultBackfillOrg resolves the org that org-less legacy documents belong to:
// the "default" org, created if absent. Shared by 0001 and 0003 so an instance
// that ran either one converges on the same org.
func defaultBackfillOrg(ctx context.Context) (*models.Org, error) {
var org models.Org
err := db.Col("orgs").FindOne(ctx, bson.M{"slug": "default"}).Decode(&org)
switch {
case err == nil:
case errors.Is(err, mongo.ErrNoDocuments):
// Only a genuine absence justifies an insert. Treating a timeout or a
// decode failure as "absent" would race the fatal unique orgs.slug index
// and turn a transient blip into a boot crash.
org = models.Org{OrgID: uuid.NewString(), Name: "Default", Slug: "default", CreatedAt: time.Now()}
if _, err := db.Col("orgs").InsertOne(ctx, org); err != nil {
return nil, err
}
default:
return nil, err
}
return &org, nil
}
// RunMigrations backfills a default org onto pre-existing documents. Idempotent
// via a marker in the migrations collection.
func RunMigrations() error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
const marker = "0001_default_org_backfill"
if n, _ := db.Col("migrations").CountDocuments(ctx, bson.M{"_id": marker}); n > 0 {
return nil
}
// Only backfill if there is legacy data lacking org_id.
needs := false
for _, col := range scopedCollections {
n, _ := db.Col(col).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": false}})
if n > 0 {
needs = true
break
}
}
if needs {
org, err := defaultBackfillOrg(ctx)
if err != nil {
return err
}
for _, col := range scopedCollections {
if _, err := db.Col(col).UpdateMany(ctx,
bson.M{"org_id": bson.M{"$exists": false}},
bson.M{"$set": bson.M{"org_id": org.OrgID}},
); err != nil {
return err
}
}
}
_, err := db.Col("migrations").InsertOne(ctx, bson.M{"_id": marker, "applied_at": time.Now()})
return err
}
// MigrateMissedOrgScopes repairs collections that migration 0001 could not
// reach. 0001 originally listed "audit" and "channels", but the real collections
// are audit_logs and notification_channels, so on any instance that ran that
// version those documents were left without org_id — invisible to org-filtered
// reads, and in the channels' case silently non-firing. The 0001 marker is
// already written there, so renaming alone does not repair them; this migration
// converges both the never-migrated and the incorrectly-migrated case.
//
// It also stamps console_sessions, incidents and monitor_rollups, which gained
// an org_id only after 0001 shipped. Those carry an owning monitor/server whose
// org is authoritative, so they are derived rather than defaulted. Idempotent
// via a marker in the migrations collection.
func MigrateMissedOrgScopes() error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
const marker = "0003_missed_org_scopes"
if n, _ := db.Col("migrations").CountDocuments(ctx, bson.M{"_id": marker}); n > 0 {
return nil
}
// Same org resolution 0001 uses, for the collections it meant to cover.
missed := []string{"audit_logs", "notification_channels"}
needs := false
for _, col := range missed {
n, _ := db.Col(col).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": false}})
if n > 0 {
needs = true
break
}
}
if needs {
org, err := defaultBackfillOrg(ctx)
if err != nil {
return err
}
for _, col := range missed {
if _, err := db.Col(col).UpdateMany(ctx,
bson.M{"org_id": bson.M{"$exists": false}},
bson.M{"$set": bson.M{"org_id": org.OrgID}},
); err != nil {
return err
}
}
}
// Derived from the owning record — defaulting these would hand one org
// another org's console history and incident timeline.
if err := backfillOrgFromOwner(ctx, "console_sessions", "server_id", "servers", "server_id"); err != nil {
return err
}
if err := backfillOrgFromOwner(ctx, "incidents", "monitor_id", "monitors", "monitor_id"); err != nil {
return err
}
if err := backfillOrgFromOwner(ctx, "monitor_rollups", "monitor_id", "monitors", "monitor_id"); err != nil {
return err
}
_, err := db.Col("migrations").InsertOne(ctx, bson.M{"_id": marker, "applied_at": time.Now()})
return err
}
// backfillOrgFromOwner stamps org_id on every doc in col that lacks one, taking
// the org from the record in ownerCol it points at. Orphans (owner already
// deleted) are left alone; they are unreachable either way.
func backfillOrgFromOwner(ctx context.Context, col, localField, ownerCol, ownerField string) error {
// Decoded loosely: a single null or non-string value in the collection would
// fail a []string decode and abort the migration — and therefore boot — over
// one unusable document. Skip what we cannot use instead.
var raw []bson.RawValue
if err := db.Col(col).Distinct(ctx, localField,
bson.M{"org_id": bson.M{"$exists": false}}).Decode(&raw); err != nil {
return err
}
for _, rv := range raw {
id, ok := rv.StringValueOK()
if !ok || id == "" {
continue
}
var owner struct {
OrgID string `bson:"org_id"`
}
if err := db.Col(ownerCol).FindOne(ctx, bson.M{ownerField: id}).Decode(&owner); err != nil {
continue
}
if owner.OrgID == "" {
continue
}
if _, err := db.Col(col).UpdateMany(ctx,
bson.M{localField: id, "org_id": bson.M{"$exists": false}},
bson.M{"$set": bson.M{"org_id": owner.OrgID}},
); err != nil {
return err
}
}
return nil
}
// MigrateSettingsOrg stamps the legacy global settings singleton with the
// default org's ID. Without it an upgrade would orphan the existing SMTP
// config, alert config, retention setting, and ESO read token. Idempotent via
// a marker in the migrations collection.
func MigrateSettingsOrg() error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
const marker = "0002_settings_org_backfill"
if n, _ := db.Col("migrations").CountDocuments(ctx, bson.M{"_id": marker}); n > 0 {
return nil
}
n, _ := db.Col("settings").CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": false}})
if n > 0 {
// The org-less settings doc belongs to whichever org already exists —
// migration 0001 only creates a "default" org when there was legacy
// data to backfill, so keying off that slug would invent a phantom org
// and move the real org's config onto it.
var org models.Org
orgCount, err := db.Col("orgs").CountDocuments(ctx, bson.M{})
if err != nil {
return err
}
switch orgCount {
case 1:
if err := db.Col("orgs").FindOne(ctx, bson.M{}).Decode(&org); err != nil {
return err
}
case 0:
org = models.Org{OrgID: uuid.NewString(), Name: "Default", Slug: "default", CreatedAt: time.Now()}
if _, err := db.Col("orgs").InsertOne(ctx, org); err != nil {
return err
}
default:
// Ambiguous: several orgs but an unstamped settings doc. Guessing
// would hand one org another's SMTP config and ESO token. Continuing
// is not an option either: the unique settings.org_id index built
// straight after this indexes every unstamped doc as null, so two or
// more of them collide and boot fails there instead — with a far less
// useful message. Stop here, where we can name the remedy.
return fmt.Errorf(
"settings org migration: %d settings document(s) have no org_id but %d orgs exist; "+
"cannot infer the owner. Set org_id manually on each settings document "+
"(db.settings.updateOne({_id:<id>},{$set:{org_id:\"<org uuid>\"}})), deleting any "+
"duplicates, then restart", n, orgCount)
}
if org.OrgID != "" {
if _, err := db.Col("settings").UpdateMany(ctx,
bson.M{"org_id": bson.M{"$exists": false}},
bson.M{"$set": bson.M{"org_id": org.OrgID}},
); err != nil {
return err
}
}
}
_, err := db.Col("migrations").InsertOne(ctx, bson.M{"_id": marker, "applied_at": time.Now()})
return err
}
+161 -24
View File
@@ -3,6 +3,7 @@ package services
import (
"context"
"errors"
"fmt"
"log"
"time"
@@ -36,10 +37,10 @@ func SpecFor(m *models.Monitor) checker.Spec {
}
}
func ListMonitors() ([]models.Monitor, error) {
func ListMonitors(orgID string) ([]models.Monitor, error) {
ctx, cancel := monCtx()
defer cancel()
cur, err := db.Col("monitors").Find(ctx, bson.M{}, options.Find().SetSort(bson.M{"created_at": 1}))
cur, err := db.Col("monitors").Find(ctx, bson.M{"org_id": orgID}, options.Find().SetSort(bson.M{"created_at": 1}))
if err != nil {
return nil, err
}
@@ -50,11 +51,37 @@ func ListMonitors() ([]models.Monitor, error) {
return out, nil
}
// ListMonitorsForRunner returns enabled monitors whose Runner matches runner.
func ListMonitorsForRunner(runner string) ([]models.Monitor, error) {
// ListMonitorsForRunner returns enabled monitors whose Runner matches runner,
// scoped to orgID. Runner is client-supplied at write time, so an agent fetching
// its own work must scope by the org of its authenticated server record —
// otherwise another org could point a monitor at that server_id and have it run
// their checks. An empty orgID is rejected: it would silently widen the query to
// every org.
func ListMonitorsForRunner(orgID, runner string) ([]models.Monitor, error) {
if orgID == "" {
return nil, errors.New("org id required")
}
return listMonitorsForRunner(orgID, runner)
}
// ListServerScheduledMonitors returns every enabled server-run monitor across
// all orgs. This is the in-process scheduler's entry point (mirrors the cross-org
// MarkOfflineServers sweep) and must never be called from a request-driven path —
// it performs no org scoping at all.
func ListServerScheduledMonitors() ([]models.Monitor, error) {
return listMonitorsForRunner("", models.RunnerServer)
}
// listMonitorsForRunner is the shared query. An empty orgID means no org filter
// and is only reachable via ListServerScheduledMonitors.
func listMonitorsForRunner(orgID, runner string) ([]models.Monitor, error) {
ctx, cancel := monCtx()
defer cancel()
cur, err := db.Col("monitors").Find(ctx, bson.M{"runner": runner, "enabled": true})
filter := bson.M{"runner": runner, "enabled": true}
if orgID != "" {
filter["org_id"] = orgID
}
cur, err := db.Col("monitors").Find(ctx, filter)
if err != nil {
return nil, err
}
@@ -65,7 +92,24 @@ func ListMonitorsForRunner(runner string) ([]models.Monitor, error) {
return out, nil
}
func GetMonitor(monitorID string) (*models.Monitor, error) {
// GetMonitor looks up a monitor scoped to an org (handler/session use).
func GetMonitor(orgID, monitorID string) (*models.Monitor, error) {
ctx, cancel := monCtx()
defer cancel()
var m models.Monitor
err := db.Col("monitors").FindOne(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID}).Decode(&m)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, nil
}
if err != nil {
return nil, err
}
return &m, nil
}
// getMonitorByID looks up a monitor by its unique monitor_id with no org
// filter. For agent/scheduler use only (IngestResult), which has no session.
func getMonitorByID(monitorID string) (*models.Monitor, error) {
ctx, cancel := monCtx()
defer cancel()
var m models.Monitor
@@ -79,9 +123,30 @@ func GetMonitor(monitorID string) (*models.Monitor, error) {
return &m, nil
}
func CreateMonitor(m *models.Monitor) (*models.Monitor, error) {
// validateRunner rejects a runner that is neither the reserved server-scheduler
// value nor a server in the org. The value is client-supplied and is later
// consumed by an agent's own monitor fetch, so ownership has to be proven at
// the write boundary.
func validateRunner(orgID, runner string) error {
if runner == "" || runner == models.RunnerServer {
return nil
}
if _, err := GetServer(orgID, runner); err != nil {
return fmt.Errorf("runner server %s not found", runner)
}
return nil
}
func CreateMonitor(orgID string, m *models.Monitor) (*models.Monitor, error) {
ctx, cancel := monCtx()
defer cancel()
if err := validateChannelIDs(orgID, m.ChannelIDs); err != nil {
return nil, err
}
if err := validateRunner(orgID, m.Runner); err != nil {
return nil, err
}
m.OrgID = orgID
m.MonitorID = uuid.NewString()
m.CreatedAt = time.Now()
if m.IntervalSec <= 0 {
@@ -100,31 +165,62 @@ func CreateMonitor(m *models.Monitor) (*models.Monitor, error) {
return m, nil
}
func UpdateMonitor(monitorID string, upd bson.M) error {
func UpdateMonitor(orgID, monitorID string, upd bson.M) error {
ctx, cancel := monCtx()
defer cancel()
_, err := db.Col("monitors").UpdateOne(ctx, bson.M{"monitor_id": monitorID}, bson.M{"$set": upd})
// A present-but-wrong-type value is a hard error: silently skipping the
// check would still let the unvalidated value through to the $set.
if raw, present := upd["channel_ids"]; present {
ids, ok := raw.([]string)
if !ok {
return fmt.Errorf("channel_ids must be a string array")
}
if err := validateChannelIDs(orgID, ids); err != nil {
return err
}
}
if raw, present := upd["runner"]; present {
runner, ok := raw.(string)
if !ok {
return fmt.Errorf("runner must be a string")
}
if err := validateRunner(orgID, runner); err != nil {
return err
}
// Match CreateMonitor: an empty runner means the server scheduler.
// Storing "" would match no runner at all and silently stop the
// monitor being checked.
if runner == "" {
upd["runner"] = models.RunnerServer
}
}
_, err := db.Col("monitors").UpdateOne(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID}, bson.M{"$set": upd})
return err
}
func DeleteMonitor(monitorID string) error {
func DeleteMonitor(orgID, monitorID string) error {
ctx, cancel := monCtx()
defer cancel()
if _, err := db.Col("monitors").DeleteOne(ctx, bson.M{"monitor_id": monitorID}); err != nil {
res, err := db.Col("monitors").DeleteOne(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID})
if err != nil {
return err
}
db.Col("incidents").DeleteMany(ctx, bson.M{"monitor_id": monitorID})
db.Col("monitor_rollups").DeleteMany(ctx, bson.M{"monitor_id": monitorID})
// Only cascade when the org-scoped delete actually removed a monitor.
if res.DeletedCount == 0 {
return nil
}
db.Col("incidents").DeleteMany(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID})
db.Col("monitor_rollups").DeleteMany(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID})
return nil
}
func ListIncidents(monitorID string, limit int64) ([]models.Incident, error) {
func ListIncidents(orgID, monitorID string, limit int64) ([]models.Incident, error) {
ctx, cancel := monCtx()
defer cancel()
if limit <= 0 {
limit = 50
}
cur, err := db.Col("incidents").Find(ctx, bson.M{"monitor_id": monitorID},
cur, err := db.Col("incidents").Find(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID},
options.Find().SetSort(bson.M{"started_at": -1}).SetLimit(limit))
if err != nil {
return nil, err
@@ -137,11 +233,11 @@ func ListIncidents(monitorID string, limit int64) ([]models.Incident, error) {
}
// UptimeRollups returns hourly rollups for a monitor since the cutoff, oldest first.
func UptimeRollups(monitorID string, since time.Time) ([]models.Rollup, error) {
func UptimeRollups(orgID, monitorID string, since time.Time) ([]models.Rollup, error) {
ctx, cancel := monCtx()
defer cancel()
cur, err := db.Col("monitor_rollups").Find(ctx,
bson.M{"monitor_id": monitorID, "period_start": bson.M{"$gte": since}},
bson.M{"monitor_id": monitorID, "org_id": orgID, "period_start": bson.M{"$gte": since}},
options.Find().SetSort(bson.M{"period_start": 1}))
if err != nil {
return nil, err
@@ -157,14 +253,49 @@ func UptimeRollups(monitorID string, since time.Time) ([]models.Rollup, error) {
// incidents on up<->down transitions, rolls up the hourly bucket, and fires
// notifications on transition. Both the server scheduler and agent-reported
// results funnel through here.
func IngestResult(monitorID string, res checker.Result) error {
//
// monitorID is client-supplied on the agent path, so the caller passes the org
// and runner it is authenticated as: orgID is the reporting agent's server org
// and runner is its server_id. A result is only applied to a monitor owned by
// that org and assigned to that runner. An empty orgID is rejected — it would
// skip the ownership check entirely.
func IngestResult(orgID, runner, monitorID string, res checker.Result) error {
if orgID == "" {
return errors.New("org id required")
}
return ingestResult(orgID, runner, monitorID, res)
}
// IngestServerScheduledResult applies a result produced by the in-process server
// scheduler, which has no org context of its own. This is the scheduler's entry
// point and must never be called from a request-driven path — it skips the org
// ownership check.
func IngestServerScheduledResult(monitorID string, res checker.Result) error {
return ingestResult("", models.RunnerServer, monitorID, res)
}
// ingestResult is the shared implementation. An empty orgID skips the org
// ownership check and is only reachable via IngestServerScheduledResult.
func ingestResult(orgID, runner, monitorID string, res checker.Result) error {
ctx, cancel := monCtx()
defer cancel()
m, err := GetMonitor(monitorID)
if err != nil || m == nil {
m, err := getMonitorByID(monitorID)
if err != nil {
return err
}
// Report not-found the same way as a cross-org hit, so probing an unknown
// monitor_id is no quieter than probing a foreign one and stale monitors
// stay visible to operators.
if m == nil {
return fmt.Errorf("monitor %s not found", monitorID)
}
if orgID != "" && m.OrgID != orgID {
return fmt.Errorf("monitor %s belongs to another org", monitorID)
}
if m.Runner != runner {
return fmt.Errorf("monitor %s is not run by %s", monitorID, runner)
}
now := time.Now()
prev := m.State.Status
@@ -207,9 +338,14 @@ func IngestResult(monitorID string, res checker.Result) error {
if res.Up {
up = 1
}
// org_id via $setOnInsert rather than the filter: a legacy bucket written
// before rollups were tenanted must keep accumulating, not fork in two.
db.Col("monitor_rollups").UpdateOne(ctx,
bson.M{"monitor_id": monitorID, "period_start": bucket},
bson.M{"$inc": bson.M{"checks": 1, "up_count": up, "sum_latency": int64(res.LatencyMs)}},
bson.M{
"$inc": bson.M{"checks": 1, "up_count": up, "sum_latency": int64(res.LatencyMs)},
"$setOnInsert": bson.M{"org_id": m.OrgID},
},
options.UpdateOne().SetUpsert(true))
// Transition handling.
@@ -217,6 +353,7 @@ func IngestResult(monitorID string, res checker.Result) error {
switch newStatus {
case models.StatusDown:
inc := models.Incident{
OrgID: m.OrgID,
IncidentID: uuid.NewString(),
MonitorID: monitorID,
StartedAt: now,
@@ -227,7 +364,7 @@ func IngestResult(monitorID string, res checker.Result) error {
case models.StatusUp:
if prev == models.StatusDown {
db.Col("incidents").UpdateOne(ctx,
bson.M{"monitor_id": monitorID, "resolved_at": nil},
bson.M{"monitor_id": monitorID, "org_id": m.OrgID, "resolved_at": nil},
bson.M{"$set": bson.M{"resolved_at": now}})
notifyTransition(m, newStatus, res.Message)
}
@@ -243,7 +380,7 @@ func notifyTransition(m *models.Monitor, newStatus, message string) {
if len(m.ChannelIDs) == 0 {
return
}
channels, err := GetChannels(m.ChannelIDs)
channels, err := GetChannels(m.OrgID, m.ChannelIDs)
if err != nil {
log.Printf("notify: load channels for %s: %v", m.MonitorID, err)
return
@@ -266,5 +403,5 @@ func notifyTransition(m *models.Monitor, newStatus, message string) {
}
}(ch)
}
_ = UpdateMonitor(m.MonitorID, bson.M{"state.last_notified_at": time.Now()})
_ = UpdateMonitor(m.OrgID, m.MonitorID, bson.M{"state.last_notified_at": time.Now()})
}
+52
View File
@@ -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
}
+168
View File
@@ -0,0 +1,168 @@
package services
import (
"context"
"fmt"
"log"
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
var reservedSlugs = map[string]bool{
"www": true, "api": true, "app": true, "admin": true, "auth": true,
"install": true, "static": true, "_next": true, "default": true,
}
func GetOrg(orgID string) (*models.Org, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var o models.Org
err := db.Col("orgs").FindOne(ctx, bson.M{"org_id": orgID}).Decode(&o)
if err != nil {
return nil, err
}
return &o, nil
}
func GetOrgBySlug(slug string) (*models.Org, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var o models.Org
err := db.Col("orgs").FindOne(ctx, bson.M{"slug": slug}).Decode(&o)
if err != nil {
return nil, err
}
return &o, nil
}
// ListOrgIDs returns the org_id of every organization. Used by startup tasks
// (e.g. seeding default workflow steps) that must run once per org.
func ListOrgIDs() ([]string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cursor, err := db.Col("orgs").Find(ctx, bson.M{})
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var orgs []models.Org
if err := cursor.All(ctx, &orgs); err != nil {
return nil, err
}
ids := make([]string, 0, len(orgs))
for _, o := range orgs {
ids = append(ids, o.OrgID)
}
return ids, nil
}
// CountOrgs returns the number of organizations on the instance. Used by
// first-run bootstrap to tell "empty instance" from "upgraded single-tenant
// instance whose data already sits under a migration-created org".
func CountOrgs() (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return db.Col("orgs").CountDocuments(ctx, bson.M{})
}
// FirstOrg returns the sole/earliest org. Callers must have established that
// exactly one exists before treating it as authoritative.
func FirstOrg() (*models.Org, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var o models.Org
if err := db.Col("orgs").FindOne(ctx, bson.M{}).Decode(&o); err != nil {
return nil, err
}
return &o, nil
}
// AdoptOrg renames an existing org to name, re-slugging it when the new slug is
// clean to take. It exists for the upgrade path: migration 0001 stamps every
// legacy document with the "default" org's ID, so bootstrap must claim that org
// rather than mint a second one — otherwise the operator signs in to an empty
// instance while all their servers and keys stay behind under "default".
//
// The slug is only changed when the derived one is usable and free; anything
// else keeps the current slug, including the reserved "default", which stays
// valid because it is pre-existing rather than newly chosen.
func AdoptOrg(orgID, name string) (*models.Org, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
set := bson.M{"name": name}
slug := Slugify(name)
if len(slug) > 40 {
slug = slug[:40]
}
if len(slug) >= 3 && !reservedSlugs[slug] {
n, err := db.Col("orgs").CountDocuments(ctx, bson.M{"slug": slug, "org_id": bson.M{"$ne": orgID}})
if err != nil {
return nil, err
}
if n == 0 {
set["slug"] = slug
}
}
if _, err := db.Col("orgs").UpdateOne(ctx, bson.M{"org_id": orgID}, bson.M{"$set": set}); err != nil {
if mongo.IsDuplicateKeyError(err) {
return nil, fmt.Errorf("organization slug already taken")
}
return nil, err
}
return GetOrg(orgID)
}
func CreateOrg(name string) (*models.Org, error) {
base := Slugify(name)
if len(base) < 3 {
return nil, fmt.Errorf("organization name too short (slug must be >= 3 chars)")
}
if len(base) > 40 {
base = base[:40]
}
if reservedSlugs[base] {
return nil, fmt.Errorf("organization name is reserved")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Resolve slug collision by suffixing -2, -3, ...
slug := base
for i := 2; ; i++ {
n, err := db.Col("orgs").CountDocuments(ctx, bson.M{"slug": slug})
if err != nil {
return nil, err
}
if n == 0 {
break
}
slug = fmt.Sprintf("%s-%d", base, i)
}
o := &models.Org{OrgID: uuid.NewString(), Name: name, Slug: slug, CreatedAt: time.Now()}
if _, err := db.Col("orgs").InsertOne(ctx, o); err != nil {
if mongo.IsDuplicateKeyError(err) {
return nil, fmt.Errorf("organization slug already taken")
}
return nil, err
}
// Boot-time seeding only covers orgs that already existed, so an org created
// at runtime would have an empty step library until the next restart. Not
// fatal: the org is usable without it and seeding is retried on boot.
if created, updated, err := SeedDefaultSteps(o.OrgID); err != nil {
log.Printf("warning: failed to seed default steps for new org %s: %v", o.OrgID, err)
} else {
log.Printf("default steps seeded for new org %s: %d created, %d updated", o.OrgID, created, updated)
}
return o, nil
}
-37
View File
@@ -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)
}
}
+41 -16
View File
@@ -2,6 +2,7 @@ package services
import (
"context"
"errors"
"fmt"
"sort"
"time"
@@ -13,25 +14,47 @@ import (
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// EnsureSecretIndexes creates the unique compound index on (group, key).
// EnsureSecretIndexes creates the unique compound index on (org_id, group, key).
// The pre-multi-tenant index was on (group, key) alone, which made a second org
// collide on the same group/key — drop it if a live DB still carries it.
func EnsureSecretIndexes() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := db.Col("secrets").Indexes().DropOne(ctx, "group_1_key_1"); err != nil && !isIndexNotFound(err) {
return err
}
_, err := db.Col("secrets").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "group", Value: 1}, {Key: "key", Value: 1}},
Keys: bson.D{{Key: "org_id", Value: 1}, {Key: "group", Value: 1}, {Key: "key", Value: 1}},
Options: options.Index().SetUnique(true),
})
return err
}
// isIndexNotFound reports whether err is Mongo's IndexNotFound (27), returned
// when dropping an index that was never created, or NamespaceNotFound (26),
// returned when the collection itself does not exist yet. Both mean "there is
// no legacy index to drop" — on a fresh install nothing has written to these
// collections, so the drop must be tolerated or the index creation that follows
// it never runs and a brand-new deployment crash-loops at startup.
func isIndexNotFound(err error) bool {
var ce mongo.CommandError
if errors.As(err, &ce) {
return ce.Code == 27 || ce.Name == "IndexNotFound" ||
ce.Code == 26 || ce.Name == "NamespaceNotFound"
}
return false
}
// ListSecretGroups returns a summary of every group with its key count and
// most recent update time.
func ListSecretGroups() ([]models.GroupSummary, error) {
func ListSecretGroups(orgID string) ([]models.GroupSummary, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
pipeline := mongo.Pipeline{
{{Key: "$match", Value: bson.D{{Key: "org_id", Value: orgID}}}},
{{Key: "$group", Value: bson.D{
{Key: "_id", Value: "$group"},
{Key: "key_count", Value: bson.D{{Key: "$sum", Value: 1}}},
@@ -68,11 +91,11 @@ func ListSecretGroups() ([]models.GroupSummary, error) {
// GetSecretGroup returns the keys within a group, sorted by key name, without
// decrypted values.
func GetSecretGroup(group string) ([]models.Secret, error) {
func GetSecretGroup(orgID, group string) ([]models.Secret, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cursor, err := db.Col("secrets").Find(ctx, bson.M{"group": group},
cursor, err := db.Col("secrets").Find(ctx, bson.M{"org_id": orgID, "group": group},
options.Find().SetSort(bson.D{{Key: "key", Value: 1}}))
if err != nil {
return nil, err
@@ -87,9 +110,10 @@ func GetSecretGroup(group string) ([]models.Secret, error) {
}
// GetSecretGroupDecrypted returns a flat map of key → plaintext value for a
// group. Used by the ESO read endpoint.
func GetSecretGroupDecrypted(group string) (map[string]string, error) {
docs, err := GetSecretGroup(group)
// group. Also used by the ESO read endpoint, which resolves its org from the
// per-org bearer token rather than from a session.
func GetSecretGroupDecrypted(orgID, group string) (map[string]string, error) {
docs, err := GetSecretGroup(orgID, group)
if err != nil {
return nil, err
}
@@ -105,12 +129,12 @@ func GetSecretGroupDecrypted(group string) (map[string]string, error) {
}
// RevealSecret returns the decrypted value of a single key.
func RevealSecret(group, key string) (string, error) {
func RevealSecret(orgID, group, key string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var doc models.Secret
err := db.Col("secrets").FindOne(ctx, bson.M{"group": group, "key": key}).Decode(&doc)
err := db.Col("secrets").FindOne(ctx, bson.M{"org_id": orgID, "group": group, "key": key}).Decode(&doc)
if err == mongo.ErrNoDocuments {
return "", fmt.Errorf("secret not found")
}
@@ -121,7 +145,7 @@ func RevealSecret(group, key string) (string, error) {
}
// UpsertSecrets encrypts and writes each key/value pair into the group.
func UpsertSecrets(group string, values map[string]string) error {
func UpsertSecrets(orgID, group string, values map[string]string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -131,8 +155,9 @@ func UpsertSecrets(group string, values map[string]string) error {
return fmt.Errorf("encrypt %s: %w", key, err)
}
_, err = db.Col("secrets").UpdateOne(ctx,
bson.M{"group": group, "key": key},
bson.M{"org_id": orgID, "group": group, "key": key},
bson.M{"$set": bson.M{
"org_id": orgID,
"encrypted_value": encrypted,
"updated_at": time.Now(),
}},
@@ -156,19 +181,19 @@ func SortedKeys(m map[string]string) []string {
}
// DeleteSecret removes a single key from a group.
func DeleteSecret(group, key string) error {
func DeleteSecret(orgID, group, key string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := db.Col("secrets").DeleteOne(ctx, bson.M{"group": group, "key": key})
_, err := db.Col("secrets").DeleteOne(ctx, bson.M{"org_id": orgID, "group": group, "key": key})
return err
}
// DeleteSecretGroup removes an entire group and all its keys.
func DeleteSecretGroup(group string) error {
func DeleteSecretGroup(orgID, group string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := db.Col("secrets").DeleteMany(ctx, bson.M{"group": group})
_, err := db.Col("secrets").DeleteMany(ctx, bson.M{"org_id": orgID, "group": group})
return err
}
+86 -34
View File
@@ -6,6 +6,7 @@ import (
"crypto/sha256"
"encoding/hex"
"fmt"
"log"
"strings"
"time"
@@ -29,13 +30,14 @@ func HashToken(token string) string {
return hex.EncodeToString(sum[:])
}
func CreateServer() (*models.Server, string, error) {
func CreateServer(orgID string) (*models.Server, string, error) {
token, err := generateToken(32)
if err != nil {
return nil, "", err
}
expires := time.Now().Add(time.Hour)
s := &models.Server{
OrgID: orgID,
ServerID: uuid.NewString(),
PreRegToken: token,
PreRegExpires: &expires,
@@ -52,7 +54,22 @@ func CreateServer() (*models.Server, string, error) {
return s, token, nil
}
func GetServer(serverID string) (*models.Server, error) {
// GetServer looks up a server scoped to an org (handler/session use).
func GetServer(orgID, serverID string) (*models.Server, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var s models.Server
err := db.Col("servers").FindOne(ctx, bson.M{"server_id": serverID, "org_id": orgID}).Decode(&s)
if err != nil {
return nil, err
}
return &s, nil
}
// getServerByID looks up a server by its unique server_id with no org filter.
// For agent/internal use only (e.g. workflow runner resolving org from a run).
func getServerByID(serverID string) (*models.Server, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -164,6 +181,11 @@ func ValidateAgentToken(serverID, agentToken string) (*models.Server, error) {
if err != nil {
return nil, fmt.Errorf("invalid agent token")
}
// Defence in depth: every agent-path caller scopes its work by this OrgID,
// so a blank one would widen those queries instead of narrowing them.
if s.OrgID == "" {
return nil, fmt.Errorf("server %s has no org", serverID)
}
return &s, nil
}
@@ -214,12 +236,12 @@ func UpdateServerLastSeen(serverID, agentVersion string) error {
return err
}
func ListServers() ([]models.Server, error) {
func ListServers(orgID string) ([]models.Server, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})
cursor, err := db.Col("servers").Find(ctx, bson.M{}, opts)
cursor, err := db.Col("servers").Find(ctx, bson.M{"org_id": orgID}, opts)
if err != nil {
return nil, err
}
@@ -232,16 +254,16 @@ func ListServers() ([]models.Server, error) {
return servers, nil
}
func DeleteServer(serverID string) error {
func DeleteServer(orgID, serverID string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := db.Col("servers").DeleteOne(ctx, bson.M{"server_id": serverID})
_, err := db.Col("servers").DeleteOne(ctx, bson.M{"server_id": serverID, "org_id": orgID})
if err != nil {
return err
}
// Also remove assignments
_, err = db.Col("assignments").DeleteMany(ctx, bson.M{"server_id": serverID})
_, err = db.Col("assignments").DeleteMany(ctx, bson.M{"server_id": serverID, "org_id": orgID})
return err
}
@@ -261,39 +283,73 @@ func StoreAvailableUpdates(serverID string, pkgs []models.PackageUpdate) error {
}
func MarkOfflineServers() error {
settings, _ := GetSettings()
thresholdMinutes := 5
if settings != nil && settings.Alerts.OfflineThresholdMinutes > 0 {
thresholdMinutes = settings.Alerts.OfflineThresholdMinutes
}
threshold := time.Duration(thresholdMinutes) * time.Minute
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cutoff := time.Now().Add(-threshold)
// Find servers about to transition to offline so we can alert on them.
cursor, err := db.Col("servers").Find(ctx, bson.M{
"status": "active",
"last_seen": bson.M{"$lt": cutoff},
})
orgIDs, err := ListOrgIDs()
if err != nil {
return err
}
defer cursor.Close(ctx)
var goingOffline []models.Server
if err := cursor.All(ctx, &goingOffline); err != nil {
return err
// No session here, so the sweep runs per-org and each org's threshold and
// alert config come from that org's own settings doc. Each org gets its own
// deadline so a slow org can't starve the ones after it, and a failure on
// one org is logged rather than aborting the whole sweep.
for _, orgID := range orgIDs {
if err := markOfflineForFilter(bson.M{"org_id": orgID}, orgID); err != nil {
log.Printf("offline sweep failed for org %s: %v", orgID, err)
}
}
// Servers whose org_id matches no existing org (org deleted, or the doc
// predates the backfill) would otherwise never be swept, where the old
// global query caught them. Sweep them with the default threshold; there is
// no org settings doc to read, and no org to alert.
if err := markOfflineForFilter(bson.M{"org_id": bson.M{"$nin": orgIDs}}, ""); err != nil {
log.Printf("offline sweep failed for orphaned servers: %v", err)
}
return nil
}
// markOfflineForFilter transitions active-but-stale servers matching scope to
// offline. orgID selects whose settings supply the threshold and alert config;
// empty means defaults with no alerting (orphaned servers).
func markOfflineForFilter(scope bson.M, orgID string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var settings *models.Settings
thresholdMinutes := 5
if orgID != "" {
settings, _ = GetSettings(orgID)
if settings != nil && settings.Alerts.OfflineThresholdMinutes > 0 {
thresholdMinutes = settings.Alerts.OfflineThresholdMinutes
}
}
cutoff := time.Now().Add(-time.Duration(thresholdMinutes) * time.Minute)
filter := bson.M{
"status": "active",
"last_seen": bson.M{"$lt": cutoff},
}
for k, v := range scope {
filter[k] = v
}
// Find servers about to transition to offline so we can alert on them.
cursor, err := db.Col("servers").Find(ctx, filter)
if err != nil {
return err
}
var goingOffline []models.Server
err = cursor.All(ctx, &goingOffline)
cursor.Close(ctx)
if err != nil {
return err
}
if len(goingOffline) == 0 {
return nil
}
for _, s := range goingOffline {
LogEvent("server.offline", "system", s.ServerID, "", fmt.Sprintf("%s (%s) went offline", s.Hostname, s.IPAddress))
LogEvent(s.OrgID, "server.offline", "system", s.ServerID, "", fmt.Sprintf("%s (%s) went offline", s.Hostname, s.IPAddress))
if settings != nil && settings.Alerts.Enabled && settings.Alerts.WebhookURL != "" {
go SendOfflineWebhook(settings.Alerts.WebhookURL, s.Hostname, s.ServerID, s.IPAddress)
}
@@ -302,11 +358,7 @@ func MarkOfflineServers() error {
}
}
_, err = db.Col("servers").UpdateMany(ctx,
bson.M{
"status": "active",
"last_seen": bson.M{"$lt": cutoff},
},
_, err = db.Col("servers").UpdateMany(ctx, filter,
bson.M{"$set": bson.M{"status": "offline"}},
)
return err
@@ -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)
}
}
}
+66 -22
View File
@@ -34,14 +34,47 @@ var defaultSettings = models.Settings{
},
}
func GetSettings() (*models.Settings, error) {
// EnsureSettingsIndexes creates the per-org uniqueness constraints on settings.
// Pre-multi-tenant deployments had a single global settings doc and no indexes;
// drop any legacy index if a live DB still carries one.
func EnsureSettingsIndexes() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := db.Col("settings").Indexes().DropOne(ctx, "secrets.read_token_hash_1"); err != nil && !isIndexNotFound(err) {
return err
}
if _, err := db.Col("settings").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "org_id", Value: 1}},
Options: options.Index().SetUnique(true),
}); err != nil {
return err
}
// Partial so the many settings docs with no ESO token set don't collide on
// a missing (or empty) field. Explicitly named so it does not share Mongo's
// default name with the legacy index dropped above, which would make every
// restart drop and rebuild the enforcing index.
_, err := db.Col("settings").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "secrets.read_token_hash", Value: 1}},
Options: options.Index().SetUnique(true).SetName("settings_read_token_hash_unique").
SetPartialFilterExpression(bson.M{
"secrets.read_token_hash": bson.M{"$type": "string", "$gt": ""},
}),
})
return err
}
func GetSettings(orgID string) (*models.Settings, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var s models.Settings
err := db.Col("settings").FindOne(ctx, bson.M{}).Decode(&s)
err := db.Col("settings").FindOne(ctx, bson.M{"org_id": orgID}).Decode(&s)
if err == mongo.ErrNoDocuments {
cp := defaultSettings
cp.OrgID = orgID
return &cp, nil
}
if err != nil {
@@ -58,7 +91,7 @@ func hashToken(token string) string {
// RotateSecretsReadToken generates a new ESO read token, stores its SHA-256
// hash, and returns the plaintext token exactly once.
func RotateSecretsReadToken() (string, error) {
func RotateSecretsReadToken(orgID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -69,11 +102,14 @@ func RotateSecretsReadToken() (string, error) {
token := hex.EncodeToString(raw)
_, err := db.Col("settings").UpdateOne(ctx,
bson.M{},
bson.M{"$set": bson.M{
"secrets.read_token_hash": hashToken(token),
"secrets.rotated_at": time.Now(),
}},
bson.M{"org_id": orgID},
bson.M{
"$set": bson.M{
"secrets.read_token_hash": hashToken(token),
"secrets.rotated_at": time.Now(),
},
"$setOnInsert": bson.M{"org_id": orgID},
},
options.UpdateOne().SetUpsert(true),
)
if err != nil {
@@ -82,25 +118,33 @@ func RotateSecretsReadToken() (string, error) {
return token, nil
}
// VerifySecretsReadToken reports whether the supplied token matches the stored
// hash, using a constant-time comparison.
func VerifySecretsReadToken(token string) bool {
// ResolveSecretsReadToken looks the presented token's hash up directly and
// returns the owning org. This is the ESO machine-to-machine path: the org is
// carried by the token itself, since there is no session to scope it.
func ResolveSecretsReadToken(token string) (string, bool) {
if token == "" {
return false
return "", false
}
s, err := GetSettings()
if err != nil || s.Secrets.ReadTokenHash == "" {
return false
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var s models.Settings
err := db.Col("settings").FindOne(ctx, bson.M{"secrets.read_token_hash": hashToken(token)}).Decode(&s)
if err != nil || s.Secrets.ReadTokenHash == "" || s.OrgID == "" {
return "", false
}
expected, err := hex.DecodeString(s.Secrets.ReadTokenHash)
if err != nil {
return false
return "", false
}
got := sha256.Sum256([]byte(token))
return subtle.ConstantTimeCompare(expected, got[:]) == 1
if subtle.ConstantTimeCompare(expected, got[:]) != 1 {
return "", false
}
return s.OrgID, true
}
func SaveSettings(alerts models.AlertSettings, email models.EmailSettings, retentionDays *int) error {
func SaveSettings(orgID string, alerts models.AlertSettings, email models.EmailSettings, retentionDays *int) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -116,8 +160,8 @@ func SaveSettings(alerts models.AlertSettings, email models.EmailSettings, reten
set["workflow_log_retention_days"] = *retentionDays
}
_, err := db.Col("settings").UpdateOne(ctx,
bson.M{},
bson.M{"$set": set},
bson.M{"org_id": orgID},
bson.M{"$set": set, "$setOnInsert": bson.M{"org_id": orgID}},
options.UpdateOne().SetUpsert(true),
)
return err
@@ -125,8 +169,8 @@ func SaveSettings(alerts models.AlertSettings, email models.EmailSettings, reten
// GetWorkflowLogRetentionDays returns the log retention in days: 30 when unset,
// 0 for keep-forever, or the configured value.
func GetWorkflowLogRetentionDays() (int, error) {
s, err := GetSettings()
func GetWorkflowLogRetentionDays(orgID string) (int, error) {
s, err := GetSettings(orgID)
if err != nil {
return 30, err
}
+4 -4
View File
@@ -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
}
-60
View File
@@ -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)
}
}
+57 -25
View File
@@ -2,6 +2,7 @@ package services
import (
"bytes"
"log"
"os"
"path/filepath"
"strings"
@@ -10,6 +11,7 @@ import (
"github.com/mrhid6/vantage/server/internal/db"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// WorkflowLogDir returns the base directory for workflow step logs, creating it.
@@ -164,54 +166,84 @@ func StartLogSweeper() {
}()
}
// sweepLogs walks the run-log dirs on disk. Log dirs are keyed by run ID, not
// by org, and this runs with no session — so retention is resolved per run from
// the owning org of that run's doc, with the per-org values cached for the
// sweep. Runs whose doc is gone fall back to the default retention.
func sweepLogs() {
days := retentionDays()
if days <= 0 {
return
}
cutoff := time.Now().AddDate(0, 0, -days)
base := WorkflowLogDir()
entries, err := os.ReadDir(base)
if err != nil {
return
}
cache := map[string]int{}
now := time.Now()
for _, e := range entries {
if !e.IsDir() {
continue
}
runID := e.Name()
dir := filepath.Join(base, runID)
if runExpired(runID, dir, cutoff) {
orgID, finishedAt, found, err := runRetentionInfo(runID)
if err != nil {
// A transient lookup failure is not evidence the run is gone —
// purging at the default retention here would delete logs an org
// had set to keep longer, or forever.
log.Printf("log sweep: retention lookup failed for run %s: %v", runID, err)
continue
}
if found && finishedAt == nil {
continue // still running / never finished — keep
}
days, ok := cache[orgID]
if !ok {
days = defaultRetentionDays
if orgID != "" {
if v, err := GetWorkflowLogRetentionDays(orgID); err == nil {
days = v
}
}
cache[orgID] = days
}
if days <= 0 {
continue // keep forever
}
cutoff := now.AddDate(0, 0, -days)
if found {
if finishedAt.Before(cutoff) {
_ = os.RemoveAll(dir)
}
continue
}
// run doc gone: use dir mtime
if fi, e := os.Stat(dir); e == nil && fi.ModTime().Before(cutoff) {
_ = os.RemoveAll(dir)
}
}
}
// runExpired is true when the run finished before cutoff (falling back to dir
// mtime when the run doc is gone).
func runExpired(runID, dir string, cutoff time.Time) bool {
const defaultRetentionDays = 30
// runRetentionInfo returns the owning org and finish time of a run, and whether
// the run doc still exists. A non-nil error means the lookup itself failed and
// says nothing about whether the run doc exists.
func runRetentionInfo(runID string) (string, *time.Time, bool, error) {
ctx, cancel := wfCtx()
defer cancel()
var run struct {
OrgID string `bson:"org_id"`
FinishedAt *time.Time `bson:"finished_at"`
}
err := db.Col("workflow_runs").FindOne(ctx, bson.M{"run_id": runID}).Decode(&run)
if err == nil {
if run.FinishedAt == nil {
return false // still running / never finished — keep
}
return run.FinishedAt.Before(cutoff)
if err == mongo.ErrNoDocuments {
return "", nil, false, nil
}
// run doc gone: use dir mtime
if fi, e := os.Stat(dir); e == nil {
return fi.ModTime().Before(cutoff)
if err != nil {
return "", nil, false, err
}
return false
}
func retentionDays() int {
if v, err := GetWorkflowLogRetentionDays(); err == nil {
return v
}
return 30
return run.OrgID, run.FinishedAt, true, nil
}
-45
View File
@@ -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)
}
}
+9 -1
View File
@@ -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
}
+183
View File
@@ -0,0 +1,183 @@
package services
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"golang.org/x/crypto/bcrypt"
)
// ErrLastOwner is returned when an operation would leave an org with no owner,
// which would lock every remaining member out of org administration.
var ErrLastOwner = errors.New("this is the organization's last owner — promote another member to owner first")
// CountUsers counts users across the whole instance. It answers "is this a
// brand new deployment", so it is deliberately unscoped; anything that asks
// about a single tenant must use CountOrgUsers.
func CountUsers() (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return db.Col("users").CountDocuments(ctx, bson.M{})
}
func CountOrgUsers(orgID string) (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return db.Col("users").CountDocuments(ctx, bson.M{"org_id": orgID})
}
// countOtherOwners counts owner-role users in the org excluding exceptUserID,
// i.e. how many owners would remain if that user were removed or demoted.
func countOtherOwners(orgID, exceptUserID string) (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return db.Col("users").CountDocuments(ctx, bson.M{
"org_id": orgID,
"role": models.RoleOwner,
"user_id": bson.M{"$ne": exceptUserID},
})
}
func GetUserInOrg(orgID, userID string) (*models.User, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var u models.User
err := db.Col("users").FindOne(ctx, bson.M{"user_id": userID, "org_id": orgID}).Decode(&u)
if err != nil {
return nil, err
}
return &u, nil
}
func CreateUser(orgID, email, password, role, authSource string) (*models.User, error) {
email = strings.ToLower(strings.TrimSpace(email))
if email == "" {
return nil, fmt.Errorf("email required")
}
if !models.ValidRole(role) {
return nil, fmt.Errorf("invalid role %q", role)
}
u := &models.User{
UserID: uuid.NewString(),
OrgID: orgID,
Email: email,
Role: role,
AuthSource: authSource,
CreatedAt: time.Now(),
}
if password != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
return nil, err
}
u.PasswordHash = string(hash)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := db.Col("users").InsertOne(ctx, u); err != nil {
if mongo.IsDuplicateKeyError(err) {
return nil, fmt.Errorf("email already registered")
}
return nil, err
}
return u, nil
}
func GetUserByEmail(email string) (*models.User, error) {
email = strings.ToLower(strings.TrimSpace(email))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var u models.User
err := db.Col("users").FindOne(ctx, bson.M{"email": email}).Decode(&u)
if err != nil {
return nil, err
}
return &u, nil
}
func VerifyPassword(u *models.User, password string) bool {
if u == nil || u.PasswordHash == "" {
return false
}
return bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil
}
func TouchLastLogin(userID string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
now := time.Now()
_, err := db.Col("users").UpdateOne(ctx, bson.M{"user_id": userID},
bson.M{"$set": bson.M{"last_login": now}})
return err
}
func ListUsers(orgID string) ([]models.User, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cursor, err := db.Col("users").Find(ctx, bson.M{"org_id": orgID})
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var users []models.User
if err := cursor.All(ctx, &users); err != nil {
return nil, err
}
return users, nil
}
func UpdateUserRole(orgID, userID, role string) error {
if !models.ValidRole(role) {
return fmt.Errorf("invalid role %q", role)
}
target, err := GetUserInOrg(orgID, userID)
if err != nil {
return fmt.Errorf("user not found")
}
// Demoting the final owner would leave nobody able to administer the org.
if target.Role == models.RoleOwner && role != models.RoleOwner {
others, err := countOtherOwners(orgID, userID)
if err != nil {
return err
}
if others == 0 {
return ErrLastOwner
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err = db.Col("users").UpdateOne(ctx,
bson.M{"user_id": userID, "org_id": orgID},
bson.M{"$set": bson.M{"role": role}})
return err
}
func DeleteUser(orgID, userID string) error {
target, err := GetUserInOrg(orgID, userID)
if err != nil {
return fmt.Errorf("user not found")
}
if target.Role == models.RoleOwner {
others, err := countOtherOwners(orgID, userID)
if err != nil {
return err
}
if others == 0 {
return ErrLastOwner
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err = db.Col("users").DeleteOne(ctx, bson.M{"user_id": userID, "org_id": orgID})
return err
}
-29
View File
@@ -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)
}
})
}
}
+41 -20
View File
@@ -19,8 +19,8 @@ const stepDispatchGrace = 15 * time.Second
// TriggerWorkflow snapshots the workflow, creates a run doc, and starts a
// background goroutine per target server (parallel fan-out). Returns run_id.
func TriggerWorkflow(workflowID, actor string) (string, error) {
wf, err := GetWorkflow(workflowID)
func TriggerWorkflow(orgID, workflowID, actor string) (string, error) {
wf, err := GetWorkflow(orgID, workflowID)
if err != nil {
return "", err
}
@@ -30,21 +30,27 @@ func TriggerWorkflow(workflowID, actor string) (string, error) {
if len(wf.Steps) == 0 {
return "", fmt.Errorf("workflow has no steps")
}
// Re-check ownership at trigger time — targets may predate validation or a
// server may have been removed since the workflow was saved.
if err := validateTargetServers(orgID, wf.TargetServerIDs); err != nil {
return "", err
}
// Reject a concurrent run of the same workflow.
ctx, cancel := wfCtx()
running := db.Col("workflow_runs").FindOne(ctx, bson.M{"workflow_id": workflowID, "status": "running"})
running := db.Col("workflow_runs").FindOne(ctx, bson.M{"org_id": orgID, "workflow_id": workflowID, "status": "running"})
cancel()
if running.Err() == nil {
return "", fmt.Errorf("workflow already has a run in progress")
}
resolved, err := resolveSteps(wf)
resolved, err := resolveSteps(orgID, wf)
if err != nil {
return "", err
}
run := models.WorkflowRun{
OrgID: orgID,
RunID: uuid.New().String(),
WorkflowID: workflowID,
Name: wf.Name,
@@ -56,7 +62,7 @@ func TriggerWorkflow(workflowID, actor string) (string, error) {
}
for _, sid := range wf.TargetServerIDs {
hostname := sid
if s, e := GetServer(sid); e == nil {
if s, e := getServerByID(sid); e == nil {
hostname = s.Hostname
}
sr := models.ServerRun{ServerID: sid, Hostname: hostname, Status: "queued", RunEnv: map[string]string{}}
@@ -78,7 +84,7 @@ func TriggerWorkflow(workflowID, actor string) (string, error) {
// resolveSteps freezes each workflow step ref into a ResolvedStep by loading the
// library step and applying overrides.
func resolveSteps(wf *models.Workflow) ([]models.ResolvedStep, error) {
func resolveSteps(orgID string, wf *models.Workflow) ([]models.ResolvedStep, error) {
ctx, cancel := wfCtx()
defer cancel()
out := make([]models.ResolvedStep, 0, len(wf.Steps))
@@ -87,7 +93,7 @@ func resolveSteps(wf *models.Workflow) ([]models.ResolvedStep, error) {
out = append(out, resolveInlineStep(ref))
continue
}
lib, err := getStep(ctx, ref.StepID)
lib, err := getStep(ctx, orgID, ref.StepID)
if err != nil {
return nil, err
}
@@ -158,14 +164,14 @@ func resolveInlineStep(ref models.WorkflowStepRef) models.ResolvedStep {
// executeRun fans out one goroutine per server run and waits for all to finish.
func executeRun(runID string) {
run, err := GetRun(runID)
run, err := getRunByID(runID)
if err != nil {
return
}
done := make(chan int, len(run.ServerRuns))
for i := range run.ServerRuns {
go func(idx int) {
runServer(runID, idx, run.Steps, run.ServerRuns[idx].ServerID)
runServer(run.OrgID, runID, idx, run.Steps, run.ServerRuns[idx].ServerID)
done <- idx
}(i)
}
@@ -174,7 +180,7 @@ func executeRun(runID string) {
}
// Aggregate status.
final, _ := GetRun(runID)
final, _ := getRunByID(runID)
status := "success"
for _, sr := range final.ServerRuns {
if sr.Status == "failed" {
@@ -190,7 +196,7 @@ func executeRun(runID string) {
// runServer executes the resolved steps sequentially on one server, threading
// output env forward and applying per-step failure policy.
func runServer(runID string, srvIdx int, steps []models.ResolvedStep, serverID string) {
func runServer(orgID, runID string, srvIdx int, steps []models.ResolvedStep, serverID string) {
now := time.Now()
setServerRun(runID, srvIdx, bson.M{"server_runs.$.status": "running", "server_runs.$.started_at": now})
@@ -218,7 +224,7 @@ func runServer(runID string, srvIdx int, steps []models.ResolvedStep, serverID s
}
// Merge secrets into command env (kept out of persisted logs).
secretVals := resolveSecrets(step.SecretRefs)
secretVals := resolveSecrets(orgID, step.SecretRefs)
for k, v := range secretVals {
allSecrets[k] = v
}
@@ -366,7 +372,7 @@ func expandVars(v string, lookup map[string]string) string {
})
}
func resolveSecrets(refs []string) map[string]string {
func resolveSecrets(orgID string, refs []string) map[string]string {
out := map[string]string{}
for _, ref := range refs {
// ref format "group/KEY"; resolve via RevealSecret.
@@ -374,7 +380,7 @@ func resolveSecrets(refs []string) map[string]string {
if len(parts) != 2 {
continue
}
if v, err := RevealSecret(parts[0], parts[1]); err == nil {
if v, err := RevealSecret(orgID, parts[0], parts[1]); err == nil {
out[parts[1]] = v
}
}
@@ -403,7 +409,7 @@ func setServerRun(runID string, srvIdx int, set bson.M) {
// serverIDAt returns the server_id at an index (positional operator needs a match).
func serverIDAt(runID string, srvIdx int) string {
r, err := GetRun(runID)
r, err := getRunByID(runID)
if err != nil || srvIdx >= len(r.ServerRuns) {
return ""
}
@@ -467,7 +473,10 @@ func updateStep(runID, serverID string, order int, set bson.M) {
// ---- reads ----
func GetRun(runID string) (*models.WorkflowRun, error) {
// getRunByID looks up a run by its unique run_id with no org filter. For
// agent/internal run-execution use only (executeRun/runServer, etc.), which
// don't have a session and instead resolve org from the run doc itself.
func getRunByID(runID string) (*models.WorkflowRun, error) {
ctx, cancel := wfCtx()
defer cancel()
var r models.WorkflowRun
@@ -478,10 +487,22 @@ func GetRun(runID string) (*models.WorkflowRun, error) {
return &r, err
}
func ListRuns(workflowID string, limit int64) ([]models.WorkflowRun, error) {
// GetRun looks up a run scoped to an org (handler/session use).
func GetRun(orgID, runID string) (*models.WorkflowRun, error) {
ctx, cancel := wfCtx()
defer cancel()
cur, err := db.Col("workflow_runs").Find(ctx, bson.M{"workflow_id": workflowID},
var r models.WorkflowRun
err := db.Col("workflow_runs").FindOne(ctx, bson.M{"run_id": runID, "org_id": orgID}).Decode(&r)
if err == mongo.ErrNoDocuments {
return nil, fmt.Errorf("run not found")
}
return &r, err
}
func ListRuns(orgID, workflowID string, limit int64) ([]models.WorkflowRun, error) {
ctx, cancel := wfCtx()
defer cancel()
cur, err := db.Col("workflow_runs").Find(ctx, bson.M{"org_id": orgID, "workflow_id": workflowID},
options.Find().SetSort(bson.D{{Key: "started_at", Value: -1}}).SetLimit(limit))
if err != nil {
return nil, err
@@ -494,12 +515,12 @@ func ListRuns(workflowID string, limit int64) ([]models.WorkflowRun, error) {
return runs, nil
}
func CancelRun(runID string) error {
func CancelRun(orgID, runID string) error {
now := time.Now()
ctx, cancel := wfCtx()
defer cancel()
_, err := db.Col("workflow_runs").UpdateOne(ctx,
bson.M{"run_id": runID, "status": "running"},
bson.M{"org_id": orgID, "run_id": runID, "status": "running"},
bson.M{"$set": bson.M{"status": "cancelled", "finished_at": now}})
return err
}
+47 -22
View File
@@ -25,8 +25,13 @@ func EnsureWorkflowIndexes() error {
}); err != nil {
return err
}
// The pre-multi-tenant index was on slug alone, so seeding defaults for a
// second org collided — drop it if a live DB still carries it.
if err := db.Col("workflow_steps").Indexes().DropOne(ctx, "slug_1"); err != nil && !isIndexNotFound(err) {
return err
}
if _, err := db.Col("workflow_steps").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "slug", Value: 1}},
Keys: bson.D{{Key: "org_id", Value: 1}, {Key: "slug", Value: 1}},
Options: options.Index().SetUnique(true).
SetPartialFilterExpression(bson.M{"source": "default"}),
}); err != nil {
@@ -45,10 +50,10 @@ func EnsureWorkflowIndexes() error {
// ---- Steps ----
func ListSteps() ([]models.WorkflowStep, error) {
func ListSteps(orgID string) ([]models.WorkflowStep, error) {
ctx, cancel := wfCtx()
defer cancel()
cur, err := db.Col("workflow_steps").Find(ctx, bson.M{},
cur, err := db.Col("workflow_steps").Find(ctx, bson.M{"org_id": orgID},
options.Find().SetSort(bson.D{{Key: "name", Value: 1}}))
if err != nil {
return nil, err
@@ -63,10 +68,10 @@ func ListSteps() ([]models.WorkflowStep, error) {
// StepUsageCounts returns, per library step_id, the number of distinct
// workflows that reference it. Inline steps have no step_id and are ignored.
func StepUsageCounts() (map[string]int, error) {
func StepUsageCounts(orgID string) (map[string]int, error) {
ctx, cancel := wfCtx()
defer cancel()
cur, err := db.Col("workflows").Find(ctx, bson.M{})
cur, err := db.Col("workflows").Find(ctx, bson.M{"org_id": orgID})
if err != nil {
return nil, err
}
@@ -89,9 +94,10 @@ func StepUsageCounts() (map[string]int, error) {
return counts, nil
}
func CreateStep(s models.WorkflowStep) (*models.WorkflowStep, error) {
func CreateStep(orgID string, s models.WorkflowStep) (*models.WorkflowStep, error) {
ctx, cancel := wfCtx()
defer cancel()
s.OrgID = orgID
s.StepID = uuid.New().String()
s.CreatedAt = time.Now()
s.UpdatedAt = s.CreatedAt
@@ -111,10 +117,10 @@ func CreateStep(s models.WorkflowStep) (*models.WorkflowStep, error) {
return &s, nil
}
func UpdateStep(stepID string, s models.WorkflowStep) error {
func UpdateStep(orgID, stepID string, s models.WorkflowStep) error {
ctx, cancel := wfCtx()
defer cancel()
_, err := db.Col("workflow_steps").UpdateOne(ctx, bson.M{"step_id": stepID}, bson.M{"$set": bson.M{
_, err := db.Col("workflow_steps").UpdateOne(ctx, bson.M{"step_id": stepID, "org_id": orgID}, bson.M{"$set": bson.M{
"name": s.Name,
"description": s.Description,
"interpreter": s.Interpreter,
@@ -127,14 +133,14 @@ func UpdateStep(stepID string, s models.WorkflowStep) error {
return err
}
func DeleteStep(stepID string) error {
func DeleteStep(orgID, stepID string) error {
ctx, cancel := wfCtx()
defer cancel()
if _, err := db.Col("workflow_steps").DeleteOne(ctx, bson.M{"step_id": stepID}); err != nil {
if _, err := db.Col("workflow_steps").DeleteOne(ctx, bson.M{"step_id": stepID, "org_id": orgID}); err != nil {
return err
}
// Cascade: remove this step from every workflow that references it, re-sequencing orders.
cur, err := db.Col("workflows").Find(ctx, bson.M{"steps.step_id": stepID})
cur, err := db.Col("workflows").Find(ctx, bson.M{"steps.step_id": stepID, "org_id": orgID})
if err != nil {
return err
}
@@ -164,9 +170,9 @@ func DeleteStep(stepID string) error {
return nil
}
func getStep(ctx context.Context, stepID string) (*models.WorkflowStep, error) {
func getStep(ctx context.Context, orgID, stepID string) (*models.WorkflowStep, error) {
var s models.WorkflowStep
err := db.Col("workflow_steps").FindOne(ctx, bson.M{"step_id": stepID}).Decode(&s)
err := db.Col("workflow_steps").FindOne(ctx, bson.M{"step_id": stepID, "org_id": orgID}).Decode(&s)
if err == mongo.ErrNoDocuments {
return nil, fmt.Errorf("step %s not found", stepID)
}
@@ -175,10 +181,10 @@ func getStep(ctx context.Context, stepID string) (*models.WorkflowStep, error) {
// ---- Workflows ----
func ListWorkflows() ([]models.Workflow, error) {
func ListWorkflows(orgID string) ([]models.Workflow, error) {
ctx, cancel := wfCtx()
defer cancel()
cur, err := db.Col("workflows").Find(ctx, bson.M{},
cur, err := db.Col("workflows").Find(ctx, bson.M{"org_id": orgID},
options.Find().SetSort(bson.D{{Key: "name", Value: 1}}))
if err != nil {
return nil, err
@@ -191,20 +197,21 @@ func ListWorkflows() ([]models.Workflow, error) {
return wfs, nil
}
func GetWorkflow(id string) (*models.Workflow, error) {
func GetWorkflow(orgID, id string) (*models.Workflow, error) {
ctx, cancel := wfCtx()
defer cancel()
var w models.Workflow
err := db.Col("workflows").FindOne(ctx, bson.M{"workflow_id": id}).Decode(&w)
err := db.Col("workflows").FindOne(ctx, bson.M{"workflow_id": id, "org_id": orgID}).Decode(&w)
if err == mongo.ErrNoDocuments {
return nil, fmt.Errorf("workflow not found")
}
return &w, err
}
func CreateWorkflow(w models.Workflow) (*models.Workflow, error) {
func CreateWorkflow(orgID string, w models.Workflow) (*models.Workflow, error) {
ctx, cancel := wfCtx()
defer cancel()
w.OrgID = orgID
w.WorkflowID = uuid.New().String()
w.CreatedAt = time.Now()
w.UpdatedAt = w.CreatedAt
@@ -217,6 +224,9 @@ func CreateWorkflow(w models.Workflow) (*models.Workflow, error) {
if err := ValidateWorkflow(w); err != nil {
return nil, err
}
if err := validateTargetServers(orgID, w.TargetServerIDs); err != nil {
return nil, err
}
normalizeInlineSteps(&w)
if _, err := db.Col("workflows").InsertOne(ctx, w); err != nil {
return nil, err
@@ -224,14 +234,17 @@ func CreateWorkflow(w models.Workflow) (*models.Workflow, error) {
return &w, nil
}
func UpdateWorkflow(id string, w models.Workflow) error {
func UpdateWorkflow(orgID, id string, w models.Workflow) error {
ctx, cancel := wfCtx()
defer cancel()
if err := ValidateWorkflow(w); err != nil {
return err
}
if err := validateTargetServers(orgID, w.TargetServerIDs); err != nil {
return err
}
normalizeInlineSteps(&w)
_, err := db.Col("workflows").UpdateOne(ctx, bson.M{"workflow_id": id}, bson.M{"$set": bson.M{
_, err := db.Col("workflows").UpdateOne(ctx, bson.M{"workflow_id": id, "org_id": orgID}, bson.M{"$set": bson.M{
"name": w.Name,
"target_server_ids": w.TargetServerIDs,
"steps": w.Steps,
@@ -240,6 +253,18 @@ func UpdateWorkflow(id string, w models.Workflow) error {
return err
}
// validateTargetServers rejects any target server that does not belong to the
// org. The IDs are client-supplied and are later consumed by the runner's
// unscoped lookups, so ownership has to be proven at the write boundary.
func validateTargetServers(orgID string, serverIDs []string) error {
for _, sid := range serverIDs {
if _, err := GetServer(orgID, sid); err != nil {
return fmt.Errorf("target server %s not found", sid)
}
}
return nil
}
// normalizeInlineSteps derives outputs for inline steps and strips fields that
// only belong to library steps.
func normalizeInlineSteps(w *models.Workflow) {
@@ -263,9 +288,9 @@ func normalizeInlineSteps(w *models.Workflow) {
}
}
func DeleteWorkflow(id string) error {
func DeleteWorkflow(orgID, id string) error {
ctx, cancel := wfCtx()
defer cancel()
_, err := db.Col("workflows").DeleteOne(ctx, bson.M{"workflow_id": id})
_, err := db.Col("workflows").DeleteOne(ctx, bson.M{"workflow_id": id, "org_id": orgID})
return err
}
@@ -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])
}
}
+17
View File
@@ -0,0 +1,17 @@
import { AuthProvider } from "@/components/AuthProvider";
import { Sidebar } from "@/components/Sidebar";
export default function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<AuthProvider>
<div className="flex h-screen overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-y-auto">{children}</main>
</div>
</AuthProvider>
);
}
+418
View File
@@ -0,0 +1,418 @@
"use client";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, auth as authApi, type OrgUser, type Role } from "@/lib/api";
import { useAuth } from "@/components/AuthProvider";
import { Badge, Button, Card, Modal, Table, Tbody, Td, Th, Thead, Tr } from "@/components/ui";
const ROLES: Role[] = ["owner", "admin", "member"];
const inputClass =
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30";
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">{label}</label>
{children}
{hint && <p className="mt-1 text-xs text-text-tertiary">{hint}</p>}
</div>
);
}
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<Role>("member");
const { data: users, isLoading, error } = useQuery({ queryKey: ["org-users"], queryFn: api.listOrgUsers });
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["org-users"] });
const { mutate: createUser, isPending: creating, error: createError } = useMutation({
mutationFn: () => api.createOrgUser({ email, password, role }),
onSuccess: () => {
invalidate();
setAddOpen(false);
setEmail("");
setPassword("");
setRole("member");
},
});
const { mutate: changeRole, error: roleError } = useMutation({
mutationFn: ({ userId, next }: { userId: string; next: Role }) => api.updateOrgUserRole(userId, next),
onSuccess: invalidate,
// A rejected change (last owner, owner-only grant) leaves the select showing
// the value the server refused — refetch so the row snaps back to the truth.
onError: invalidate,
});
const { mutate: removeUser, error: removeError } = useMutation({
mutationFn: (userId: string) => api.deleteOrgUser(userId),
onSuccess: invalidate,
});
const actionError = (roleError ?? removeError) as Error | null;
// The server lets only an owner grant or change the owner role. Mirror that
// here so admins aren't offered controls that can only 403.
const isOwner = user?.role === "owner";
const assignableRoles = isOwner ? ROLES : ROLES.filter((r) => r !== "owner");
return (
<Card>
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-text-primary">Members</h2>
<p className="mt-0.5 text-sm text-text-secondary">
People with access to this organization. Owners and admins can manage settings.
</p>
</div>
<Button variant="primary" size="sm" onClick={() => setAddOpen(true)}>
Add Member
</Button>
</div>
{actionError && (
<div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{actionError.message}
</div>
)}
{isLoading ? (
<div className="flex justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : error ? (
<p className="py-6 text-sm text-danger">{(error as Error).message}</p>
) : !users || users.length === 0 ? (
<p className="py-6 text-sm text-text-secondary">No members yet.</p>
) : (
<Table>
<Thead>
<Tr>
<Th>Email</Th>
<Th>Role</Th>
<Th>Sign-in</Th>
<Th>Last login</Th>
<Th className="text-right">Actions</Th>
</Tr>
</Thead>
<Tbody>
{users.map((u: OrgUser) => {
const isSelf = u.user_id === user?.user_id;
// Own row stays read-only, and only owners may act on owners.
const locked = isSelf || (u.role === "owner" && !isOwner);
return (
<Tr key={u.user_id}>
<Td>
<span className="font-medium">{u.email}</span>
{isSelf && <span className="ml-2 text-xs text-text-tertiary">(you)</span>}
</Td>
<Td>
{locked ? (
<Badge variant={roleVariant(u.role)}>{u.role}</Badge>
) : (
<select
value={u.role}
onChange={(e) => changeRole({ userId: u.user_id, next: e.target.value as Role })}
className="rounded-lg border border-border bg-surface-2 px-2 py-1 text-sm text-text-primary focus:border-accent/50 focus:outline-none"
>
{assignableRoles.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
)}
</Td>
<Td>
<Badge variant="neutral">{u.auth_source === "oidc" ? "SSO" : "Password"}</Badge>
</Td>
<Td className="text-text-secondary">
{u.last_login ? new Date(u.last_login).toLocaleString() : "Never"}
</Td>
<Td className="text-right">
{!locked && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm(`Remove ${u.email} from this organization?`)) removeUser(u.user_id);
}}
>
Remove
</Button>
)}
</Td>
</Tr>
);
})}
</Tbody>
</Table>
)}
<Modal open={addOpen} title="Add Member" onClose={() => setAddOpen(false)}>
<form
onSubmit={(e) => {
e.preventDefault();
createUser();
}}
className="space-y-4"
>
<Field label="Email">
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className={inputClass}
/>
</Field>
<Field
label="Password"
hint="Leave blank if this member will sign in through SSO instead."
>
<input
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={inputClass}
/>
</Field>
<Field label="Role">
<select value={role} onChange={(e) => setRole(e.target.value as Role)} className={inputClass}>
{assignableRoles.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</Field>
{createError && (
<div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{(createError as Error).message}
</div>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={() => setAddOpen(false)}>
Cancel
</Button>
<Button type="submit" variant="primary" loading={creating}>
Add Member
</Button>
</div>
</form>
</Modal>
</Card>
);
}
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 (
<Card>
<div className="flex justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
</Card>
);
}
const secretSet = cfg?.client_secret_set ?? false;
return (
<Card>
<div className="mb-4">
<h2 className="text-base font-semibold text-text-primary">Single Sign-On (OIDC)</h2>
<p className="mt-0.5 text-sm text-text-secondary">
Let members sign in with your identity provider. Users are provisioned into this organization on
first sign-in.
</p>
</div>
<div className="mb-5 rounded-lg border border-border bg-surface-2 p-3">
<p className="mb-2 text-xs font-medium text-text-secondary">
Register this redirect URL with your provider:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded bg-background px-2 py-1.5 font-mono text-xs text-text-primary">
{redirectUrl}
</code>
<Button type="button" variant="ghost" size="sm" onClick={copyRedirect}>
{copied ? "Copied!" : "Copy"}
</Button>
</div>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
save();
}}
className="space-y-4"
>
<Field label="Issuer URL" hint="The provider's OIDC discovery base, e.g. https://accounts.google.com">
<input
type="url"
required
value={issuer}
onChange={(e) => setIssuer(e.target.value)}
className={inputClass}
/>
</Field>
<Field label="Client ID">
<input
type="text"
required
value={clientId}
onChange={(e) => setClientId(e.target.value)}
className={inputClass}
/>
</Field>
<Field
label="Client Secret"
hint={
secretSet
? "A secret is stored. Leave this blank to keep it, or enter a new one to replace it."
: "No secret stored yet."
}
>
<input
type="password"
autoComplete="new-password"
placeholder={secretSet ? "•••••••• (unchanged)" : "Enter client secret"}
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
className={inputClass}
/>
</Field>
<div className="flex items-center gap-2 text-sm">
<span className={`inline-block h-2 w-2 rounded-full ${secretSet ? "bg-success" : "bg-text-tertiary"}`} />
<span className="text-text-secondary">
{secretSet ? "Client secret is configured" : "No client secret configured"}
</span>
</div>
<label className="flex items-center gap-2 text-sm text-text-secondary">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className="h-4 w-4 rounded border-border bg-surface-2 accent-accent"
/>
Enable SSO sign-in for this organization
</label>
{enabled && !secretSet && !clientSecret && (
<div className="rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
SSO cannot complete sign-in without a client secret.
</div>
)}
{error && (
<div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{(error as Error).message}
</div>
)}
<div className="flex items-center gap-3">
<Button type="submit" variant="primary" loading={isPending}>
{saved ? "Saved!" : "Save SSO Settings"}
</Button>
{saved && <span className="text-sm text-success">SSO settings saved.</span>}
</div>
</form>
</Card>
);
}
export default function OrgSettingsPage() {
const { org, isAdmin } = useAuth();
if (!isAdmin) {
return (
<div className="p-8">
<Card className="max-w-lg">
<h1 className="text-base font-semibold text-text-primary">You don&apos;t have access</h1>
<p className="mt-1 text-sm text-text-secondary">
Organization settings are available to owners and admins only. Ask an administrator if you need
access.
</p>
</Card>
</div>
);
}
return (
<div className="p-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-text-primary">Organization</h1>
<p className="mt-1 text-sm text-text-secondary">
{org ? `Manage members and sign-in for ${org.name}.` : "Manage members and sign-in."}
</p>
</div>
<div className="space-y-6">
<MembersCard />
<OIDCCard />
</div>
</div>
);
}
@@ -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 (
<div className="p-8">
<Card className="max-w-lg">
<h1 className="text-base font-semibold text-text-primary">You don&apos;t have access</h1>
<p className="mt-1 text-sm text-text-secondary">
Settings are available to owners and admins only. Ask an administrator if you need access.
</p>
</Card>
</div>
);
}
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
+1 -12
View File
@@ -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 (
<html lang="en" className="dark">
<body className="bg-background text-text-primary">
<Providers>
<AuthProvider>
<div className="flex h-screen overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-y-auto">
{children}
</main>
</div>
</AuthProvider>
</Providers>
<Providers>{children}</Providers>
</body>
</html>
);
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { useEffect, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { auth } from "@/lib/api";
import { Button, Card } from "@/components/ui";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
// If the org has no users yet, first-run setup is the only way in. And if the
// visitor already has a valid session on this host, the form is a dead end —
// send them into the app instead.
useEffect(() => {
(async () => {
try {
const s = await auth.bootstrapStatus();
if (s.needs_setup) {
window.location.href = "/setup";
return;
}
} catch {
// Status unavailable — fall through and let the login form stand.
}
try {
await auth.me();
window.location.href = "/";
} catch {
// Not signed in (or session invalid here) — show the form.
}
})();
}, []);
const { mutate: signIn, isPending, error } = useMutation({
mutationFn: () => auth.login(email, password),
onSuccess: () => {
window.location.href = "/";
},
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
signIn();
}
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-sm">
<div className="mb-8 flex flex-col items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-accent">
<svg className="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg>
</div>
<h1 className="text-xl font-semibold text-text-primary">Sign in to Vantage</h1>
</div>
<Card>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-text-secondary">
Email
</label>
<input
id="email"
type="email"
required
autoComplete="username"
value={email}
onChange={(e) => 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"
/>
</div>
<div>
<label htmlFor="password" className="mb-1.5 block text-sm font-medium text-text-secondary">
Password
</label>
<input
id="password"
type="password"
required
autoComplete="current-password"
value={password}
onChange={(e) => 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"
/>
</div>
{error && (
<div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{(error as Error).message}
</div>
)}
<Button type="submit" variant="primary" loading={isPending} className="w-full justify-center">
Sign In
</Button>
</form>
<div className="my-5 flex items-center gap-3">
<div className="h-px flex-1 bg-border" />
<span className="text-xs uppercase tracking-wider text-text-tertiary">or</span>
<div className="h-px flex-1 bg-border" />
</div>
<a href="/auth/oidc/start" className="block">
<Button type="button" variant="secondary" className="w-full justify-center">
Sign in with your organization&apos;s SSO
</Button>
</a>
<p className="mt-3 text-center text-xs text-text-tertiary">
SSO must be enabled for this organization by an administrator.
</p>
</Card>
</div>
</div>
);
}
+206
View File
@@ -0,0 +1,206 @@
"use client";
import { useEffect, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { auth } from "@/lib/api";
import { Button, Card } from "@/components/ui";
const MIN_PASSWORD_LENGTH = 8;
/**
* Org hosts are `<slug>.vantage.<rest>` and the apex is `vantage.<rest>` (see
* auth.hostSlug on the server). Build the new org's URL by prepending or
* replacing the leftmost label. Hosts that don't match that shape (localhost,
* bare IPs) have no per-org subdomain, so stay put.
*
* Setup runs on the apex, and the session cookie it sets is scoped to that
* exact host by design org hosts must not share cookies. So the new owner is
* sent to the org host's *login* page to sign in there, which is what puts a
* session cookie on the host their org actually lives on.
*/
function orgLoginUrlForSlug(slug: string): string {
if (typeof window === "undefined") return "/login";
const { protocol, host } = window.location;
const [hostname, port] = host.split(":");
const parts = hostname.split(".");
if (parts.length < 2 || parts[parts.length - 1] === "localhost") return "/login";
const rest = parts[0] === "vantage" ? parts : parts.slice(1);
if (rest[0] !== "vantage") return "/login";
const newHost = [slug, ...rest].join(".") + (port ? `:${port}` : "");
return `${protocol}//${newHost}/login`;
}
export default function SetupPage() {
const [orgName, setOrgName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [validationError, setValidationError] = useState<string | null>(null);
const [created, setCreated] = useState<{ slug: string; loginUrl: string } | null>(null);
// Setup is a one-shot route; once an owner exists it must not be reachable.
useEffect(() => {
auth
.bootstrapStatus()
.then((s) => {
if (!s.needs_setup) window.location.href = "/login";
})
.catch(() => {
// Status unavailable — let the form stand; the backend re-checks on submit.
});
}, []);
const { mutate: bootstrap, isPending, error } = useMutation({
mutationFn: () => auth.bootstrap({ org_name: orgName, email, password }),
onSuccess: (res) => {
setCreated({ slug: res.slug, loginUrl: orgLoginUrlForSlug(res.slug) });
},
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (password.length < MIN_PASSWORD_LENGTH) {
setValidationError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`);
return;
}
if (password !== confirm) {
setValidationError("Passwords do not match.");
return;
}
setValidationError(null);
bootstrap();
}
// Prefer the backend's message (it owns the real validation rules).
const message = validationError ?? (error ? (error as Error).message : null);
const inputClass =
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30";
if (created) {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-text-primary">Organization created</h1>
<p className="mt-1 text-sm text-text-secondary">
Your owner account is ready. One more step to finish signing in.
</p>
</div>
<Card>
<p className="text-sm text-text-secondary">
{created.slug} has its own address, and sign-in is kept separate per organization. Continue
to your organization&apos;s sign-in page and log in with the email and password you just
chose.
</p>
<code className="mt-3 block overflow-x-auto rounded bg-surface-2 px-2 py-1.5 font-mono text-xs text-text-primary">
{created.loginUrl}
</code>
<a href={created.loginUrl} className="mt-5 block">
<Button type="button" variant="primary" className="w-full justify-center">
Go to sign in
</Button>
</a>
</Card>
</div>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-text-primary">Welcome to Vantage</h1>
<p className="mt-1 text-sm text-text-secondary">
Create your organization and its owner account to get started.
</p>
</div>
<Card>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="org" className="mb-1.5 block text-sm font-medium text-text-secondary">
Organization name
</label>
<input
id="org"
type="text"
required
value={orgName}
onChange={(e) => setOrgName(e.target.value)}
className={inputClass}
/>
<p className="mt-1 text-xs text-text-tertiary">
Used to derive your organization&apos;s subdomain.
</p>
</div>
<div>
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-text-secondary">
Owner email
</label>
<input
id="email"
type="email"
required
autoComplete="username"
value={email}
onChange={(e) => setEmail(e.target.value)}
className={inputClass}
/>
</div>
<div>
<label htmlFor="password" className="mb-1.5 block text-sm font-medium text-text-secondary">
Password
</label>
<input
id="password"
type="password"
required
minLength={MIN_PASSWORD_LENGTH}
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={inputClass}
/>
<p className="mt-1 text-xs text-text-tertiary">
At least {MIN_PASSWORD_LENGTH} characters.
</p>
</div>
<div>
<label htmlFor="confirm" className="mb-1.5 block text-sm font-medium text-text-secondary">
Confirm password
</label>
<input
id="confirm"
type="password"
required
autoComplete="new-password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
className={inputClass}
/>
</div>
{message && (
<div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{message}
</div>
)}
<Button type="submit" variant="primary" loading={isPending} className="w-full justify-center">
Create Organization
</Button>
</form>
</Card>
</div>
</div>
);
}
+74 -28
View File
@@ -1,49 +1,68 @@
"use client";
import { createContext, useContext, useEffect, useState, ReactNode } from "react";
import { auth, type Org, type Role, type SessionUser } from "@/lib/api";
export interface User {
user_id: string;
email: string;
name: string;
}
export type { Org, Role, SessionUser };
interface AuthContextType {
user: User | null;
authEnabled: boolean;
user: SessionUser | null;
org: Org | null;
/** True for owner and admin — the roles the /api/settings and /api/org routes require. */
isAdmin: boolean;
}
const AuthContext = createContext<AuthContextType>({ user: null, authEnabled: false });
const AuthContext = createContext<AuthContextType>({ 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<User | null>(null);
const [authEnabled, setAuthEnabled] = useState(false);
const [user, setUser] = useState<SessionUser | null>(null);
const [org, setOrg] = useState<Org | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch("/auth/me", { credentials: "include" })
.then(async (res) => {
if (res.status === 401) {
window.location.href = "/auth/login";
let cancelled = false;
(async () => {
try {
const status = await auth.bootstrapStatus();
if (status.needs_setup) {
window.location.href = "/setup";
return;
}
const data = await res.json();
if (data.auth_enabled === false) {
setAuthEnabled(false);
} else {
setAuthEnabled(true);
setUser(data as User);
const me = await auth.me();
if (cancelled) return;
setUser(me.user);
setOrg(me.org);
setLoading(false);
} catch (err) {
if (cancelled) return;
const status = (err as { status?: number }).status;
if (status === 401) {
window.location.href = "/login";
return;
}
// Anything else (backend unreachable, org host mismatch) leaves us with
// no session. Rendering children here would mount the whole shell with
// user=null — every page would fire its own doomed API calls and the UI
// would read as a member view. Show the failure instead.
setError((err as Error).message || "Unable to load your session.");
setLoading(false);
})
.catch(() => {
// Backend unreachable — don't block the UI
setLoading(false);
});
}
})();
return () => {
cancelled = true;
};
}, []);
if (loading) {
@@ -54,9 +73,36 @@ export function AuthProvider({ children }: { children: ReactNode }) {
);
}
if (error || !user) {
return (
<div className="flex h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md rounded-xl border border-border bg-surface p-6 text-center">
<h1 className="text-base font-semibold text-text-primary">Can&apos;t load your session</h1>
<p className="mt-2 text-sm text-text-secondary">
{error ?? "Unable to load your session."}
</p>
<div className="mt-5 flex justify-center gap-2">
<button
onClick={() => window.location.reload()}
className="rounded-lg bg-accent px-3 py-2 text-sm font-medium text-white"
>
Retry
</button>
<a
href="/login"
className="rounded-lg border border-border px-3 py-2 text-sm font-medium text-text-secondary"
>
Sign in
</a>
</div>
</div>
</div>
);
}
const isAdmin = user.role === "owner" || user.role === "admin";
return (
<AuthContext.Provider value={{ user, authEnabled }}>
{children}
</AuthContext.Provider>
<AuthContext.Provider value={{ user, org, isAdmin }}>{children}</AuthContext.Provider>
);
}
+49 -12
View File
@@ -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 (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m3-6H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21" />
</svg>
);
}
const navItems: NavItem[] = [
{ href: "/servers", label: "Servers", icon: <ServerIcon /> },
{ href: "/monitors", label: "Monitors", icon: <MonitorIcon /> },
@@ -84,12 +95,32 @@ const navItems: NavItem[] = [
{ href: "/workflows", label: "Workflows", icon: <WorkflowIcon /> },
{ href: "/steps", label: "Steps", icon: <StepsIcon /> },
{ href: "/audit", label: "Audit Log", icon: <AuditIcon /> },
{ href: "/settings", label: "Settings", icon: <SettingsIcon /> },
{ href: "/settings/org", label: "Organization", icon: <OrgIcon />, adminOnly: true },
{ href: "/settings", label: "Settings", icon: <SettingsIcon />, 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<string | null>((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 (
<aside className="flex h-screen w-60 flex-col border-r border-border bg-surface">
@@ -99,14 +130,16 @@ export function Sidebar() {
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg>
</div>
<span className="text-base font-semibold text-text-primary">Vantage</span>
<div className="min-w-0">
<span className="block text-base font-semibold leading-tight text-text-primary">Vantage</span>
{org && <span className="block truncate text-xs text-text-secondary">{org.name}</span>}
</div>
</div>
<nav className="flex-1 overflow-y-auto px-3 py-4">
<ul className="space-y-1">
{navItems.map((item) => {
const isActive =
pathname === item.href || pathname.startsWith(item.href + "/");
{visibleItems.map((item) => {
const isActive = activeHref === item.href;
return (
<li key={item.href}>
<Link
@@ -128,21 +161,25 @@ export function Sidebar() {
</nav>
<div className="border-t border-border px-4 py-3">
{authEnabled && user && (
{user && (
<div className="mb-3">
<p className="truncate text-sm font-medium text-text-primary">{user.name || user.email}</p>
<p className="truncate text-xs text-text-secondary">{user.email}</p>
<p className="truncate text-xs text-text-secondary">
{user.email}
{user.role && <span className="ml-1 text-text-tertiary">· {user.role}</span>}
</p>
</div>
)}
<div className="flex items-center justify-between">
<p className="text-xs text-text-secondary">Vantage v1.0</p>
{authEnabled && user && (
<a
href="/auth/logout"
{user && (
<button
type="button"
onClick={handleLogout}
className="text-xs text-text-secondary transition-colors hover:text-danger"
>
Logout
</a>
</button>
)}
</div>
</div>
+171 -2
View File
@@ -296,6 +296,74 @@ export interface WorkflowRun {
server_runs: ServerRun[];
}
export type Role = "owner" | "admin" | "member";
/** The session as returned by GET /auth/me — mirrors auth.Session on the server. */
export interface SessionUser {
user_id: string;
org_id: string;
role: Role;
email: string;
name: string;
}
export interface Org {
org_id: string;
name: string;
slug: string;
created_at: string;
}
export interface MeResponse {
user: SessionUser;
org: Org | null;
}
export interface BootstrapStatus {
needs_setup: boolean;
}
export interface BootstrapResponse {
org: Org;
slug: string;
}
export interface OrgUser {
user_id: string;
org_id: string;
email: string;
role: Role;
auth_source: "local" | "oidc";
created_at: string;
last_login?: string;
}
export interface OrgUserInput {
email: string;
password: string;
role: Role;
}
/**
* GET /api/org/oidc. `client_secret_set` reports whether a secret is stored
* the secret itself is write-only and is never returned. Submitting an empty
* `client_secret` on save keeps the stored one.
*/
export interface OrgOIDCConfig {
issuer?: string;
client_id?: string;
enabled: boolean;
client_secret_set: boolean;
updated_at?: string;
}
export interface OrgOIDCInput {
issuer: string;
client_id: string;
client_secret: string;
enabled: boolean;
}
class ApiError extends Error {
constructor(
public status: number,
@@ -317,8 +385,17 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
});
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new ApiError(res.status, text || `HTTP ${res.status}`);
const text = await res.text().catch(() => "");
// Handlers report failures as {"error": "..."} — unwrap it so the message
// reaching the UI is the sentence the backend wrote, not raw JSON.
let message = text || res.statusText || `HTTP ${res.status}`;
try {
const body = JSON.parse(text);
if (body?.error) message = body.error;
} catch {
// non-JSON body — keep the text as-is
}
throw new ApiError(res.status, message);
}
if (res.status === 204) {
@@ -328,7 +405,99 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
return res.json();
}
/**
* Auth endpoints live at the root (not under /api) and report failures as
* `{"error": "..."}`, which we surface verbatim so backend validation messages
* reach the user.
*/
async function authRequest<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(path, {
credentials: "include",
headers: { "Content-Type": "application/json", ...options?.headers },
...options,
});
if (!res.ok) {
let message = `HTTP ${res.status}`;
try {
const body = await res.json();
if (body?.error) message = body.error;
} catch {
// non-JSON body — keep the status message
}
throw new ApiError(res.status, message);
}
if (res.status === 204) {
return undefined as T;
}
return res.json();
}
export const auth = {
bootstrapStatus(): Promise<BootstrapStatus> {
return authRequest<BootstrapStatus>("/auth/bootstrap-status");
},
bootstrap(input: { org_name: string; email: string; password: string }): Promise<BootstrapResponse> {
return authRequest<BootstrapResponse>("/auth/bootstrap", {
method: "POST",
body: JSON.stringify(input),
});
},
login(email: string, password: string): Promise<{ ok: boolean }> {
return authRequest<{ ok: boolean }>("/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
},
logout(): Promise<void> {
return authRequest<void>("/auth/logout", { method: "POST" });
},
me(): Promise<MeResponse> {
return authRequest<MeResponse>("/auth/me");
},
/** The URL an admin must register with their OIDC provider. */
oidcRedirectUrl(): string {
if (typeof window === "undefined") return "/auth/oidc/callback";
return `${window.location.origin}/auth/oidc/callback`;
},
};
export const api = {
// Organization
listOrgUsers(): Promise<OrgUser[]> {
return request<OrgUser[]>("/org/users");
},
createOrgUser(input: OrgUserInput): Promise<OrgUser> {
return request<OrgUser>("/org/users", { method: "POST", body: JSON.stringify(input) });
},
updateOrgUserRole(userId: string, role: Role): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>(`/org/users/${userId}/role`, {
method: "PUT",
body: JSON.stringify({ role }),
});
},
deleteOrgUser(userId: string): Promise<void> {
return request<void>(`/org/users/${userId}`, { method: "DELETE" });
},
getOrgOIDC(): Promise<OrgOIDCConfig> {
return request<OrgOIDCConfig>("/org/oidc");
},
saveOrgOIDC(input: OrgOIDCInput): Promise<{ saved: boolean }> {
return request<{ saved: boolean }>("/org/oidc", { method: "PUT", body: JSON.stringify(input) });
},
// Servers
listServers(): Promise<Server[]> {
return request<Server[]>("/servers");