diff --git a/README.md b/README.md index 4fee2bd..32999f9 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,9 @@ can target the same API. **Stack:** C++23 · Qt 6.8 · QML · PostgreSQL 16 · CMake ≥ 3.28 · libpqxx · libsodium · Qt Test / Qt Quick Test — no source-level dependencies. -**Status:** pre-release, under active development. Increment 1 (foundations: build system, -database, migrations, REST skeleton, client shell, test scaffolding) is wrapping up with -public-repo readiness (license, CI); next up is authentication & RBAC. See the [Roadmap](#roadmap) and the +**Status:** pre-release, under active development. Increment 1 (foundations — build system, +database, migrations, REST skeleton, client shell, tests, CI) shipped as `v0.1.0`; +Increment 2 (authentication & RBAC) is in progress. See the [Roadmap](#roadmap) and the [Implementation log](#implementation-log). The project maximizes Qt framework usage — Qt is used everywhere unless it is clearly @@ -33,12 +33,20 @@ keeping the future container's migration entrypoint minimal. > Developed incrementally, one reviewed step at a time. This README grows with each step — > see [Repository layout](#repository-layout) for what exists today. -**Contents:** [Architecture](#architecture) · [Prerequisites](#prerequisites) · -[Building](#building) · [Running the stack](#running-the-stack) · -[Development database](#development-database) · [Testing](#testing) · -[Code style](#code-style) · [Development workflow](#development-workflow) · -[Repository layout](#repository-layout) · [Roadmap](#roadmap) · -[Implementation log](#implementation-log) · [License](#license) +## Table of contents + +1. [Architecture](#architecture) +2. [Prerequisites](#prerequisites) +3. [Building](#building) +4. [Running the stack](#running-the-stack) +5. [Development database](#development-database) +6. [Testing](#testing) +7. [Code style](#code-style) +8. [Development workflow](#development-workflow) +9. [Repository layout](#repository-layout) +10. [Roadmap](#roadmap) +11. [Implementation log](#implementation-log) +12. [License](#license) ## Architecture @@ -180,6 +188,11 @@ The database URL resolves in order: existing `MODULO_DB_URL` in the environment `.env` at the repo root → the dev-database default. `modulo_migrate --help` shows the underlying CLI (`--url`, `--dir`). +Current schema: `0001_init` (metadata) and `0002_auth` (`users`, `roles`, `user_roles`, +`sessions`, plus a reusable `set_updated_at()` trigger and the `citext` extension for +case-insensitive emails). The data model is diagrammed in +[`docs/high_level_design.md`](docs/high_level_design.md#5-data-model). + ## Testing Tests are registered with CTest under the labels `unit`, `integration`, and `ui`: @@ -269,8 +282,8 @@ CMakePresets.json configure/build/test presets (dev, dev-asan, dev-tidy, release | Increment | Scope | |---|---| -| 1 — Foundations (in progress, final step) | Build system, dockerized Postgres, migrations, REST skeleton with health endpoint, client shell, test scaffolding, public-repo readiness | -| 2 — Auth & RBAC | Users/roles/sessions schema, Argon2id password hashing (libsodium), opaque bearer tokens, `authed()` / `requireRole()` guards, login flow + dark theme system in the client | +| 1 — Foundations (DONE, `v0.1.0`) | Build system, dockerized Postgres, migrations, REST skeleton with health endpoint, client shell, test scaffolding, public-repo readiness | +| 2 — Auth & RBAC (in progress) | Users/roles/sessions schema, Argon2id password hashing (libsodium), opaque bearer tokens, `authed()` / `requireRole()` guards, login flow + dark theme system in the client | | 3 — Transactions | BUY/SELL/SWAP records with server-side filtering & pagination; add/edit/delete dialog with price-per-unit ⇄ total-value derivation | | 4 — Transfers | Bank ⇄ exchange IN/OUT transfers; shared bank-account / exchange reference data | | 5 — Holdings & dashboards | Per-asset aggregation (amount, median buy/sell, net profit, portfolio share, value in USD/EUR) and the first Qt Charts dashboards | @@ -295,6 +308,7 @@ CMakePresets.json configure/build/test presets (dev, dev-asan, dev-tidy, release | 1.6b — Qt Test everywhere | Decision: Qt Test replaces Catch2 (one framework for C++ and QML); Catch2 + CPM removed — the project now has zero source-level dependencies | | 1.7 — Docs finalization | README restructured for a public audience (status, contents, architecture, workflow, roadmap); HLD gained the test-architecture view; local working agreement (CLAUDE.md) refreshed | | 1.8 — Public-repo readiness | MIT `LICENSE`; GitHub Actions CI (macOS runner: brew deps, `ci` preset, `-Werror` build, format check, unit + ui tests); README badges + License section; repository made public and tagged `v0.1.0` | +| 2.1 — Auth schema | `0002_auth.sql`: `users` (citext email, Argon2id hash, disabled_at), `roles` (admin/user), `user_roles`, `sessions` (SHA-256 token digest, sliding expiry, revocation, partial index); `set_updated_at()` trigger; ER diagram in the HLD | ## License diff --git a/db/migrations/0002_auth.sql b/db/migrations/0002_auth.sql new file mode 100644 index 0000000..082fa7b --- /dev/null +++ b/db/migrations/0002_auth.sql @@ -0,0 +1,70 @@ +-- 0002_auth: users, roles and sessions. +-- +-- Password hashing (Argon2id) and session-token generation happen in the +-- server (libsodium); the database only ever stores the password hash and +-- the SHA-256 digest of a session token, never the token itself. +-- Append-only: never edit this file once applied. + +CREATE EXTENSION IF NOT EXISTS citext; -- case-insensitive emails + +-- Generic "bump updated_at on UPDATE" trigger, reused by later tables. +CREATE FUNCTION set_updated_at() RETURNS trigger + LANGUAGE plpgsql +AS $$ +BEGIN + NEW.updated_at := now(); + RETURN NEW; +END; +$$; + +CREATE TABLE users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email citext NOT NULL UNIQUE, + display_name text NOT NULL, + password_hash text NOT NULL, -- Argon2id, libsodium crypto_pwhash_str format + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + disabled_at timestamptz, -- set = account locked out + + CONSTRAINT users_email_not_blank CHECK (length(trim(email)) > 0), + CONSTRAINT users_display_name_not_blank CHECK (length(trim(display_name)) > 0) +); + +CREATE TRIGGER users_set_updated_at + BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- Fixed role catalogue; ids are stable and referenced by the server. +CREATE TABLE roles ( + id smallint PRIMARY KEY, + name text NOT NULL UNIQUE +); + +INSERT INTO roles (id, name) VALUES + (1, 'admin'), + (2, 'user'); + +CREATE TABLE user_roles ( + user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE, + role_id smallint NOT NULL REFERENCES roles (id), + PRIMARY KEY (user_id, role_id) +); + +CREATE TABLE sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE, + token_sha256 bytea NOT NULL UNIQUE, -- digest of the opaque bearer token + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, -- sliding: extended on use + last_seen_at timestamptz NOT NULL DEFAULT now(), + revoked_at timestamptz, -- set = logged out / revoked + + CONSTRAINT sessions_token_sha256_length CHECK (octet_length(token_sha256) = 32), + CONSTRAINT sessions_expires_after_creation CHECK (expires_at > created_at) +); + +-- Lookups: "which user owns this token?" (covered by the UNIQUE index on +-- token_sha256) and "active sessions of a user" (logout-everywhere, listing). +CREATE INDEX sessions_user_id_active_idx + ON sessions (user_id) + WHERE revoked_at IS NULL; diff --git a/docs/high_level_design.md b/docs/high_level_design.md index d08e8d6..4f56418 100644 --- a/docs/high_level_design.md +++ b/docs/high_level_design.md @@ -136,7 +136,59 @@ sequenceDiagram M-->>U: "N applied, M skipped" (exit code) ``` -## 5. Test architecture +## 5. Data model + +```mermaid +erDiagram + users { + uuid id PK + citext email UK "case-insensitive" + text display_name + text password_hash "Argon2id (libsodium)" + timestamptz created_at + timestamptz updated_at "trigger set_updated_at" + timestamptz disabled_at "null = active" + } + roles { + smallint id PK + text name UK "admin, user" + } + user_roles { + uuid user_id PK, FK + smallint role_id PK, FK + } + sessions { + uuid id PK + uuid user_id FK + bytea token_sha256 UK "digest only, 32 bytes" + timestamptz created_at + timestamptz expires_at "sliding 30 days" + timestamptz last_seen_at + timestamptz revoked_at "null = active" + } + meta { + text key PK + text value + timestamptz updated_at + } + schema_migrations { + integer version PK + text name + text checksum "md5 of file" + timestamptz applied_at + } + + users ||--o{ user_roles : "has" + roles ||--o{ user_roles : "granted as" + users ||--o{ sessions : "owns (cascade delete)" +``` + +Migrations so far: `0001_init` (meta), `0002_auth` (users, roles, user_roles, sessions, `set_updated_at()` trigger, +`citext` extension). `schema_migrations` is not created by a migration file — the migration engine +(`modules/db`, `migrator.cpp`) creates it on first run and owns it. Tokens are never stored: the server keeps only the SHA-256 digest, so a database leak cannot +be replayed as a login. + +## 6. Test architecture ```mermaid flowchart LR