Security
How Otavi protects tenant data, and the hardening applied before the platform was opened to unknown users (vhub-0.47.0).
Trust model
- The application owns all authentication and authorization. The reverse proxy terminates TLS and routes; it does not gate app routes (vhub-0.46).
- Tenants are isolated by
tenant_idon every data table. Every counting or species-facing endpoint applies the tenant scope server-side; an omittedtenantIdmeans "the caller's tenant", not "all tenants" (platform admins excepted). - Roles: viewer < member < admin < owner, per tenant; plus a global platform admin. Routes that take a target tenant from the body re-anchor on that tenant, so an admin of one tenant cannot act on another.
Devices
Every mutating satellite route goes through one rule (deviceAccess in middleware/auth.ts), anchored on the device's own team. Before hub 0.51 those routes checked the role on the caller's active team and only membership on the device's team, so a person who was admin in one team and viewer in another could administer the second team's devices; the regression test for that lives in tenant-isolation.test.ts. Re-registration no longer adopts a device by name for anyone but its registrant or an admin (a name proves nothing about possession; the device id does). When a membership ends (removal, leaving, account deletion) the person's devices are disconnected by default: keys expired, broker login dropped. A phone must not keep uploading audio to a team its owner can no longer see.
Sessions and tokens
- Session JWTs are
httpOnly,secure,SameSite=Lax, host-only cookies, signed HS256 (pinned on verify), 7-day expiry. - Each JWT carries the
token_versionit was minted with. A password change or reset bumpsusers.token_version, so every older session stops resolving immediately, and remembered MFA devices are dropped at the same time. - Password reset tokens: 256-bit random, SHA-256 at rest, single-use, 60-minute expiry. Invite tokens: 192-bit random, bound to an email when one is given (only that address can redeem), TTL clamped to a week. Share links: truncated HMAC of the detection id, promoted detections only, compared in constant time.
- API keys: 256-bit random, SHA-256 at rest. Tenant keys expire after a year; satellite keys are per-device and pinned to their satellite.
- MFA: TOTP (AES-256-GCM at rest) + WebAuthn passkeys + bcrypt-hashed backup codes.
Abuse resistance
@fastify/rate-limitguards/api/auth/*, tenant creation and invites.request.ipis derived from a bounded proxy hop count, so a spoofedX-Forwarded-Forcannot reset the limiter or forge audit rows.- Login is uniform: a locked account and a wrong password return the same response, and the unknown-user path burns the same bcrypt work as a real one, so registered emails cannot be enumerated.
- Self-registration and tenant creation are platform-setting toggles; invite links bypass the registration toggle by design.
Browser hardening
- Security headers (HSTS,
X-Frame-Options: DENY,X-Content-Type-Options: nosniff,Referrer-Policy) are set at the edge (Traefik), by nginx for the SPA, and by@fastify/helmetfor the API. The API sends a strict CSP; the SPA ships a Report-Only CSP that becomes enforcing once validated. - CORS and the WebSocket upgrade accept only the app origin and the Capacitor WebView origins, never a reflected origin.
Outbound requests (SSRF)
User-supplied URLs (webhook alert channels, scheduled-export delivery) are validated before every send: https only, the hostname is DNS-resolved and rejected if it maps to a private, loopback or link-local address (or is a bare, non-qualified name), and redirects are not followed. This keeps a webhook from reaching internal services (postgres, redis, minio, the cloud metadata endpoint).
Ingest integrity
The MQTT broker pins each device to its own topic, but the tenant segment is a wildcard, so the hub does not trust the message body: tenant and satellite identity come from the topic and the database, and identifiers are validated before a storage key is built. A device cannot write into another tenant or forge an object key.
Storage
- MinIO buckets are private; no bucket is public-read and no presigned URLs are issued. Every object is streamed through an authenticated hub route that checks the object's tenant first.
- Uploaded images are validated by content (magic bytes), stored and served with the sniffed type plus
nosniffandContent-Disposition. CSV exports neutralise spreadsheet formula injection.
Mobile app
- Release APKs are signed with an offline keystore kept out of the repository; the build produces a non-debuggable release build with backups disabled, so device backups do not expose the stored hub key or MQTT password.
- The in-app updater downloads only over https from the configured hub, follows no redirects, and verifies the APK's SHA-256 before handing it to the system installer.
Operator checklist before going public
- Set strong values for
POSTGRES_PASSWORD,REDIS_PASSWORD,MINIO_ROOT_PASSWORD,MQTT_ADMIN_PASSWORD,MQTT_HUB_PASSWORDandJWT_SECRET(32-byte random). These reach only the internal Docker networks, but should not be defaults. - Set
MFA_ENCRYPTION_KEY(32-byte hex) so TOTP and webhook secrets are encrypted at rest. - Generate the Android release keystore once and back it up with the volume tree; losing it means installed apps can never be updated again.
- Decide the registration posture: invite-only, or self-registration on.