feat(multiuser): Phase 1 schema, core auth infrastructure, and ownership plumbing - #154
Merged
Conversation
Add multiuser data model and core infrastructure for SNORE Phase 1: Schema (breaking — migration regenerated in place): - New tables: users, auth_identities, invites, oauth_attempts with proper FK relationships and uniqueness constraints - profiles.user_id FK, name, deleting_at (tombstone); UNIQUE(user_id, name) - devices.profile_id NOT NULL FK; UNIQUE(profile_id, serial_number) - Day UNIQUE(id, device_id) to support composite FK target - Regenerated Alembic baseline migration (drops old two-step history) New modules: - auth/actor.py: immutable ActorContext (StrEnum Role/AuthMode, can_write derived) - auth/factory.py: single ActorContextFactory with fallback resolution - services/profile_service.py: ProfileService + DeletionSaga (tombstone saga) - services/writer_lease.py: cross-process flock WriterLeaseManager at BackupService.backup_via_parser() boundary; API lifetime shared hold - services/profile_service.py: DeletionSaga (tombstone → quarantine → cascade) - database/txn.py: run_txn[T] callback-style retry (invite + import chunks only) - api/routers/profiles.py: GET/POST/PATCH profile API (no DELETE — CLI-only) - cli/groups/profile.py: snore profile create/list/rename/set-default/delete - cli/groups/user.py: snore user create/list/disable/invite/invite-revoke Infrastructure changes: - import_jobs.py: PENDING_UPLOAD admission reservation state; JobStore interface with per-user/global caps; publish-terminal → cleanup → release-capacity order - backup_service.py: writer lease acquired inside backup_via_parser() - import_data.py: profile-namespaced backup root (raw/<profile_id>/) - importers.py: profile_id threaded through SessionImporter._import_single_session - app.py: profiles router registered; writer lease lifetime hold at lifespan - cli/__init__.py: profile and user command groups registered - cli/groups/db.py: snore db purge-quarantine command Test fixes (all 1153 passing): - conftest.py: async_test_user + async_test_profile fixtures - All test call sites that create Device rows now create User+Profile first - Type annotations added where mypy required them just check green (mypy + ruff lint + ruff format). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…eletion saga
Step 1 + Step 2 of Phase 1 multiuser work.
Step 1 — composite FK + baseline regen:
- Add ForeignKeyConstraint Session(day_id, device_id) → Day(id, device_id) with
use_alter=True so SQLite deferred enforcement works correctly.
- Add Day.UNIQUE(id, device_id) (uq_day_id_device) to make the composite FK
enforceable; both ownership joins are provably identical.
- Add overlaps= annotations to Day.sessions, Session.device, Device.sessions
relationships to silence the SQLAlchemy column-copy warning introduced by
the composite FK sharing sessions.device_id across two relationships.
- Regenerate baseline migration in-place (dab8ad625898) to include all of the
above; no second migration file added.
Step 2 — adversarial infra tests (1184 total, +31 from prior checkpoint):
- tests/unit/test_import_jobs_admission.py (25 tests):
Per-user cap enforcement, global cap enforcement, slot-reuse-at-cap for all
5 release paths (try_cancel on PENDING_UPLOAD, try_cancel on PENDING,
mark_failed, cleanup_files, release_capacity), atomic reservation→job
conversion invariant, cleanup ordering, concurrent admission under global cap,
run_txn idempotency / no-duplicate-on-retry.
- tests/integration/test_writer_lease_and_deletion.py (16 tests):
Writer lease unit: shared/exclusive context managers, nested refcount,
release idempotency, shared-holds-blocks-exclusive in-process,
pre-tombstone failure releases exclusive lock.
Cross-process: subprocess backup blocked during exclusive hold; shared
acquire succeeds after exclusive released.
Deletion saga fault injection: pre-tombstone failure leaves no orphans,
last-profile rejection raises ProfileLastError, tombstone-only leaves
recovery path, rename-only leaves recovery path, cascade-only leaves
recovery path (quarantine purge), full recovery with two tombstoned
profiles leaves no orphaned raw files.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Step 3+4+5 of Phase 1 multiuser work. Services threading (Step 3): - Add profile_id to SessionService, DayService, DatabaseService, EventService, DeviceService, WaveformService, StatsService, ReportService, ExportService, AnalysisFacade constructors. - Each service's _profile_filter() / _profile_filters() method now scopes queries to the active profile via Device.profile_id. - profile_id=None is accepted by WaveformService and AnalysisFacade for internal callers (WaveformInspector, BatchValidator) that have already validated ownership externally; ownership checks are skipped. - WaveformInspector gains profile_id parameter and threads it to its WaveformService instance. - Import service threads profile_id through ImportJob → device find-or-create path. CLI plumbing (Step 4): - resolve_local_profile_id() added at the top of every CLI command that creates a service directly: stats, analysis delete/batch/single/ list, waveform list/show/compare, report summary/comparison, export csv/json. - _resolve_session_id() in waveform CLI now returns (session_id, profile_id) tuple so callers do not re-query the DB. Test fixes (Step 5): - test_waveform_parallel: add ActorContextFactory.make_local AsyncMock to all three multi-type test classes so MagicMock DB sessions do not hit real async awaits. - test_analysis_batch: patch resolve_local_profile_id to return 1 so the mock session does not need to satisfy the full make_local query chain. - test_api/test_waveforms: update test_list_waveforms_not_found_session expectation to 404 — profile-scoped _assert_session_owned now returns 404 for sessions outside the profile, which is correct. Type safety: - _profile_filter() / _profile_filters() return ColumnElement[bool] / list[ColumnElement[bool]] instead of object / list[object]. - ColumnElement added to sqlalchemy imports in all affected services. - service_dep generic uses Callable[[AsyncSession, int | None], T]. - AnalysisFacade and WaveformService __init__ profile_id typed as int | None = None (required int callers still pass it explicitly). All 1184 tests pass; just check green (mypy + ruff). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Add SNORE_USER / SNORE_PROFILE env vars and --user / --profile flags to every profile-scoped data command (stats, session, analysis, report, export, waveform, db stats). Resolution is via the same ActorContextFactory.make_from_cli() — fails with a clear error on missing user or profile, never silently picks the first row. Changes: - factory.py: add make_from_cli(), _resolve_profile_by_ref(), and resolve_cli_profile_id() wrapper (ValueError → ClickException) - decorators.py: add actor_options() decorator (--user/SNORE_USER + --profile/SNORE_PROFILE; param names actor_user/actor_profile) - 8 CLI files: apply @actor_options, replace resolve_local_profile_id calls with resolve_cli_profile_id(db, actor_user, actor_profile) - test_analysis_batch.py: update mocks from resolve_local_profile_id to resolve_cli_profile_id (no behavior change) Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Thufir Pass 4 contract defects addressed: - make_from_cli: count non-disabled users before falling through to make_local; raise ValueError with --user guidance when count > 1 - resolve_local_profile_id: route through make_from_cli so the guard propagates to all remaining single-arg CLI callers - import_data: add actor_options (--user/--profile/SNORE_USER/ SNORE_PROFILE); thread user_ref/profile_ref through ImportService and _import_sources_async; use resolve_cli_profile_id when refs are supplied, resolve_local_profile_id otherwise - analysis._list_sessions: remove dead profile_id=None fallback (all callers now supply a resolved id from resolve_cli_profile_id) - test_waveform_parallel: patch make_from_cli not make_local so the unit tests bypass the DB count query entirely - test_factory_ambiguity: 8 cases covering the guard, disabled-user exclusion, count in error message, and resolve_local_profile_id propagation - test_profile_isolation: 9 cases for DeviceService and SessionService list and point-lookup isolation across profile boundaries Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wpfleger96
marked this pull request as ready for review
August 2, 2026 23:25
wpfleger96
marked this pull request as draft
August 3, 2026 00:19
…ngs 1–7 Profile isolation was optional (None default + fallback to global queries), allowing any entry point that forgot to pass context to silently leak data across profiles. This commit removes all optional paths and makes profile_id a required constructor argument at every data boundary. Finding 1 — profiles.py Request annotation: Removed the defunct 'request: object' annotation that caused FastAPI to expose 'request' as a required query parameter (422 on every call). Refactored all three profile route handlers to use ActorDep exclusively. Findings 2–3 — Import targeting + device scoping: _run_import now consumes job.target_profile_id so DB writes land in the correct profile even if the default profile changes after job creation. _import_single_session device lookup is scoped by (profile_id, serial_number) so same-serial devices owned by different profiles cannot collide. Finding 3 — Job control authorization: cancel_import and import_progress now use ActorDep and authorize against owner_user_id (foreign or missing job → 404, no information leak). cancel_import also enforces can_write. Finding 4 — Export isolation: ExportService.__init__ now requires profile_id: int (no None default, no global-query fallback). Backup root is always DEFAULT_RAW_BACKUP_DIR / profile_id — never client-supplied. API router and all three CLI export commands resolved via actor/resolve_cli_profile_id. Finding 5 — Validation isolation: BatchValidator.__init__ requires profile_id: int. API router and CLI validate command both resolve profile_id before constructing the validator. Finding 6 — Rx isolation: RxTracker.__init__ requires profile_id: int. All device/day queries use Device.profile_id == self.profile_id. API router and all four CLI rx commands resolve profile_id via actor/resolve_cli_profile_id. Finding 7 — analysis show isolation: All analysis CLI commands (run, list, show, delete) now carry actor_options and resolve profile_id before any DB query. The show command's date and numeric-ID lookups both include Device.profile_id == profile_id in WHERE. Finding 8 — run_txn wired at import chunks: import_service._import_sources_async now uses run_txn for each import chunk. UNIQUE(device_id, device_session_id) makes chunk replay safe under contention. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
run_txn idempotency (finding 8): - test_no_duplicate_session_on_import_chunk_retry: unit_of_work raises on attempt 1 (simulated SQLITE_BUSY), succeeds on attempt 2; exactly one Session row persisted — UNIQUE(device_id, device_session_id) makes it safe - test_non_contention_exception_propagates_immediately: non-contention error never retried; call_count == 1 on first ValueError - test_exhausted_contention_raises_last_error: max_attempts=3 exhausted; call_count == 3 before RuntimeError raised - asyncio.sleep patched out to keep tests fast Isolation matrix extension (finding 8 + Paul synthesis point 4): - TestExportServiceIsolation: CSV export with profile A returns 0 nights when sessions belong to profile B; own profile returns 1; two profiles see exactly their own count - TestBatchValidatorIsolation: validate_date_range finds 0 sessions for foreign profile; own-profile query is scoped and never crosses boundaries - TestRxTrackerIsolation: get_history returns [] for foreign profile; own devices + settings produce at least 1 period; two-profile partition check - TestAnalysisSessionIsolation: direct query (mirrors analysis show --session-id) returns None for foreign session_id scoped by profile_id; own-profile lookup succeeds Job control isolation (finding 3): - test_cancel_foreign_job_returns_404: job owned by user 9999, actor is local auto-provisioned (user_id != 9999) → DELETE returns 404 - test_progress_foreign_job_returns_404: same setup for GET progress - test_cancel_own_job_not_404: unowned job (owner_user_id=None, local mode) → DELETE returns 204 or 404 profiles.py regression guard (finding 1): - test_profiles.py: 12 tests covering list/create/patch without 'request=' query param (422 regression guard), correct status codes, 404 for foreign profile IDs, 409 for duplicate names, and 422 for missing fields Updated test fixtures for required profile_id constructors: - test_export_service.py: ExportService(1, ...) and ExportService(1) - test_rx_tracker.py: RxTracker(1) everywhere - test_import_state_machine.py: get_actor stub returns profile_id=1 (required by cancel/progress ActorDep) Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Remove malformed profiles-router parameters (request: unknown was exposed as a required query param due to the 'request: object' annotation bug fixed in the previous commit). Regenerated via: uv run python scripts/export_openapi.py ui/openapi.json cd ui && pnpm run generate:types Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
BackupService was initialized with backup_root=None from the CLI import path, causing it to fall back to DEFAULT_RAW_BACKUP_DIR without the profile namespace. ExportService always reads from DEFAULT_RAW_BACKUP_DIR / str(profile_id), so the two paths disagreed and export raw could not find files written by CLI import. After profile_id is resolved in import_service._import_source_async, default backup_root to DEFAULT_RAW_BACKUP_DIR / str(profile_id) when the caller did not provide an explicit path. This mirrors what the API import path already does (import_data.py:259-261) and ensures CLI import and export raw share the same directory layout. The e2e test is updated to match the new contract: no --backup-dir on either side; both commands use the same --db so they resolve the same profile_id and agree on the backup root. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wpfleger96
marked this pull request as ready for review
August 3, 2026 01:08
wpfleger96
marked this pull request as draft
August 3, 2026 01:32
Finding 1 (CRITICAL): bulk delete ignores profile ownership - SessionService.delete_sessions() now carries ownership predicate inside the DELETE: subquery on Device.profile_id, no separate SELECT - AnalysisFacade.delete_analysis() scopes both branches (all_versions and single) through an owned_sessions_subq predicate - Added two-profile isolation matrix tests in test_profile_isolation.py proving foreign session/analysis rows survive the delete call Finding 2 (IMPORTANT): import routes read request.state.actor instead of ActorDep - import_files() and import_from_path() now declare actor: ActorDep; both enforce actor.can_write and snapshot non-null owner_user_id / target_profile_id - CLI import_data: derive raw backup root from resolved profile_id; removed --backup-dir client override Finding 3 (IMPORTANT): AnalysisFacade accepts profile_id=None, fresh scopes unscoped - AnalysisFacade.__init__ now requires profile_id: int (no None default) - run_analysis() validates session ownership before I/O phase - get_analysis_result() validates ownership; returns None for foreign IDs - BatchValidator._validate_session() constructs AnalysisFacade(db, profile_id) for both lookup and run paths Finding 4 (IMPORTANT): WaveformService/WaveformInspector accept profile_id=None - Both constructors now require profile_id: int - WaveformService._assert_session_owned() no longer has a None bypass - WaveformInspector arg order: profile_id positional before service - waveform show CLI: unpacks resolved profile_id from _resolve_session_id and threads it into WaveformInspector and parallel fresh scopes - SessionService.resolve_session_id() validates ownership for explicit IDs - Waveform parallel unit tests: also mock _resolve_session_id (hits DB now) Finding 5 (IMPORTANT): recover() misses orphaned quarantine dirs post-cascade - _recover_async() Case 2: enumerate quarantine dirs with no surviving tombstone row and purge them directly - Updated test to call public saga.recover() instead of private helper Finding 6 (IMPORTANT): lifespan lease can double-acquire shared hold on failure - Restructured app.py lifespan: exclusive acquire in isolated try/except (exclusive_held flag); shared acquire called exactly once unconditionally outside both blocks - Added lifespan unit test asserting _refcount == 1 after injected recovery failure and _refcount == 0 after release Finding 7 (IMPORTANT): run_txn contention test doesn't exercise rollback+replay - _insert_session UoW: attempt 1 does db.add + await db.flush then raises contention, so run_txn session_scope rolls back the transaction - Attempt 2 inserts cleanly; exactly-one-row assertion validates replay - Added test_invite_redemption_contention for invite idempotency Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wpfleger96
marked this pull request as ready for review
August 3, 2026 02:58
wpfleger96
marked this pull request as draft
August 3, 2026 03:01
…ope; scope CLI lookups Regression (c2f0293): run_analysis() executed its ownership check against self.db_session, which the CLI closes before calling the facade. This caused InvalidRequestError on every 'snore analysis run' invocation. Fix: move the ownership check inside the existing session_scope() read block so it always runs against a live connection. Also scoped the CLI pre-lookup queries at analysis.py:479-512: both the date branch and the ID branch now join through Device and apply Device.profile_id == profile_id, preventing a foreign session from being resolved before the facade can reject it. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wpfleger96
marked this pull request as ready for review
August 3, 2026 03:06
wpfleger96
marked this pull request as draft
August 3, 2026 03:18
wpfleger96
marked this pull request as ready for review
August 3, 2026 03:32
…oute-level 404 for foreign IDs - SessionImporter(profile_id) — required constructor arg; no per-method optional profile; contention re-raised past batch catch to reach run_txn - ImportService.import_sources() — profile_id required (no default); internal actor resolution removed; backup root derived from profile_id - AnalysisService — raises ValueError when db_session given without profile_id; compute-only mode (db_session=None) still allowed - WaveformService.get_waveform_data() — does NOT close caller-owned session; regression guard test added - Route DELETE /sessions/ and DELETE /analysis — get_owned_ids / get_owned_session_ids validate ownership before mutation; any foreign ID in the set returns 404 without revealing which failed - Route-level two-profile isolation tests: TestDeleteSessionsCrossProfile Isolation and TestDeleteAnalysisCrossProfileIsolation prove foreign rows survive and the response is 404, not 200 - Production chunk contention test exercises real _import_batch_with_session via run_txn; proves rollback+replay yields exactly one persisted row - Lifespan lease test replaced: exercises app.router.lifespan_context() with DeletionSaga.recover() faulted; asserts refcount==1 during serving, 0 after exit Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wpfleger96
marked this pull request as draft
August 3, 2026 03:58
wpfleger96
marked this pull request as ready for review
August 3, 2026 04:01
wpfleger96
marked this pull request as draft
August 3, 2026 04:15
…rtService session closes Fix 1: AnalysisService.store_result() now validates the target session belongs to self.profile_id before inserting. Selects Session JOIN Device WHERE Device.profile_id == self.profile_id in the same transaction; raises NotFoundError when absent. Prevents cross-profile analysis writes from direct service callers. Fix 2: Remove both await self._db.close() calls from ReportService generate_summary_report() and generate_comparison_report(). Injected sessions are caller-owned; only get_db()/db_session()/session_scope() may close them. The closes caused silent rollback of local user/profile provisioning on fresh DBs and InvalidRequestError on subsequent queries. Fix 3: Tree-wide .close() sweep — all remaining hits are legitimate session-owner closes (deps.py get_db, session.py session_scope/cursor, cli/groups/analysis.py deliberate I/O-compute split, import_jobs.py SSE channel close, parsers/formats/edf.py file handle closes). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Add the data model and core infrastructure for SNORE multiuser support (Phase 1 of the gate-cleared multiuser plan). This is a breaking schema change — the old two-migration history is replaced with a single baseline migration; existing DBs should be rebuilt from scratch.
New tables (
users,auth_identities,invites,oauth_attempts) and ownership chain (profiles.user_id,devices.profile_id NOT NULL) establish theUser 1—N Profile 1—N Devicehierarchy. All session/day/waveform/event/statistics data hangs offdevices, so a single join predicates every query to a profile. TheDaymodel gainsUNIQUE(id, device_id)to support a future composite FK fromSession.auth/actor.py: immutableActorContext(StrEnum Role/AuthMode;can_writederived from role, never supplied at construction)auth/factory.py: singleActorContextFactory— the only place profile ownership is validated; fallback resolution: requested → default → first liveservices/profile_service.py:ProfileService(CRUD) +DeletionSaga(tombstone → same-filesystem quarantine rename → cascade → purge, startup recovery, CLI-only under exclusive writer lease)services/writer_lease.py: cross-process advisoryflockWriterLeaseManageracquired insideBackupService.backup_via_parser()— the sole raw-mutation boundary; API lifetime shared hold via startup lifespan;snore profile delete/snore db purge-quarantinerequire exclusive non-blockingdatabase/txn.py:run_txn[T]callback-style retry for idempotent units only (invite redemption + uniqueness-protected import chunks; analysis storage explicitly excluded, returns 503 on contention)api/import_jobs.py:PENDING_UPLOADadmission reservation taken before any body bytes; one counter/state machine for ingress + pending + running; publish-terminal → cleanup → release-capacity orderingapi/routers/profiles.py:GET/POST/PATCH /api/v1/profiles(no DELETE — CLI-only offline operation)cli/groups/profile.py+cli/groups/user.py:snore profileandsnore usercommand groups;_resolve_userfails instead of first-row guessing when multiple users exist~/.snore/raw/<profile_id>/in upload flow;SessionImporterthreadsprofile_idfor device find-or-createjust check(mypy + ruff lint + ruff format) green