feat: add auth and multi-tenancy (Schritt 2, part 1/2)

internal/auth is pure logic (bcrypt hashing, session token generation)
with no DB access — persistence for account/app_user/session lives in
internal/store like everything else, via migration 0003.

account is the tenant (Mandant); app_user is a login inside one account;
session is a real server-side row (not a signed stateless token) so
logout can actually end a session rather than the client just
forgetting a JWT. submission.account_id is NOT NULL — added directly
rather than the nullable-then-backfill dance, since no submission rows
exist anywhere yet (verified empty on the test server before writing
the migration). Added as migration 0003 (new file), not folded into an
earlier one, since 0001/0002 are already applied on the test server.

store.ErrNotFound lets callers distinguish "wrong email" / "unknown
session" from a genuine DB error — matters for login, where those two
cases should both fail closed but for different reasons.

Not yet wired into internal/web — that's the next commit. All of this
is tested against real Postgres (14 store tests green) but isn't
reachable from any HTTP handler yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
noroot
2026-08-27 15:48:40 +02:00
parent f5ad08cebd
commit 2a16dc2200
13 changed files with 498 additions and 19 deletions

View File

@@ -0,0 +1,4 @@
ALTER TABLE submission DROP COLUMN account_id;
DROP TABLE session;
DROP TABLE app_user;
DROP TABLE account;

View File

@@ -0,0 +1,33 @@
-- Mandantentrennung: ein account ist der Mandant (Creator, Agentur,
-- Marke oder Kanzlei als eigene Organisation), app_user ist ein Login
-- innerhalb eines accounts. session ist bewusst eine eigene Tabelle
-- statt signierter, zustandsloser Tokens — ein Logout muss eine
-- Sitzung wirklich beenden können, nicht nur clientseitig "vergessen"
-- werden.
CREATE TABLE account (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- "app_user" statt "user", da user ein reserviertes Wort in SQL ist.
CREATE TABLE app_user (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES account (id),
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE session (
token TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES app_user (id),
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Jeder Beitrag gehört ab jetzt zu genau einem Mandanten. NOT NULL ohne
-- Default ist hier sicher, weil noch keine Submission-Zeilen aus der
-- Zeit vor Auth existieren (kein Kunde live).
ALTER TABLE submission ADD COLUMN account_id UUID NOT NULL REFERENCES account (id);