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 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.