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
| Component | Tech | Runs as | Responsibility |
|---|---|---|---|
| Backend API | FastAPI + motor (async MongoDB) | vulnmgmt-backend, uvicorn on :8000 | REST API under /api/v1, integration sync jobs (FastAPI BackgroundTasks), the software rollup, auth verification. |
| Frontend | Astro SSR + React islands | vulnmgmt-frontend on :4321 | Server-rendered UI, Better Auth (sessions, email, 2FA), proxies API calls to the backend. |
| Primary datastore | MongoDB 7 | vulnmgmt-mongodb on :27017 | All webapp data — inventory, per-source collections, integration config, Better Auth sessions/users. Database vulnmgmt. |
| Legacy worker | APScheduler (BlockingScheduler) | vulnmgmt-worker | Legacy scheduled jobs (Rapid7 sync, CVE enrichment, SLA checks, reports) against PostgreSQL, not MongoDB. |
| Legacy relational DB | PostgreSQL 15 | vulnmgmt-postgres on :5432 | Backing store for the legacy src/vulnmgmt CLI/worker. Independent of the webapp’s MongoDB. |
| DB admin | pgAdmin 4 | vulnmgmt-pgadmin on :8080 | Optional 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
./backendis 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:
-
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. -
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 withallow_credentials=Trueso the cookie survives the cross-origin hop. -
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_tokencookie, the backend forwards the rawCookie:header to the frontend’s/api/auth/get-sessionendpoint 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 withJWT_SECRET/HS256(decode_access_token).
Both paths normalize to the same dict:
{user_id, email, tenant_id, role, source}.get_current_admin_userlayers arole == "admin"check on top. - Better Auth session cookie — if the request carries a
-
Tenant scoping. Routes read
current_user["tenant_id"]and scope every MongoDB query by it (see Multi-tenancy). -
Handler → MongoDB. The handler uses the injected
motordatabase (Depends(get_database)) to read/write collections, then returns JSON. -
Long-running work. Integration syncs are dispatched as FastAPI
BackgroundTasks(e.g._run_syncin each integration router) so the HTTP request returns immediately while the pull runs asynchronously and updates async_statusfield 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, invitesTo 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 NoneThe 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:
@asynccontextmanagerasync 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_configneedsSENTINELONE_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 withenabled: Trueand 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 thePRIVATE KEYblock via_extract_private_key._seed_entra_configdeliberately reuses the same Azure app registration and certificate as Intune (Entra just needsUser.Read.All/Group.Read.Allconsented). - 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:
| Job | Schedule |
|---|---|
| Rapid7 full sync | Weekly (Sun 02:00) |
| Rapid7 incremental sync | Every 6 hours |
| Critical CVE detection | Hourly |
| SLA breach check | Hourly |
| CVE enrichment (NVD / FIRST.org) | Every 2 hours |
| Risk scoring | Every 4 hours |
| Software inventory import | Daily 03:00 |
| Software → CVE matching (CPE) | Daily 04:00 |
| Daily summary report | Daily 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:
backenddepends onmongodb; mounts./backend → /app(hot reload),../certs → /app/certs:ro, and abackend_uploadsvolume. Reaches the frontend athttp://frontend:4321(FRONTEND_INTERNAL_URL) for session validation, and MongoDB atmongodb://…@mongodb:27017.frontenddepends onbackend; getsBETTER_AUTH_*,MONGODB_URL, andRESEND_API_KEY(Better Auth transactional email).PUBLIC_API_URL/PUBLIC_BETTER_AUTH_URLare build-time client vars.workerdepends on a healthypostgresand runsuv run -m vulnmgmt.worker.scheduler.
See Docker & local setup for running it end to end.