Security model
Konsultverkstad is a multi-user SaaS handling consulting business data
(clients, hours, invoices with bank account numbers, and personal data
about each user's own clients). This document is the public-facing
summary; the internal compliance record lives in COMPLIANCE.md, the
runbook in OPERATIONS.md, the user-facing notice in PRIVACY.md.
Threat model
The app is single-tenant per user — every user is functionally an admin
of their own data, and there are no privileged roles inside the
application. There are two trust levels:
- Owner. The user who signed up and owns their workspace.
- Guest. A user invited via
canvas_sharesto a specific subtree.
There is no global admin role inside the app. The service_role key
(Supabase) is held by the server only and is used for two narrow
operations: creating user accounts during signup/invite and generating
magic links for invited guests. It is never sent to the browser.
Out of scope:
- Network-level attacks (DDoS, traffic interception). Railway + Supabase
handle TLS and rate limiting. - Compromise of the Supabase project itself.
- Client device compromise (browser extensions, stolen cookies).
Enforcement layers
Postgres Row-Level Security is the primary access-control mechanism.
Every table in the application schema has RLS enabled, with policies of
the form user_id = auth.uid() (or, for shared resources, augmented withhas_canvas_access(node_id)).
SECURITY DEFINER functions (e.g., the node_activity triggers,has_canvas_access, future claim_invoice_number()) all setsearch_path = public to prevent search-path injection, and only ever
read/write rows scoped to auth.uid().
Server actions / API routes sit on top of RLS. They use the SSR
client (anon key + cookie session) so all queries are subject to RLS by
default. The service role is only used in two places:
actions/shares.ts— to create accounts for invited guests and to
generate magic-link tokens.actions/auth.ts:signup— currently gated behindENABLE_SIGNUP.
If a server action ever reaches for the service role, that's a flag to
re-audit: the action becomes responsible for authorization that RLS
would otherwise enforce.
Known guarantees
- A user can only read/write their own nodes, logs, links, files,
viewports, profile, activity rows, and (eventually) invoices. - A guest invited to a canvas can read nodes/links/files in that
canvas's subtree, plus the corresponding activity log. Guests
cannot see logs (billing data) or the owner's profile. - Guests cannot reassign node ownership (
user_idis locked by thenodes_lock_user_idtrigger introduced in migration 0013). - A canvas share row must have
owner_user_idmatching the owning
user ofcanvas_node_id— enforced by triggercanvas_shares_validate_ownership(migration 0013). - Activity rows can only be inserted by triggers (
with check (false)
on the table). Clients cannot forge audit history. - The service-role key is never exposed to the browser bundle
(SUPABASE_SERVICE_ROLE_KEYhas noNEXT_PUBLIC_prefix). - Error monitoring (Sentry) is configured with
sendDefaultPii: false
and a custombeforeSendscrubber that strips request bodies,
cookies, auth headers, and sensitive query params before any event
leaves the running process. Seesentry.shared.ts. - Sign-up is gated behind
ENABLE_SIGNUP=truein the server
environment. When enabled, new accounts go through Supabase's
standardauth.signUp()flow, which requires email confirmation
before login. The previous service-role auto-confirm path is no
longer used.
Known limitations / TODO
These are accepted gaps that should be revisited before the
corresponding feature ships.
Invoice immutability (phase 2). Once an invoice's status isImplemented bysentorpaid, the sourcelogsrows that fed it must become
read-only to satisfy Swedish bookkeeping rules.
thelock_invoiced_logsandrelease_logs_on_voidtriggers in
migration 0014.Storage bucket policies (phase 2).Implemented forprofile-assetsin migration 0016: bucket is public-read (logos
are meant to be visible on printed/sent invoices), but writes,
updates, and deletes are scoped to the owner via a path-prefix
check ((storage.foldername(name))[1] = auth.uid()::text). The
existingnode-filesbucket has not been re-audited; revisit when
user-uploaded files become routine.- CSP
unsafe-inline. Next.js's default rendering relies on inline
styles and scripts; the currentContent-Security-Policypermits
them. Tightening to nonce-based CSP requires a middleware rewrite and
is deferred. api/auth/signinreturns raw access/refresh tokens in JSON.
Removed in the multi-user prep pass; the orphan route was deleted.
All sign-in goes through the SSR client which sets HttpOnly cookies.Audit log for invoice state changes (phase 2).Implemented
in migration 0015:invoice_activitytable withwith check (false)
on inserts and SECURITY DEFINER triggers for create + status changes.- PII export / deletion. GDPR Article 15/17. Today, deleting an
auth.usersrow cascades correctly. A user-facing export endpoint
doesn't exist yet; add when needed. - Rate limiting on auth endpoints. Supabase enforces this for the
built-in routes; verify the dashboard settings periodically. - Backups. Supabase point-in-time recovery is paid-tier. For the
current single-user setup this is acceptable; once invoices exist,
schedule apg_dumpto user-controlled storage.
Reporting a vulnerability
This is a personal-use application. If you find a real vulnerability,
tell David directly.