Skip to content

Architecture

Lockstep VM is a multi-tenant vulnerability- and asset-intelligence platform. It pulls devices, software, users and vulnerabilities from several sources (SentinelOne, Rapid7 InsightVM, Microsoft Intune, ConnectWise Automate, Microsoft Entra ID and arbitrary CSV/Excel exports), reconciles them into a shared inventory, and serves that data through a web UI.

This page covers the runtime shape of the system: the services, how a request flows through them, how tenancy and auth work, and how integration config is bootstrapped from environment variables at startup. For the collections those services read and write, see the Data model.

Components

ComponentTechRuns asResponsibility
Backend APIFastAPI + motor (async MongoDB)vulnmgmt-backend, uvicorn on :8000REST API under /api/v1, integration sync jobs (FastAPI BackgroundTasks), the software rollup, auth verification.
FrontendAstro SSR + React islandsvulnmgmt-frontend on :4321Server-rendered UI, Better Auth (sessions, email, 2FA), proxies API calls to the backend.
Primary datastoreMongoDB 7vulnmgmt-mongodb on :27017All webapp data — inventory, per-source collections, integration config, Better Auth sessions/users. Database vulnmgmt.
Legacy workerAPScheduler (BlockingScheduler)vulnmgmt-workerLegacy scheduled jobs (Rapid7 sync, CVE enrichment, SLA checks, reports) against PostgreSQL, not MongoDB.
Legacy relational DBPostgreSQL 15vulnmgmt-postgres on :5432Backing store for the legacy src/vulnmgmt CLI/worker. Independent of the webapp’s MongoDB.
DB adminpgAdmin 4vulnmgmt-pgadmin on :8080Optional Postgres admin UI.

The webapp (backend + frontend + MongoDB) and the legacy stack (worker + Postgres + pgAdmin) share a Compose file and network but are otherwise separate data planes. New integration syncs run inside the FastAPI backend as background tasks, not on the APScheduler worker.

Backend hot-reload vs. baked frontend

The two app services are deployed differently on purpose:

  • Backend — the host directory ./backend is bind-mounted into the container at /app, and uvicorn runs with --reload. Editing a Python file on the host restarts the worker in place, so backend changes are live without a rebuild.
  • Frontend — built into a production Astro bundle at image-build time. PUBLIC_* env vars are inlined into the client bundle during that build, so changing them (or the UI) requires rebuilding the frontend image. There is no live reload in the deployed frontend.

Request flow

A typical authenticated API call:

  1. Browser → Frontend (:4321). The user hits an Astro SSR page. Better Auth (running in the Astro server runtime) issues an httpOnly session cookie (vulnmgmt.session_token) on the frontend origin.

  2. Frontend → Backend (:8000). UI code calls the FastAPI API under /api/v1, forwarding the caller’s cookie (or a bearer JWT). CORS on the backend allows the frontend origin with allow_credentials=True so the cookie survives the cross-origin hop.

  3. Auth resolution. Every protected route depends on get_current_user (app/core/security.py). It resolves identity from one of two sources, in order:

    • Better Auth session cookie — if the request carries a session_token cookie, the backend forwards the raw Cookie: header to the frontend’s /api/auth/get-session endpoint over the internal Docker network (app/core/better_auth.py, validate_session_cookie). Better Auth caches the lookup ~5 minutes, so the extra hop is cheap.
    • Bearer JWT (legacy) — otherwise, a Authorization: Bearer <jwt> header is decoded with JWT_SECRET/HS256 (decode_access_token).

    Both paths normalize to the same dict: {user_id, email, tenant_id, role, source}. get_current_admin_user layers a role == "admin" check on top.

  4. Tenant scoping. Routes read current_user["tenant_id"] and scope every MongoDB query by it (see Multi-tenancy).

  5. Handler → MongoDB. The handler uses the injected motor database (Depends(get_database)) to read/write collections, then returns JSON.

  6. Long-running work. Integration syncs are dispatched as FastAPI BackgroundTasks (e.g. _run_sync in each integration router) so the HTTP request returns immediately while the pull runs asynchronously and updates a sync_status field on the config document.

Rate limiting (slowapi) wraps the whole app — a default of RATE_LIMIT_PER_MINUTE with per-route overrides. A global exception handler returns a structured 500 with the exception type and request path.

Router registration

The backend mounts a single api_router under settings.API_V1_PREFIX (/api/v1) in app/main.py:

app.include_router(api_router, prefix=settings.API_V1_PREFIX)

api_router itself is assembled in app/api/v1/__init__.py, which imports each endpoint module and includes its router under a prefix + tag. A representative slice:

api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(tenant.router, prefix="/tenant", tags=["tenant"])
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
api_router.include_router(devices.router, prefix="/devices", tags=["devices"])
api_router.include_router(sentinelone.router, prefix="/sentinelone", tags=["sentinelone"])
api_router.include_router(rapid7.router, prefix="/rapid7", tags=["rapid7"])
api_router.include_router(intune.router, prefix="/intune", tags=["intune"])
api_router.include_router(connectwise_automate.router, prefix="/connectwise-automate", tags=["connectwise-automate"])
api_router.include_router(csv_sources.router, prefix="/csv-sources", tags=["csv-sources"])
api_router.include_router(entra.router, prefix="/entra", tags=["entra"])
# … plus vulnerabilities, research, reports, versions, integrations, subscriptions,
# tags, weaknesses, projects, connectwise, threat-intel, news-feeds, advisories,
# dashboards, invites

To add a surface: create app/api/v1/endpoints/<name>.py exposing a router = APIRouter(), import it in app/api/v1/__init__.py, and include_router it. See the API reference for the full route list.

Multi-tenancy

Every domain document carries a tenant_id, and every query filters on it. get_current_user supplies the caller’s tenant (from the Better Auth tenantId, defaulting to "default", or from the JWT tenant_id claim). Writes always include tenant_id, and reads always match on it, so tenants never see each other’s data. Uniqueness keys are compound — e.g. {tenant_id, source, device_id, application_name, version} for inventory_items.

The _resolve_config adopt-on-read pattern

Integration credentials live in per-integration config collections, one document per tenant (sentinelone_config, intune_config, rapid7_config, connectwise_automate_config, entra_config, connectwise_config). At startup the backend seeds a single tenant_id: "default" config document from environment variables (see below). Other tenants inherit it lazily via an adopt-on-read helper present in each integration router:

async def _resolve_config(tenant_id, db):
config = await db.sentinelone_config.find_one({"tenant_id": tenant_id})
if config:
return config # per-tenant config wins
if tenant_id != "default":
seed = await db.sentinelone_config.find_one({"tenant_id": "default"})
if seed: # copy the env-seeded default on first access
adopted = {k: v for k, v in seed.items() if k != "_id"}
adopted["tenant_id"] = tenant_id
await db.sentinelone_config.update_one(
{"tenant_id": tenant_id}, {"$setOnInsert": adopted}, upsert=True)
return await db.sentinelone_config.find_one({"tenant_id": tenant_id})
return None

The effect: env-provided credentials apply to every tenant automatically, but a tenant’s own edits or deletes create/replace their own document and win from then on. _setOnInsert guarantees adoption never clobbers an existing per-tenant config.

Env seeding at startup

app/main.py defines a FastAPI lifespan context manager that runs on boot. It connects to MongoDB, then invokes one _seed_* function per integration:

@asynccontextmanager
async def lifespan(app: FastAPI):
await connect_to_mongo()
await _seed_intune_config()
await _seed_sentinelone_config()
await _seed_connectwise_config()
await _seed_rapid7_config()
await _seed_connectwise_automate_config()
await _seed_entra_config()
yield
await close_mongo_connection()

Each _seed_* function:

  • Returns early if the integration’s env vars are unset (e.g. _seed_sentinelone_config needs SENTINELONE_API_TOKEN + SENTINELONE_CONSOLE_URL).
  • Skips if a tenant_id: "default" config already exists (idempotent — safe on every restart).
  • Otherwise inserts a tenant_id: "default" config document with enabled: True and empty sync counters.

Notable details:

  • Intune / Entra read a certificate PEM from INTUNE_CERT_KEY_PATH (mounted read-only at /app/certs) and extract just the PRIVATE KEY block via _extract_private_key. _seed_entra_config deliberately reuses the same Azure app registration and certificate as Intune (Entra just needs User.Read.All / Group.Read.All consented).
  • These seeds populate only the "default" tenant; other tenants pick them up through _resolve_config (above).

Settings themselves are loaded by app/core/config.py (pydantic-settings), which reads from the environment (and an optional .env), including MONGODB_URL, DATABASE_NAME, JWT_SECRET, CORS_ORIGINS, and all the integration credential vars.

The legacy APScheduler worker

src/vulnmgmt/worker/scheduler.py runs a BlockingScheduler with cron/interval jobs against PostgreSQL (DATABASE_URL), independent of the webapp. Registered jobs include:

JobSchedule
Rapid7 full syncWeekly (Sun 02:00)
Rapid7 incremental syncEvery 6 hours
Critical CVE detectionHourly
SLA breach checkHourly
CVE enrichment (NVD / FIRST.org)Every 2 hours
Risk scoringEvery 4 hours
Software inventory importDaily 03:00
Software → CVE matching (CPE)Daily 04:00
Daily summary reportDaily 08:00

This worker predates the FastAPI webapp. The current per-integration syncs (SentinelOne, Rapid7, Intune, ConnectWise Automate, Entra, CSV) run as FastAPI background tasks in the backend and write to MongoDB.

Docker Compose topology

All services share the vulnmgmt-network bridge and resolve each other by container name:

  • backend depends on mongodb; mounts ./backend → /app (hot reload), ../certs → /app/certs:ro, and a backend_uploads volume. Reaches the frontend at http://frontend:4321 (FRONTEND_INTERNAL_URL) for session validation, and MongoDB at mongodb://…@mongodb:27017.
  • frontend depends on backend; gets BETTER_AUTH_*, MONGODB_URL, and RESEND_API_KEY (Better Auth transactional email). PUBLIC_API_URL / PUBLIC_BETTER_AUTH_URL are build-time client vars.
  • worker depends on a healthy postgres and runs uv run -m vulnmgmt.worker.scheduler.

See Docker & local setup for running it end to end.