From e543fe2229e15887664f6291929fb7abf4b7ac2a Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Fri, 13 Feb 2026 14:02:05 +0100 Subject: [PATCH] docs: update sqlite design doc to reflect completed implementation --- .../2026-02-12-sqlite-repositories-design.md | 235 ++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 docs/plans/2026-02-12-sqlite-repositories-design.md diff --git a/docs/plans/2026-02-12-sqlite-repositories-design.md b/docs/plans/2026-02-12-sqlite-repositories-design.md new file mode 100644 index 0000000..493c776 --- /dev/null +++ b/docs/plans/2026-02-12-sqlite-repositories-design.md @@ -0,0 +1,235 @@ +# SQLite Repository Implementation — Design & Session State + +> **For Claude:** This document captures the full project state and in-progress design decisions +> for the SQLite repository implementation. Resume from "Next Steps" section. + +## Project State (as of 2026-02-12) + +### Git + +- **Branch:** `main` (10 commits, no feature branches) +- **HEAD:** `9d7a67b fix: add collision retry for userid generation` +- **Working tree:** Clean (untracked: `.idea/`, `README.md`, `docs/`) +- **All 17 tests passing**, all quality checks green + +### What Exists + +| File | Purpose | +|------|---------| +| `src/fastapi_oidc_op/config.py` | `Settings(BaseSettings)` with `env_prefix="OIDC_OP_"`, `StorageBackend` enum (sqlite/mongodb), `sqlite_path` default `data/oidc_op.db` | +| `src/fastapi_oidc_op/models.py` | `User`, `WebAuthnCredential`, `PasswordCredential`, `MagicLink`, `CredentialType` enum. Datetimes use `datetime.UTC`. `MagicLink.expires_at` is required (no default). | +| `src/fastapi_oidc_op/store/protocols.py` | `@runtime_checkable` Protocol interfaces: `UserRepository`, `CredentialRepository`, `MagicLinkRepository` | +| `src/fastapi_oidc_op/userid.py` | `generate_userid()` (pure, proquint 32-bit), `generate_unique_userid(user_repo, max_retries=10)` (async, collision retry) | +| `src/fastapi_oidc_op/app.py` | `create_app(settings)` factory. `/health` endpoint. Settings on `app.state`. Docs only in debug mode. | +| `tests/conftest.py` | `settings` fixture, async `client` fixture via httpx `ASGITransport` | +| `scripts/check.sh` | Quality gate: ruff format, ruff check, ty check, pytest | +| `pyproject.toml` | Python 3.13, hatchling build, src layout. Deps: fastapi, idpyoidc, pydantic-settings, fido2, argon2-cffi, motor, aiosqlite, proquint, httpx. Dev: ruff, ty, pytest, pytest-asyncio. | + +### Package Structure + +``` +src/fastapi_oidc_op/ +├── __init__.py +├── app.py +├── config.py +├── models.py +├── userid.py +├── oidc/__init__.py +├── authn/__init__.py +├── manage/__init__.py +├── invite/__init__.py +└── store/ + ├── __init__.py + ├── protocols.py + ├── mongodb/__init__.py + └── sqlite/__init__.py +``` + +### Existing Protocol Signatures (must implement exactly) + +```python +class UserRepository(Protocol): + async def create(self, user: User) -> User + async def get_by_userid(self, userid: str) -> User | None + async def get_by_username(self, username: str) -> User | None + async def update(self, user: User) -> User + async def list_users(self, offset: int = 0, limit: int = 100) -> list[User] + async def delete(self, userid: str) -> bool + +class CredentialRepository(Protocol): + async def create_webauthn(self, credential: WebAuthnCredential) -> WebAuthnCredential + async def create_password(self, credential: PasswordCredential) -> PasswordCredential + async def get_webauthn_by_user(self, user_id: str) -> list[WebAuthnCredential] + async def get_webauthn_by_credential_id(self, credential_id: bytes) -> WebAuthnCredential | None + async def get_password_by_user(self, user_id: str) -> PasswordCredential | None + async def update_webauthn(self, credential: WebAuthnCredential) -> WebAuthnCredential + async def delete_webauthn(self, user_id: str, credential_id: bytes) -> bool + async def delete_password(self, user_id: str) -> bool + +class MagicLinkRepository(Protocol): + async def create(self, link: MagicLink) -> MagicLink + async def get_by_token(self, token: str) -> MagicLink | None + async def mark_used(self, token: str) -> bool + async def delete_expired(self) -> int +``` + +### Design Document + +Full design doc at `docs/plans/2026-02-11-oidc-op-design.md`. Key points: +- SQLite uses `aiosqlite` with raw SQL +- Storage backend selected at startup via config +- SQLite is the default test backend (no external deps) + +### Known Quirks + +- **ty 0.0.16:** `python-version` goes under `[tool.ty.environment]`, no `src` field at top level +- **ruff 0.15:** version command is `ruff version` not `ruff --version` +- **User.userid** (no underscore) vs **credential.user_id** (underscore) — intentional, matches design doc + +--- + +## Design Decisions (confirmed by user) + +### 1. Schema Initialization: SQL Migration Files + +- Directory: `src/fastapi_oidc_op/store/sqlite/migrations/` +- Numbered SQL files: `001_initial.sql`, `002_...`, etc. +- Tracker table `_migrations` records applied files +- Migration runner at startup: read files, skip applied, execute in order, record + +### 2. Connection Strategy: Single Shared Connection + +- One `aiosqlite` connection created at startup, shared by all repositories +- WAL mode enabled for concurrent reads +- Foreign keys enabled (`PRAGMA foreign_keys = ON`) +- Connection closed on shutdown + +### 3. Dependency Injection: Lifespan + app.state + +- App lifespan (`@asynccontextmanager`) creates DB connection and repositories +- Stored on `app.state` (e.g. `app.state.user_repo`) +- FastAPI `Depends()` retrieves from `request.app.state` + +### 4. Groups Storage: Separate Join Table + +- `user_groups(userid TEXT, group_name TEXT)` with composite PK +- `ON DELETE CASCADE` from users table +- Repository handles join/split when reading/writing User models + +### 5. Bytes Columns: BLOB + +- `credential_id` and `public_key` stored as BLOB +- aiosqlite handles bytes natively + +### 6. Datetimes: ISO 8601 TEXT + +- All datetime columns stored as ISO 8601 text strings +- Converted to/from `datetime` objects in the repository layer + +### 7. Booleans: INTEGER 0/1 + +- SQLite has no native bool; use INTEGER with 0/1 + +--- + +## Schema (approved — Section 1) + +```sql +-- _migrations tracker +CREATE TABLE IF NOT EXISTS _migrations ( + id INTEGER PRIMARY KEY, + filename TEXT NOT NULL UNIQUE, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- 001_initial.sql +CREATE TABLE users ( + userid TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + preferred_username TEXT, + given_name TEXT, + family_name TEXT, + nickname TEXT, + email TEXT, + email_verified INTEGER NOT NULL DEFAULT 0, + phone_number TEXT, + phone_number_verified INTEGER NOT NULL DEFAULT 0, + picture TEXT, + locale TEXT, + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE user_groups ( + userid TEXT NOT NULL REFERENCES users(userid) ON DELETE CASCADE, + group_name TEXT NOT NULL, + PRIMARY KEY (userid, group_name) +); + +CREATE TABLE webauthn_credentials ( + user_id TEXT NOT NULL REFERENCES users(userid) ON DELETE CASCADE, + credential_id BLOB NOT NULL, + public_key BLOB NOT NULL, + sign_count INTEGER NOT NULL DEFAULT 0, + device_name TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + PRIMARY KEY (user_id, credential_id) +); + +CREATE TABLE password_credentials ( + user_id TEXT PRIMARY KEY REFERENCES users(userid) ON DELETE CASCADE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE magic_links ( + token TEXT PRIMARY KEY, + username TEXT NOT NULL, + expires_at TEXT NOT NULL, + used INTEGER NOT NULL DEFAULT 0, + created_by TEXT, + note TEXT +); +``` + +**Status:** Schema approved. Implementation complete. + +--- + +## Implementation Status + +All SQLite repository tasks are **complete**. See `docs/plans/2026-02-13-sqlite-repositories-plan.md` for the full implementation plan. + +### Completed (commits `bfa5b2e` through `a45604f`) + +1. SQL migration file (`001_initial.sql`) +2. Migration runner (`run_migrations()` with `_migrations` tracker table) +3. `DuplicateError` domain exception +4. `SQLiteUserRepository` with tests +5. `SQLiteCredentialRepository` with tests +6. `SQLiteMagicLinkRepository` with tests +7. Lifespan integration and dependency injection (`dependencies.py`) + +### Quality Gate + +All 64 tests passing. `./scripts/check.sh` all green (ruff format, ruff check, ty check, pytest). + +### Roadmap + +Per the design doc, the roadmap is: +1. ~~Scaffolding~~ (done) +2. ~~SQLite repositories~~ (done) +3. **Authentication** (WebAuthn + password) — next phase +4. OIDC provider integration (idpyoidc) +5. Management UI (OIDC RP that dogfoods the OP) + +--- + +## Process Notes + +- User prefers to be asked design questions before implementation begins +- Use **brainstorming** skill: one question at a time, present design in 200-300 word sections, validate each +- Use **subagent-driven development** for implementation +- Plans live in `docs/plans/` +- Quality gate: `./scripts/check.sh` (ruff format, ruff check, ty check, pytest)