Skip to content

feat(db): persist users + resource selection — Part of #586 #980

Open
skypank-coder wants to merge 9 commits into
OWASP:mainfrom
skypank-coder:feat/586-users
Open

feat(db): persist users + resource selection — Part of #586 #980
skypank-coder wants to merge 9 commits into
OWASP:mainfrom
skypank-coder:feat/586-users

Conversation

@skypank-coder

Copy link
Copy Markdown
Contributor

What & why

First increment of #586 ("Add support for users"): login-first user
persistence. OIDC login existed but nothing was persisted — no User table,
nowhere to hang per-user data. This adds that foundation so per-user resource
filtering (PR2–PR4) can be built on it. Aligns with auth RFC #876 (TODO 1/2).

Changes

  • users table anchored on the immutable OIDC sub (google_sub); email
    and display_name refreshed on each login.
  • user_resource_selection table: one row per selected standard,
    UNIQUE(user_id, standard_name), FK → users.id ON DELETE CASCADE.
  • Login callback upserts the User and sets session['user_id'] — gated on
    CRE_ENABLE_LOGIN, wrapped so it never blocks login. Existing session keys
    and the anonymous 401 are unchanged (intentionally narrower than RFC RFC: User Authentication and Online MyOpenCRE Mapping #876's
    login_required rewrite).
  • Node_collection methods: upsert_user, get_user_by_sub,
    get_user_resource_selection, set_user_resource_selection. These back the
    /rest/v1/user resource endpoints coming in PR2.
  • Migrations: a dedicated merge revision collapses the two pre-existing
    open heads (ab12cd34ef56, 9b1c2d3e4f50), then a revision creates the two
    tables — keeping the head single-parent so flask db downgrade is
    unambiguous.

Testing

  • 12 new tests: idempotent upsert (no dup row), selection round-trip / replace /
    dedup / per-user isolation, unique constraint, cascade delete, callback upsert,
    and the flag-off no-op path.
  • Verified on real Postgres (prod parity), not just SQLite: single head,
    ON DELETE CASCADE enforced at the DB level (rows 1 → 0), both unique
    constraints created cleanly, migration upgrade → downgrade → upgrade
    deterministic, alembic revision guardrail green. 12/12 pass on Postgres.
  • black clean; no new mypy errors vs. baseline.

Scope boundaries

No new endpoints, no server-side filtering, no frontend, no login_required
rewrite — those are the follow-up PRs in #586.

Unrelated pre-existing issue (out of scope, flagging for visibility)

flask db upgrade from a blank DB fails on Postgres before reaching my
revisions: migrations 3c65127871a6 / 7bf4eac76958 reuse the constraint name
uq_pair (authored via SQLite batch_alter_table), which collides on a
from-base replay. Postgres rolls the whole thing back cleanly (transactional
DDL). This predates this branch and doesn't affect the prod path (apply-onto-
existing-schema), so it's out of scope here — but worth a separate fix, since
anyone bootstrapping a fresh Postgres DB will hit it.

skypank-coder and others added 6 commits July 9, 2026 20:18
…WASP#586)

Persist accounts on OIDC login and store a per-user resource (standard) selection. Additive to the existing session auth: the callback upserts the User row and sets session['user_id'] only when CRE_ENABLE_LOGIN is on; the existing session keys and 401 behaviour are unchanged.

- User(google_sub UNIQUE, email, display_name, created_at, last_seen_at)
- UserResourceSelection(user_id FK CASCADE, standard_name, UNIQUE(user_id, name))
- Node_collection: upsert_user / get_user_by_sub / get_user_resource_selection / set_user_resource_selection
- Alembic: dedicated merge of the two open heads + create tables (up/down verified on Postgres; guardrail green)
…int (OWASP#586)

Address PR review:
- upsert_user now mirrors add_node: catch IntegrityError, rollback, re-query
  by google_sub, update profile — safe under concurrent logins.
- Drop column-level unique=True on User.google_sub; keep only the named
  constraint so create_all matches the Alembic migration (no schema drift).
- Login callback skips persistence when the OIDC 'sub' claim is missing.
- Callback test asserts session['user_id'] equals the persisted User.id.
…OWASP#586)

Address PR review:
- Log only the exception class on login-persistence failure; the raw message
  can carry SQL parameters (email, OIDC sub).
- Add callback test: login enabled but token missing 'sub' creates no user
  row and leaves session['user_id'] unset.
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 9ed98275-b9e3-47e2-9279-d5a2f8dd4877

📥 Commits

Reviewing files that changed from the base of the PR and between fd5bd59 and fe9554e.

📒 Files selected for processing (1)
  • application/database/db.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • application/database/db.py

Summary by CodeRabbit

  • New Features
    • Added persistent user identity for authenticated OIDC sign-ins and local development sign-ins.
    • Sign-in sessions now retain the associated persisted user.
    • Added per-user resource-selection preferences with replacement behavior and duplicate handling.
    • Introduced database storage for users and their selections.
  • Tests
    • Added/expanded tests for user upsert and session behavior across login-flag scenarios.
    • Added tests for preference round-trips, per-user isolation, uniqueness enforcement, and cascade cleanup on user deletion.

Walkthrough

Adds SQLAlchemy models, migration support, persistence APIs, and tests for OIDC users and per-user resource selections. Login callback and development login flows optionally persist users and store their IDs in sessions.

Changes

User persistence

Layer / File(s) Summary
User schema and migration
application/database/db.py, migrations/versions/a1b2c3d4e5f6_add_users_and_resource_selection.py
Adds user and resource-selection models, uniqueness constraints, cascading relationships, and matching Alembic tables.
User persistence APIs
application/database/db.py, application/tests/user_model_test.py
Adds user upsert/lookup and resource-selection replacement APIs, with tests for idempotency, deduplication, isolation, constraints, and cascading deletion.
OIDC callback integration
application/web/web_main.py, application/tests/web_main_test.py
Persists authenticated and development users when login is enabled, stores session["user_id"], handles missing subjects and database errors, and tests login-flag behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • OWASP/OpenCRE#981: Uses the user and resource-selection persistence APIs added by this change.

Suggested reviewers: robvanderveer, paoga87, pa04rth

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: persisting users and resource selection for #586.
Description check ✅ Passed The description clearly matches the changeset and explains the motivation, scope, tests, and migration details.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
application/database/db.py (2)

1038-1089: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Hoist the repeated datetime/timezone import to module scope.

from datetime import datetime, timezone is imported locally in both upsert_user (Line 1046) and set_user_resource_selection (Line 1105). Move it to the top-level imports once.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@application/database/db.py` around lines 1038 - 1089, Move the repeated “from
datetime import datetime, timezone” import from the methods upsert_user and
set_user_resource_selection to the module-level imports, leaving both methods to
use the shared top-level import without local import statements.

1101-1125: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

set_user_resource_selection has no guard against concurrent duplicate inserts.

Unlike upsert_user above (which recovers from IntegrityError on google_sub races), this delete-then-insert sequence has no equivalent safeguard. Two concurrent calls for the same user_id (e.g. a double-submitted "save selection" request) could both pass the delete step and then collide on uq_user_resource_selection for a shared standard_name, raising an unhandled IntegrityError. No endpoint calls this yet, but it's worth hardening before wiring it up.

♻️ Suggested guard mirroring upsert_user's recovery pattern
         self.session.query(UserResourceSelection).filter(
             UserResourceSelection.user_id == user_id
         ).delete()
         for name in deduped:
             self.session.add(
                 UserResourceSelection(
                     id=generate_uuid(),
                     user_id=user_id,
                     standard_name=name,
                     created_at=now,
                 )
             )
-        self.session.commit()
+        try:
+            self.session.commit()
+        except IntegrityError:
+            # A concurrent call raced on the same (user_id, standard_name) pair;
+            # the other writer's result wins, just return the current state.
+            self.session.rollback()
         return self.get_user_resource_selection(user_id)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@application/database/db.py` around lines 1101 - 1125, Harden
set_user_resource_selection against concurrent IntegrityError races during its
delete-and-insert sequence. Mirror the existing recovery pattern used by
upsert_user: catch the uniqueness violation, roll back the failed transaction,
and return the persisted selection for user_id without leaving the exception
unhandled; preserve the current deduplication and replacement behavior for
non-conflicting calls.
application/web/web_main.py (1)

1164-1185: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Broad exception catch is intentional but worth narrowing eventually.

Ruff flags the blind except Exception here (BLE001). The rationale in the comment (avoid leaking SQL parameters) is sound for upsert_user failures specifically, but the broad catch also silently swallows unrelated bugs (e.g., a malformed id_info dict) that would otherwise be visible. Consider catching SQLAlchemyError specifically if you want DB failures to degrade gracefully while other exceptions still surface.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@application/web/web_main.py` around lines 1164 - 1185, In the user
persistence block around upsert_user, replace the broad Exception handler with
SQLAlchemyError and import that exception from the project’s SQLAlchemy
dependency. Preserve the existing sanitized class-name logging and graceful
redirect for database failures, while allowing unrelated errors such as
malformed id_info data to propagate.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@application/database/db.py`:
- Around line 1038-1089: Move the repeated “from datetime import datetime,
timezone” import from the methods upsert_user and set_user_resource_selection to
the module-level imports, leaving both methods to use the shared top-level
import without local import statements.
- Around line 1101-1125: Harden set_user_resource_selection against concurrent
IntegrityError races during its delete-and-insert sequence. Mirror the existing
recovery pattern used by upsert_user: catch the uniqueness violation, roll back
the failed transaction, and return the persisted selection for user_id without
leaving the exception unhandled; preserve the current deduplication and
replacement behavior for non-conflicting calls.

In `@application/web/web_main.py`:
- Around line 1164-1185: In the user persistence block around upsert_user,
replace the broad Exception handler with SQLAlchemyError and import that
exception from the project’s SQLAlchemy dependency. Preserve the existing
sanitized class-name logging and graceful redirect for database failures, while
allowing unrelated errors such as malformed id_info data to propagate.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 4fb83f8c-bd64-4f81-84cb-24a695e2c0d6

📥 Commits

Reviewing files that changed from the base of the PR and between 6b6a5ac and 1bb2424.

📒 Files selected for processing (6)
  • application/database/db.py
  • application/tests/user_model_test.py
  • application/tests/web_main_test.py
  • application/web/web_main.py
  • migrations/versions/a1b2c3d4e5f6_add_users_and_resource_selection.py
  • migrations/versions/f0e1d2c3b4a5_merge_heads_before_users.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
application/database/db.py (1)

1111-1114: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Simplify list deduplication.

You can simplify the deduplication logic and avoid an O(N^2) loop by using dict.fromkeys(), which natively preserves insertion order and operates in O(N) time.

♻️ Proposed refactor
-        deduped: List[str] = []
-        for name in standard_names:
-            if name not in deduped:
-                deduped.append(name)
+        deduped = list(dict.fromkeys(standard_names))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@application/database/db.py` around lines 1111 - 1114, Replace the manual
deduplication loop over standard_names with dict.fromkeys() to preserve
insertion order while reducing the operation to linear time, and keep the
resulting value as a List[str] assigned to deduped.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@application/database/db.py`:
- Around line 1111-1114: Replace the manual deduplication loop over
standard_names with dict.fromkeys() to preserve insertion order while reducing
the operation to linear time, and keep the resulting value as a List[str]
assigned to deduped.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: e65bb199-a9a4-482c-a895-0baf19a0a577

📥 Commits

Reviewing files that changed from the base of the PR and between 1bb2424 and 137f17e.

📒 Files selected for processing (3)
  • application/database/db.py
  • application/tests/web_main_test.py
  • application/web/web_main.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • application/web/web_main.py
  • application/tests/web_main_test.py

@northdpole northdpole left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — this is a clean foundation for #586: idempotent upsert with race recovery, cascade + uniqueness covered, callback gated so persistence can't break login, and solid Postgres-minded tests.

Blocking: migration lineage conflicts with current main (see inline). After that fix I'm happy to approve.

Also (not in the diff, so calling out here): NO_LOGIN=1 in /rest/v1/login still only sets google_id / name, not session["user_id"], and never upserts a User. Local/dev will look logged in while PR2's /rest/v1/user/resources has nothing to hang selection on. Please either fix here or explicitly handle in #981.

Once this lands, please rebase #981 onto updated main (or stack it on this branch) so we don't re-review the DB layer.

Comment thread migrations/versions/f0e1d2c3b4a5_merge_heads_before_users.py Outdated
Comment thread migrations/versions/a1b2c3d4e5f6_add_users_and_resource_selection.py Outdated
Comment thread application/web/web_main.py Outdated
skypank-coder added a commit to skypank-coder/OpenCRE that referenced this pull request Jul 20, 2026
…NO_LOGIN (OWASP#586)

Address review on OWASP#980:
- Drop obsolete merge revision f0e1d2c3b4a5; main already merges the two
  embedding heads via c7d8e9f0a1b2 (OWASP#979). Second merge left two Alembic heads.
- Point users migration at c7d8e9f0a1b2 and update the Revises docstring.
  Verified: flask db heads reports a single head; upgrade/downgrade/upgrade
  clean on Postgres.
- NO_LOGIN dev login now upserts the User and sets session['user_id'] when
  CRE_ENABLE_LOGIN is on, so local dev sessions have a row for user-scoped
  endpoints. Covered by tests for flag-on and flag-off.
- Narrow login persistence except from Exception to SQLAlchemyError.
…NO_LOGIN (OWASP#586)

Address review on OWASP#980:
- Drop obsolete merge revision f0e1d2c3b4a5; main already merges the two
  embedding heads via c7d8e9f0a1b2 (OWASP#979). Second merge left two Alembic heads.
- Point users migration at c7d8e9f0a1b2 and update the Revises docstring.
  Verified: flask db heads reports a single head; upgrade/downgrade/upgrade
  clean on Postgres.
- NO_LOGIN dev login now upserts the User and sets session['user_id'] when
  CRE_ENABLE_LOGIN is on, so local dev sessions have a row for user-scoped
  endpoints. Covered by tests for flag-on and flag-off.
- Narrow login persistence except from Exception to SQLAlchemyError.
@skypank-coder

Copy link
Copy Markdown
Contributor Author

@northdpole Thanks for the thorough review — all three points addressed in fd5bd59.

Migration lineage (blocking). You were right, and I reproduced it before fixing: flask db heads reported both a1b2c3d4e5f6 and c7d8e9f0a1b2. Since c7d8e9f0a1b2 already carries down_revision = ("9b1c2d3e4f50", "ab12cd34ef56"), my revision was a duplicate merge of the same two parents.

Deleted f0e1d2c3b4a5_merge_heads_before_users.py entirely
Pointed the users migration at down_revision = "c7d8e9f0a1b2" and updated the Revises: docstring
Confirmed on Postgres after the fix: flask db heads → single head a1b2c3d4e5f6; upgrade → downgrade → upgrade clean; alembic guardrail OK. Also noted your point that the guardrail only checks revision membership and wouldn't catch the dual-head case — I used the head count as the real gate.

NO_LOGIN dev path. Good catch — fixed here rather than deferring to #981, since it belongs with the persistence work. /rest/v1/login now upserts the dev User and sets session["user_id"] when CRE_ENABLE_LOGIN is on, so local/dev sessions have a real row for user-scoped endpoints. Two tests cover it: persists with the flag on, no-op with it off.

Nit — bare except. Adopted. Narrowed to SQLAlchemyError in both the callback and the new dev path, so DB failures stay soft while unexpected non-DB bugs surface.

On #981. It's stacked directly on this branch (verified this branch's tip is an ancestor of it). Once this merges I'll rebase #981 onto updated main so the DB layer drops out of its diff and you only review the resource-selection endpoint.

Ready for another look.

@northdpole northdpole left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed after fd5bd59 — all three prior points addressed:

  • obsolete merge revision dropped; users migration chains off c7d8e9f0a1b2 (single head)
  • NO_LOGIN path upserts User + sets session["user_id"] when login is enabled (with flag-off coverage)
  • persistence failures narrowed to SQLAlchemyError

CI green. LGTM. Please merge/rebase main (just #837) before merge if strict up-to-date checks require it, then rebase #981 onto updated main.

@skypank-coder

Copy link
Copy Markdown
Contributor Author

@northdpole Branch is updated with #837 and CI's green — ready to merge whenever you are. I'll rebase #981 onto main right after it lands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants