mb mybillbook private beta
Engineering disclosure

Security

Concrete, mechanical statements about how mb is built. No marketing copy disguised as security claims.

Tenant isolation

Every business table (15+ of them) has Postgres Row-Level Security enabled with the same policy shape:

ALTER TABLE <table> ENABLE ROW LEVEL SECURITY;

CREATE POLICY rls_tenant_isolation ON <table>
  USING (tenant_id = NULLIF(
    current_setting('app.tenant_id', true),
    ''
  )::uuid);

GRANT SELECT, INSERT, UPDATE, DELETE ON <table> TO mb_app;

The application connects as mb_app — a role that does not have BYPASSRLS. If the request handler forgets to set the GUC, the cast fails and zero rows are returned. There is no application-layer "WHERE tenant_id =" that a future bug could remove.

Continuously verified by scripts/rls-fuzzer which seeds two tenants, switches to mb_app, and asserts cross-tenant invisibility on every CI push.

Authentication

  • · Passwords: argon2id (memory-hard, GPU-resistant). Cost: 64 MiB memory, 3 iterations, 2 parallelism — re-tunable, with auto-rehash on next login when params change.
  • · Sessions: server-side rows in sessions table. Logout actually invalidates. No JWT — we cannot lose-track-of an invalidated token.
  • · Cookies: HttpOnly, Secure, SameSite=Lax.
  • · CSRF: double-submit cookie bound to the session ID, constant-time compared on every state-changing request.
  • · Google OIDC: full JWKS verification on the ID token — no shortcut. State cookie 5-minute TTL to prevent replay.
  • · Rate limits: 5/hour on signup, 10/5min on signin, 20/hour on Google OAuth start, 20/minute on the public quote endpoint.

Audit trail

Every mutation (signup, login, finalize, payment, credit-note issue, quotation transition, supplier/purchase change) writes a row to audit_log with the actor user, tenant, entity, IP, user-agent, request ID, and before/after JSONB payloads. Append-only is enforced at the DB:

CREATE OR REPLACE FUNCTION raise_immutable() RETURNS trigger ...
$$ BEGIN RAISE EXCEPTION 'audit_log is append-only'; END $$;

CREATE TRIGGER audit_log_no_update_delete
BEFORE UPDATE OR DELETE ON audit_log
FOR EACH ROW EXECUTE FUNCTION raise_immutable();

Same trigger pattern on ledger_entries. Tampering with history requires the DB owner role, which the application never has.

Data at rest & in transit

  • · TLS: all traffic is HTTPS-only via Cloudflare. HSTS preload-ready.
  • · Postgres: nightly snapshots encrypted at rest by DigitalOcean.
  • · R2 object storage: PDFs server-side-encrypted; access via short-lived signed URLs only.
  • · Secrets: API keys + DB password in /etc/mb/mb.env on the droplet, mode 0600, never in environment variables exported to subprocess space.
  • · No client-side keys: there is no offline cryptography. All trust roots are server-side.

Continuous verification

  • · govulncheck on every push (Go stdlib + module CVE scan, pinned to Go 1.25.10+).
  • · gosec via golangci-lint — bans risky patterns (writable file modes, weak randomness, SQL injection vectors).
  • · forbidigo bans float64 in money paths (exempted only at GST portal JSON serialisation boundaries).
  • · RLS fuzzer (custom): seeds two tenants, asserts cross-tenant invisibility for all 15 business tables per push.
  • · Integration tests against real Postgres on every push (concurrency, auth flow, cancellation guards).
  • · Migration round-trip — every push applies goose up + down + up to confirm reversibility.

Responsible disclosure

Found a vulnerability? Email vikas@networkershome.com with subject "Security disclosure". We commit to:

  • Acknowledge within 2 working days.
  • Triage and respond with a fix-or-rationale within 14 days.
  • Credit you publicly on a future Security page acknowledgements section (with your permission) once the issue is fixed.
  • Not pursue legal action against good-faith research.