The API is the boundary; hiding the control in web/ is a courtesy. A role
editable in two places is a role with two answers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
users is unique on (instance_id, email) now, so an unscoped FindOne could
return an arbitrary one of several matching users. On the login path that
means signing someone into a tenant that is not theirs.
GetUserByEmail is deleted rather than left unused. Local sign-in resolves
its instance from the host, falling back to the single instance a
self-hosted deployment has, and refuses to guess otherwise. The OIDC
cross-instance guard goes: a scoped lookup cannot return another
instance's user, which is a stronger guarantee than the check it replaces.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing writes them yet. They land now so phases 2 and 3 do not require a
second rebuild of every service that consumes the shared module.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Monitor execution and the workflow runner are deliberately unguarded: billing
state must not take away a customer's ability to know their infrastructure is
on fire, and killing a run midway leaves a half-configured server.
The plan called for a server-limit check in gRPC Register. Left out: a server
row only comes from CreateServer, which already checks the cap, so counting in
Register counts the caller itself and would reject a legitimate agent at
exactly the cap.
RequireActiveLicense is mounted on the /api group so new routes are gated by
where they live. GET /api/servers/new is named explicitly: it mints a
pre-registration token, so it mutates despite the method.
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.
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.
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.
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.
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.
- 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.
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.
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.
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 "".
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.
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
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.