Commit Graph
60 Commits
Author SHA1 Message Date
mrhid6 2017c95a7a feat(monitors): sweep metric monitors per server with incidents per breach 2026-09-17 08:59:24 +00:00
mrhid6 17ed0192c1 feat(monitors): public heartbeat ping endpoints, header token, log masking and sweeper 2026-09-17 08:27:34 +00:00
mrhid6 25541345a8 feat(mfa): TOTP secrets, recovery codes and factor lookup 2026-09-16 08:30:55 +00:00
mrhid6 b78a9b3832 feat(mfa): user_mfa and webauthn_credentials collections 2026-09-16 08:27:54 +00:00
mrhid6 139658864b feat: patch scheduler loop; record patch results and verify reboots from the agent stream 2026-09-15 09:17:27 +00:00
mrhid6 8f1ea6d5a0 feat: patch models, scoped collections and indexes; pin vantage-shared v0.5.0 2026-09-15 08:27:04 +00:00
mrhid6 6ee203f5e9 chore: replace em dashes with hyphens, add no-em-dash rule to CLAUDE.md
Chart Release / chart (push) Successful in 20s
Server Deploy / deploy (push) Failing after 1m52s
2026-09-10 09:18:55 +00:00
mrhid6 9bcec168b9 refactor(api): require every /api route to declare its server-scope status 2026-09-09 08:38:53 +00:00
mrhid6 dc6e1b3c29 fix: widen server-scope boot check, filter out-of-scope key assignments
serverTouchingRoutes in cmd/main.go filtered on "server"/"console"/an exact
workflows-run match, which is how POST /api/keys/:id/assign reached
production with no scope check and no boot-time signal at all: its path
names neither. Widen the filter to also match ":serverId" and "assign",
and document at the filter why a substring match is the weak part of this
design — a route that acts on a server without saying so in its path stays
invisible to it — noting that inverting the model (every /api route
declares itself, with an exemption list) would be the stronger fix and is
left as a follow-up. Re-running the mechanical check against the widened
filter swept in no route beyond what serverScopedRoutes already declared.

GET /api/keys/:id also leaked out-of-scope hostnames: it returned every
assignment for a key, server attached, unfiltered by the caller's tag
restriction. getKey now drops any assignment whose server fails
services.ServerInTokenScope before returning the list — silently, so the
response carries no count of what was removed — while still returning the
key itself, since a restricted token may legitimately hold a key also
assigned outside its scope. GetAssignmentsWithServers has exactly one
caller (getKey), so the filtering is done in the handler rather than
threaded into the service. Recorded in serverScopedRoutes; its path
matches none of the filter's substrings either, so it is not boot-enforced
and is kept as a considered decision, same as the assign/revoke entries.
2026-09-09 07:51:04 +00:00
mrhid6 a0b5565a63 docs: explain why GET /api/mcp deliberately answers 405 in stateless mode 2026-09-08 14:07:54 +00:00
mrhid6 0166b17299 feat: serve the mcp endpoint behind the licence feature 2026-09-08 13:57:21 +00:00
mrhid6 f87986b4f7 feat: enforce token tag restrictions at the server resolution chokepoints 2026-09-08 13:43:38 +00:00
mrhid6 da1dc90ac5 fix: resolve the public status page's tenant from a trusted X-Forwarded-Host
The SSR fetch set `Host` to the visitor's hostname. `Host` is a forbidden
header name and undici discards it silently, so the Go server saw
`server:8080`, `hostSlug` returned "", `InstanceFromHost` returned false and
every public status page 404'd on every deployment. The feature did not work.

- `web/` now forwards the visitor's host as `X-Forwarded-Host`, and their
  address on `X-Forwarded-For` — without the latter gin sees a request from the
  Next pod with no XFF and every visitor of every page shares one 120/min
  bucket, tripped by exactly the traffic an outage produces.
- `publicStatusInstance` honours `X-Forwarded-Host` only when `c.RemoteIP()` is
  in `TRUSTED_PROXIES`. It is a tenant selector, so an untrusted peer must not
  be able to name one; `RemoteIP()` rather than `ClientIP()` because the latter
  is reconstructed from the very headers being judged. `TrustedProxies()` moves
  from main.go into the api package so the variable keeps one parser.
- A host naming no slug on a non-cloud deployment resolves the sole instance,
  the way bootstrap does. A self-hosted install at vantage.acme.com or an IP
  has no slug and could never serve a status page; more than one instance is a
  404 rather than a guess, and an unknown-but-well-formed slug stays a 404.
- `InstanceFromHost` gains an explicit-host variant rather than a second copy
  of the slug rules, and now caches negative lookups: an unknown host cost a
  Mongo query per anonymous request, which is also a timing oracle separating
  "no such instance" from "instance exists, page does not".
- The handler's `@Router` annotation is dropped. openapi.json declares one
  server of `/api`, so it published `/api/public/status/{pageId}` — a path that
  does not exist. The real address is described in prose instead.
2026-08-25 09:04:47 +00:00
mrhid6 a3c6b2a305 feat: public status page endpoint with per-address rate limit 2026-08-24 14:29:24 +00:00
mrhid6 6f998ff506 feat: status page CRUD and cache invalidation 2026-08-24 14:15:25 +00:00
mrhid6 1c15961309 feat: status page schema, licence feature and indexes 2026-08-24 14:00:00 +00:00
mrhid6 22b99ff895 feat: Monitor grath zoom 2026-08-24 10:58:50 +00:00
mrhid6 9df4a29210 fix: Separate stacked securityDefinitions into distinct comment groups
swag v2.0.0-rc5's parseSecAttributesV3 resolves a security scheme's map key
via getSecurityDefinitionKey(lines), which scans from the start of whatever
comment-line slice it was handed and returns the first @securitydefinitions
match — ignoring the current parse position entirely. Three
@securityDefinitions.apikey blocks stacked in one Go comment group (the
three were separated only by bare '//' lines, which do not split an
ast.CommentGroup) therefore all resolved to the first block's name
(cookieAuth), with the last block's in/name/description winning: the
generated document had exactly one securityScheme, keyed cookieAuth, body
esoAuth.

Separating the three blocks with real blank source lines splits them into
three distinct ast.CommentGroups, so swag's file-level comment scan (which
requires no other tokens between them, same rule Go uses for doc comments)
hands each block its own line slice and each resolves its own key.
Regenerated openapi.json now carries all three schemes with correct
bodies, referenced with no dangling security requirements.
2026-08-13 07:54:56 +00:00
mrhid6 bfd185adbb feat: Add OpenAPI response types and top-level swag annotations
Named response types for handlers that were returning anonymous gin.H
literals, so a generated annotation and what the handler actually returns
cannot disagree. main.go carries the top-level swaggo info block (title,
description, security schemes for cookie, bearer token and ESO auth).
2026-08-12 15:22:42 +00:00
mrhid6 be4f488db3 feat: Enforce API token scopes from the route map
Keyed on the registered gin route pattern rather than a per-route
decorator, because a route registered without a decorator would be
unguarded. An unmapped route reached by a token is a 403, and a boot-time
check refuses to start when any /api route is missing, so the failure
lands at deploy rather than as a customer's surprise 403.
2026-08-12 14:40:00 +00:00
mrhid6 6ad65a1242 feat: Add the api_tokens collection and its indexes
The unique index on token_hash is what makes authentication an indexed
lookup rather than a scan, so this builder is fatal on failure like
EnsureAuthIndexes rather than warning like the secrets one.

Registered in ScopedCollections so instance purge reaches it.
2026-08-12 14:16:12 +00:00
mrhid6 675689a458 feat(audit): server-side paging, search and category filter; one event format
The page rendered a map of eleven event types to labels and seven to colours.
The server emits forty-seven. Everything unmapped fell through to its raw
string, so "Key Assigned" in green sat above "workflow.schedule_updated" in
grey — the same kind of fact in two formats, which made the column look like it
carried a meaning it did not.

Presentation is now derived rather than enumerated. Event types are named
<category>.<action> by every call site, so the category becomes a chip, the
action is humanised, and the tone comes from the verb. A type added to the
server tomorrow gets a sensible label and colour with no second list to update;
the override table holds only the dozen the rule reads badly for. Every row is
one treatment, and colour never carries meaning alone — the sentence beside it
says the same thing in words.

Paging and filtering are server-side, unlike the fleet lists that answer with
everything and slice in the browser. audit_retention_days is a licensed
entitlement measured in months, and this log is read to answer questions about
the past, so a browser filtering the most recent page would report "no results"
for events that exist. GET /api/audit now takes q, category, limit and skip and
answers {events, total} — a short page is not evidence of the end of the log,
which is why the total is counted rather than inferred.

audit_logs had no indexes at all: every read was a collection scan with an
in-memory sort over an append-only collection. Adds (instance_id, created_at)
and warns rather than failing, matching EnsureSecretIndexes.

Two bugs found by running the deriver over all forty-seven real types rather
than eyeballing it: the tone rules matched only past-tense verbs, leaving
auth_provider.delete drawn as neutral beside key.deleted in red; and
"unaccepted" matched "accepted", so withdrawing an acceptance read as the same
caution as granting one.
2026-08-10 15:25:48 +01:00
mrhid6 0838d1d735 feat: models and indexes for the workload registry 2026-08-07 08:45:00 +01:00
mrhid6 5dda3b5c4a feat: vulnerability scanning pipeline, matcher, scheduler and API
Completes tasks 10-15 and fixes what was outstanding:

- vulndb.Pull implemented with oras-go, streaming the ~50MB layer and
  staging both files before replacing either, so a failed pull leaves the
  previous database intact rather than a half-written one.
- db.go: Vulnerability.Severity is a string, not trivy Severity, so the
  int conversion did not compile. Severity now resolves vendor (highest
  when vendors disagree) then NVD then unknown, and CVSS is read too.
- findings.go: added sweepFixedFindings plus the fleet query, severity
  counts, rescan flag and accept/unaccept the API needs.
- vulnrules.go: added rule CRUD and the digest builder. ResolveTargets
  returns []models.Server, not []string, so filterByServers was wrong.
- api/vulnerabilities.go was an empty file while handlers.go registered
  twelve routes against it; written, grouped by CVE.
- shared/mail: added the missing sender. The templates were orphaned and
  the HTML one was a copy of the text one, defining "subject" (which
  html/template would escape) and emitting no markup. render.go parses
  every template in init(), so a bad one panics server, admin and sitesvc
  at boot — go build never runs init(), which is why nothing complained.
- notify: digests dispatch through their own path so SMTP gets the digest
  template rather than arriving dressed as a monitor alert.
2026-08-06 14:33:46 +01:00
mrhid6 3a6d24fe0e feat: models and indexes for package inventory and CVE findings
Adds server_packages, vuln_findings and vuln_alert_rules to
ScopedCollections rather than to a separate deletion list. purgeInstance
derives its collection list from that registry, so instance deletion
follows automatically and there is no second copy to drift.
2026-08-06 11:59:00 +01:00
mrhid6 a1e6986a64 feat: fire scheduled workflow runs from the housekeeping leader 2026-08-04 13:53:42 +01:00
mrhid6 fef0b7c7a1 feat: read and write server tags, resolve targets from the database 2026-08-04 13:33:00 +01:00
mrhid6 e22faebfcd feat: migrate instance_oidc into auth_providers (0005) 2026-08-03 10:38:52 +01:00
mrhid6 71240f183c fix: Fixes to server shutdown stream 2026-07-31 16:44:47 +01:00
mrhid6 165114471f fix: Fixes to running on kubernetes 2026-07-31 10:34:10 +01:00
mrhid6 de78688093 feat: authenticate the server's Redis connection
InitRedis now takes a username and password, read from REDIS_USERNAME and
REDIS_PASSWORD, matching what admin has always done. Both empty keeps an
unauthenticated Redis working; a password with an empty username is what a
legacy requirepass instance needs, since go-redis then sends AUTH with one
argument instead of two.

This is what lets a Kubernetes install point at a managed Redis instead of
the bundled one.
2026-07-31 09:36:31 +01:00
mrhid6 dabe6fe3aa feat: Updated package path to match repo 2026-07-28 10:01:40 +01:00
mrhid6 66140aaf58 fixes 2026-07-27 15:59:45 +01:00
mrhid6andClaude Opus 5 c4e6ad5485 feat(license): metered licensing — catalogue, entitlements, and enforcement
Implements spec 7 tasks 2-10 on top of the six-plan payload from task 1.

Admin: plans re-keyed on (deployment, tier); new catalogue collection holds
every Paddle price ID (one row per priceable component); new entitlements
collection holds desired beside granted. admin/internal/catalogue owns both
folds — entitlement to licence limits, and entitlement to Paddle line items —
so the base allowance is subtracted in exactly one place. licensing.Issue now
snapshots the instance's granted entitlement, never desired. Free is enforced
per account AND deployment. Staff endpoints for plans, catalogue and
entitlements; Free self-hosted can be claimed and renewed on its annual term;
the reaper stays cloud-only.

Server: enforces the monitor cap, audit-log retention (daily sweep, skips
Unlimited and lapsed instances), and gates the OIDC callback. Unset limits are
filled from the seed plan at the single decode site so old blobs never read as
zero.

Frontends: adminsite gains a catalogue price-ID editor, six-plan allowance
screen, and a catalogue-driven PlanConfigurator mounted on the staff instance
page. web shows monitors, audit retention and support level on the licence page.

Docs: CLAUDE.md, spec index and plan 5 preamble updated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 09:37:39 +01:00
mrhid6andClaude Opus 5 4e90e8619f feat(server): reap Free instances whose licence lapsed
The control plane owns deletion because it is the only service that knows
what an instance is made of; mirroring that collection list into admin
would drift, and a drift here deletes the wrong rows.

Defaults OFF. Eligibility is three positive assertions — Free tier, an
expiry that exists, and an expiry past the window — so a missing or stale
field is never eligible. The instance document is deleted last, making an
interrupted purge retryable rather than orphaning rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:23:37 +01:00
mrhid6 8e5f35f40c revert(server): drop the licence grandfather migration
Instances stay read-only until a licence is set. No licence is generated by
the control plane, which keeps the signing key out of it entirely.
2026-07-24 15:49:46 +01:00
mrhid6 09a39090c6 feat(server): grandfather existing cloud instances onto Professional 2026-07-24 15:26:26 +01:00
mrhid6 f646ce5c47 chore(server): remove rename-rollback
Migration 0004 is complete and verified on live, so the inverse rename has
served its purpose. Reverting the release now means restoring a backup.
2026-07-24 14:40:35 +01:00
mrhid6 539403cccf fix(server): drop stale indexes before renaming the tenant key
Two defects found by running migration 0004 against a seeded legacy database.

A unique index on org_id treats a missing org_id as null. Renaming the field
strips it, so the second document collided and the whole update failed:

  E11000 duplicate key error collection: instance_oidc index: org_id_1
  dup key: { org_id: null }

The index cleanup therefore has to run BEFORE the field rename, not after.
rename-rollback needs the symmetric step for instance_id, or reverting hits
the same wall.

The detection also silently matched nothing: the driver decodes an index key
document as bson.D, not bson.M, so the type assertion always failed and no
index was ever dropped. IndexKeyedOn now handles both.
2026-07-24 14:15:05 +01:00
mrhid6 3891a2c239 feat(server): add rename-rollback command for migration 0004 2026-07-24 13:59:04 +01:00
mrhid6 4f041d2f4b refactor(server): rename Org to Instance
Adds migration 0004_org_to_instance, the ScopedCollections list, the
AssertNoScopedCollectionMissed boot check, and moves EnsureAuthIndexes into
its own file.

Two ordering constraints the rename exposed, both now enforced and commented:

- 0004 must run BEFORE EnsureAuthIndexes. The index builder creates
  instances.slug, which would create an empty instances collection and make
  0004 refuse to rename orgs onto an existing target.
- Migrations 0001 to 0003 run BEFORE 0004 and still read and write org_id, so
  they use a private legacyOrg struct rather than shared/models.
2026-07-24 13:58:41 +01:00
mrhid6 1a6cf03c03 feat: Removed comments 2026-07-24 09:51:30 +01:00
mrhid6 9ffae221ac fix: Fixed server install scripts 2026-07-22 12:59:05 +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 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 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 022b1ef8ec feat(server): auth indexes + default-org backfill migration 2026-07-21 16:25:37 +01:00