Password Updates & Input Validation for Local Users#189
Open
KinshukSS2 wants to merge 6 commits into
Open
Conversation
Closes Issue istSOS#28 — eliminates two-step user provisioning. After POST /Users, a newly created user had no RLS policy and could not access any data until an administrator separately called POST /Policies. This commit fixes that by automatically calling the appropriate policy function inside the same transaction as user creation. Changes: - api/app/v1/endpoints/create/user.py * Add module-level _POLICY_FN_MAP (viewer/editor/obs_manager/sensor). * Capture app_role before get_db_role_for_rbac() remaps it, so the correct policy function can be dispatched. * After GRANT, call sensorthings.<role>_policy([username], policyname) with policyname = '{username}_default'. Administrator is skipped — admins bypass RLS by privilege, not by policy. * Policy functions already exist in the DB (istsos_auth.sql); no migration required. - api/app/v1/endpoints/functions.py * Add docstrings to _validate_role_identifier() and set_role(). - api/app/v1/endpoints/create/data_array_observation.py * Import shared set_role() helper (was already using the correct upstream version; this import makes the dependency explicit). - api/tests/test_rls_policy_creation.py (new) * Tests: correct policy function per role, administrator exclusion, naming convention, users_ as text[]. - api/tests/test_rbac_set_role_safety.py (new) * Tests: identifier validation, injection rejection, shared helper usage in data_array_observation.
- Add auth_provider and external_sub_id columns to sensorthings."User"
via idempotent migration (001_identity_linking.sql) with a partial
unique index on (auth_provider, external_sub_id) WHERE auth_provider
IS NOT NULL, so local password users are completely unaffected.
- Introduce PENDING_ROLE sentinel in rbac_roles.py. The 'pending' state
is intentionally absent from VALID_RBAC_ROLES so it can never be
assigned through the public API; existing role validation is unchanged.
- Gate pending accounts in get_current_user() (oauth.py): after the DB
lookup, any user with role='pending' immediately receives HTTP 403
'Account pending admin activation' before any SET ROLE or handler
body is reached.
- Add oidc_user_crud.py with create_pending_oidc_user() and
get_user_by_provider_sub(). The insert function hardcodes role to
PENDING_ROLE and contains zero DDL (no CREATE ROLE / CREATE USER),
giving new OIDC accounts zero PostgreSQL footprint until activation.
- Add POST /Users/{id}/activate endpoint (activate_user.py), restricted
to administrators. Runs UPDATE role, CREATE ROLE NOLOGIN IN ROLE,
GRANT, and RLS policy assignment inside a single transaction so a
failed step leaves the user still 'pending' with no partial state.
- Register activate_user router in api.py inside the AUTHORIZATION guard.
Local password users (POST /Users) are completely unaffected; no changes
were made to create/user.py.
Relates-to: GSoC 2026 Identity Linking architecture
- Add PasswordUpdateRequest Pydantic v2 schema (models/password.py)
enforcing: min 12 chars, at least 1 uppercase, at least 1 digit.
Violations surface as HTTP 422 before any DB is touched.
- Add update_local_password() CRUD function (db/password_crud.py):
1. Fetch user row by ID → 404 if missing.
2. OIDC guard: block auth_provider IS NOT NULL users with HTTP 400
'External identities cannot update passwords locally'.
3. Verify current_password via asyncpg.connect() (PostgreSQL auth layer)
→ 401 on InvalidPasswordError. No Python-side passlib used.
4. Execute ALTER USER <username> WITH ENCRYPTED PASSWORD <new_password>
using pg_quote_ident / pg_quote_literal to prevent injection.
- Add PATCH /Users/{id}/password endpoint (update/password.py):
owner-or-admin guard; returns 204 No Content on success.
- Register update_password router in api.py inside AUTHORIZATION guard.
- Add test_password_update.py (9 tests, all pass, no live DB required):
schema: valid, too-short, no-uppercase, no-digit
crud: 404, 400 OIDC block, 401 wrong password, 204 ALTER USER issued
endpoint: 403 non-owner/non-admin guard
Depends on: feat/identity-linking-jit-provisioning (requires auth_provider column)
Member
|
Sorry if I misunderstood, but in istSOS users do not have their own
PostgreSQL passwords.
They may have an istSOS user password, or authenticate through an external
provider such as OIDC. In both cases, access to PostgreSQL is mediated by
the istSOS backend, which connects using an internal PostgreSQL user and
then applies the appropriate database roles / RLS policies according to the
access rights assigned to the specific istSOS user.
So I am not sure that an API endpoint for changing the PostgreSQL password
of a user is aligned with the current architecture, unless we are
explicitly referring to local istSOS credentials rather than PostgreSQL
users.
*Massimiliano Cannata*
Professore SUPSI in ingegneria Geomatica
Responsabile settore Geomatica
*Istituto scienze della Terra*
Dipartimento ambiente costruzione e design
Scuola universitaria professionale della Svizzera italiana
Campus Mendrisio, Via Flora Ruchat-Roncati 15
CH – 6850 Mendrisio
Tel. +41 (0)58 666 62 14
Fax +41 (0)58 666 62 09
***@***.***
*www.supsi.ch/ist <http://www.supsi.ch/ist>*
Il lun 8 giu 2026, 19:13 syntax.sculptor ***@***.***> ha
scritto:
… Password Updates & Input Validation for Local Users
*Stacked on:* feat/identity-linking-jit-provisioning — requires the
auth_provider column from that migration. Please merge that first, or set
it as the base branch for this PR.
Why
Local users needed a way to change their PostgreSQL password via the API.
Since the codebase now supports both local and external OIDC users (sharing
the same User table), this endpoint has to explicitly guard against OIDC
users attempting to set a local password — a concept that doesn't apply to
them.
Password hashing is intentionally delegated to PostgreSQL (ALTER USER …
WITH ENCRYPTED PASSWORD) rather than Python-side passlib, keeping the
auth source of truth in one place.
What changed
File Change
api/app/models/password.py New PasswordUpdateRequest Pydantic v2 schema —
enforces min 12 chars, 1 uppercase, 1 digit via @field_validator
api/app/db/password_crud.py New update_local_password() — full DB logic:
user lookup, OIDC guard, old-password verification, DDL update
api/app/v1/endpoints/update/password.py New PATCH /Users/{id}/password —
owner-or-admin authorization guard, returns 204
api/app/v1/api.py Router registered inside the AUTHORIZATION guard
api/tests/test_password_update.py 9 new tests — all pass without a live
database Edge cases handled
- *Weak password → 422* — Pydantic rejects before any DB is touched
- *OIDC user → 400* — auth_provider IS NOT NULL check fires
immediately with "External identities cannot update passwords locally"
- *Wrong current password → 401* — verified via asyncpg.connect()
using PostgreSQL's own auth layer, not Python-side hashing
- *Non-owner/non-admin → 403* — users cannot change someone else's
password
- *SQL injection* — username interpolated with pg_quote_ident(), new
password with pg_quote_literal() (asyncpg $N params don't work in DDL)
How to test
# 1. Run unit tests (no live DB needed)cd api && .venv/bin/python -m pytest tests/test_password_update.py -v# → 9 passed
# 2. Happy path — local user changes their own password
curl -X PATCH http://localhost:8018/v1.1/Users/<id>/password \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"current_password": "OldPass1!", "new_password": "NewSecure1Pass!"}'# → 204 No Content
# 3. OIDC user blocked# Set auth_provider = 'google' on a test user, then attempt the same call# → 400 Bad Request: "External identities cannot update passwords locally"
# 4. Weak password rejected
curl -X PATCH http://localhost:8018/v1.1/Users/<id>/password \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"current_password": "OldPass1!", "new_password": "weak"}'# → 422 Unprocessable Entity
------------------------------
You can view, comment on, or merge this pull request online at:
#189
Commit Summary
- 5cd3222
<5cd3222>
fix(rbac): auto-create RLS policy on user creation
- 1097abe
<1097abe>
feat(auth): implement identity linking & JIT provisioning for OIDC users
- ee2b264
<ee2b264>
feat(auth): password update endpoint with OIDC guard & input validation
File Changes
(15 files <https://github.com/istSOS/istSOS4/pull/189/files>)
- *A* api/app/db/oidc_user_crud.py
<https://github.com/istSOS/istSOS4/pull/189/files#diff-b5cbb84664e71debe1f1a03587e8efa1b3577c7f8cb4b4860f81de691d6c9573>
(131)
- *A* api/app/db/password_crud.py
<https://github.com/istSOS/istSOS4/pull/189/files#diff-ffea5e25b398e11ffc2fe0d90209122e8280b96981c75a7a06e20707379bed96>
(149)
- *A* api/app/models/password.py
<https://github.com/istSOS/istSOS4/pull/189/files#diff-204693f4b3d080133e0a91f31e132998aad3164752d83b53e3785e7074f66a43>
(56)
- *M* api/app/oauth.py
<https://github.com/istSOS/istSOS4/pull/189/files#diff-45f7727f3c69d2fc63e33c1a7860f7991f96648f832b866c2f734b8bcf6ff4a8>
(19)
- *M* api/app/rbac_roles.py
<https://github.com/istSOS/istSOS4/pull/189/files#diff-4f64e921cc910292eb2a34f7e610d6485eee87bafdc8ecc00b453e21fe96e534>
(17)
- *M* api/app/v1/api.py
<https://github.com/istSOS/istSOS4/pull/189/files#diff-67aed7ce745d81ed94f970eff70852e72c9e84f62a6b05c0d3515e478a9f78b8>
(4)
- *A* api/app/v1/endpoints/create/activate_user.py
<https://github.com/istSOS/istSOS4/pull/189/files#diff-4138a6e3f3f19f80643d4e0f066e4dd84cae38d9b7cd02dbbde74e646a9d30cd>
(235)
- *M* api/app/v1/endpoints/create/data_array_observation.py
<https://github.com/istSOS/istSOS4/pull/189/files#diff-9bd33fdf2c1100f98cdd41c28e380ab7ab4098a5a2159ba28803217818529396>
(1)
- *M* api/app/v1/endpoints/create/user.py
<https://github.com/istSOS/istSOS4/pull/189/files#diff-732d66cf15425eb39573c8b7c41ba289ea87b2cf6020aec76c49c36495104215>
(23)
- *M* api/app/v1/endpoints/functions.py
<https://github.com/istSOS/istSOS4/pull/189/files#diff-15b2f9a554a197c2d240a605a82e04df2b27000bd3508ba77819d5d3c4a7443c>
(14)
- *A* api/app/v1/endpoints/update/password.py
<https://github.com/istSOS/istSOS4/pull/189/files#diff-3d9b1d3baacee3169de96d99e6060822a2f67ed5d9b4b096ace18490c76564d7>
(74)
- *A* api/tests/test_password_update.py
<https://github.com/istSOS/istSOS4/pull/189/files#diff-60a36c66afc728f783f4a3990510926e3ad3367ec6d9ebaa0242657fcfb7d9f7>
(294)
- *A* api/tests/test_rbac_set_role_safety.py
<https://github.com/istSOS/istSOS4/pull/189/files#diff-e4b501d51cf28b164ebff8f26f9757666cb9b665d1ef75ccf16d3b47044db492>
(86)
- *A* api/tests/test_rls_policy_creation.py
<https://github.com/istSOS/istSOS4/pull/189/files#diff-6e8464e1b0a00e6c5c45b9796ecc4996f12ea078fdfbf09de972e945fe880bd5>
(206)
- *A* database/migrations/001_identity_linking.sql
<https://github.com/istSOS/istSOS4/pull/189/files#diff-ae14d467f66768268152efd47e2a35d1e332eb864fa31d49e572ae761c028e2b>
(58)
Patch Links:
- https://github.com/istSOS/istSOS4/pull/189.patch
- https://github.com/istSOS/istSOS4/pull/189.diff
—
Reply to this email directly, view it on GitHub
<#189?email_source=notifications&email_token=ADC2FTE5H6D7WCTBIZJG5ET463X4LA5CNFSNUABEM5UWIORPF5TWS5BNNB2WEL2QOVWGYUTFOF2WK43UF4ZTQMRVGQYDCNRVG6THEZLBONXW5KTTOVRHGY3SNFRGKZFFMV3GK3TUVRTG633UMVZF6Y3MNFRWW>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/ADC2FTAIHFJFNZ3H36BKRP3463X4LAVCNFSM6AAAAACZ7ORZ4CVHI2DSMVQWIX3LMV43ASLTON2WKOZUGYYTKMBQGMZDQOI>
.
Triage notifications, keep track of coding agent tasks and review pull
requests on the go with GitHub Mobile for iOS
<https://github.com/notifications/mobile/ios/ADC2FTBPSOTVXOHVBJG4ON3463X4LA5CNFSNUABEM5UWIORPF5TWS5BNNB2WEL2QOVWGYUTFOF2WK43UF4ZTQMRVGQYDCNRVG6THEZLBONXW5KTTOVRHGY3SNFRGKZFFMV3GK3TUVJTG633UMVZF62LPOM>
and Android
<https://github.com/notifications/mobile/android/ADC2FTARHXBUNI5GCV5NDYL463X4LA5CNFSNUABEM5UWIORPF5TWS5BNNB2WEL2QOVWGYUTFOF2WK43UF4ZTQMRVGQYDCNRVG6THEZLBONXW5KTTOVRHGY3SNFRGKZFFMV3GK3TUVZTG633UMVZF6YLOMRZG62LE>.
Download it today!
You are receiving this because you are subscribed to this thread.Message
ID: ***@***.***>
|
The original implementation incorrectly treated each istSOS user as an individual PostgreSQL login role, verifying passwords via asyncpg.connect() and changing them with ALTER USER ... WITH ENCRYPTED PASSWORD DDL. Mentor feedback clarified that istSOS users are strictly application-level entities. The backend connects to PostgreSQL via a single master service account (ISTSOS_ADMIN); individual users have no PostgreSQL login role. This commit pivots to Python-side passlib/bcrypt credential management: Schema: - Add migration 002: nullable password VARCHAR(255) column in sensorthings.User to store bcrypt hashes. Registration (create/user.py): - After INSERT, hash the plaintext password with pwd_context.hash() and store the bcrypt hash in the new User.password column. The CREATE USER DDL is kept because PostgreSQL roles are still required for RLS. Password update (password_crud.py): - Remove asyncpg.connect()-as-user and ALTER USER DDL entirely. - SELECT now includes the password column. - Verify via pwd_context.verify(current_password, stored_hash). - Hash new password via pwd_context.hash(new_password). - Persist with parameterised UPDATE sensorthings.User SET password = $1. - Add HTTP 400 guard for accounts with no local credential (NULL password). Tests (test_password_update.py): - Remove all asyncpg.connect mocks. - Mock pwd_context.verify / pwd_context.hash instead. - Add test for NULL password guard (new 400 case). - Happy-path assertion now verifies UPDATE (not ALTER) with hash as param. - Total: 10 tests, all passing. Docs: update model and endpoint docstrings to say local istSOS credential instead of PostgreSQL password.
Replace asyncpg.connect()-as-user authentication in oauth.py with Python-side passlib/bcrypt verification against the application-level sensorthings."User"."password" column. Changes: - Delete get_auth_connection() context manager entirely - Rewrite authenticate_user() to use a single pooled SELECT that reads id, role, and password, then verifies via pwd_context.verify() - Remove asyncpg, POSTGRES_DB/HOST/PORT imports from oauth.py (no longer needed — all auth is now pool-based) - NULL password (OIDC user or pre-migration account) returns None → 401 at the login endpoint, avoiding a crash or misleading error - Replace test_oauth_connection_leak.py (which asserted asyncpg.connect was called) with TestAuthenticateUser covering: unknown user, NULL hash, wrong password, correct password, and a regression guard confirming asyncpg.connect is never invoked in the new flow 15 tests passing (10 password update + 5 oauth).
Legacy local accounts (User.password IS NULL) previously blocked login and the password-update endpoint. This commit adds a transparent Just-In-Time upgrade path so these accounts work without any manual migration step. oauth.py — authenticate_user(): - Restored get_auth_connection() as a legacy fallback helper - Added OIDC guard: auth_provider IS NOT NULL → return None - Modern accounts (password IS NOT NULL): verify via pwd_context.verify() - Legacy accounts (password IS NULL): fall back to get_auth_connection(); on success, write bcrypt hash inline (pool UPDATE) so the next login uses the fast bcrypt path directly password_crud.py — update_local_password(): - Removed HTTP 400 block for NULL password accounts - Modern accounts: verify via pwd_context.verify() - Legacy accounts: verify via get_auth_connection() fallback; the new bcrypt hash written by the UPDATE doubles as the migration Tests: - test_oauth_connection_leak.py: +2 tests (legacy wrong/correct password, JIT hash-write assertion); +1 OIDC guard test → 6 total - test_password_update.py: replaced null-password 400 test with legacy wrong-password 401 + legacy upgrade UPDATE tests → 11 total - Total: 17 passed
Contributor
Author
|
Thank you for the clarification.I have updated the PR to use application-layer credentials (passlib bcrypt hashes) and removed all PostgreSQL password logic entirely. I also refactored oauth.py to authenticate against these new hashes instead of pg_authid and implemented a JIT migration to seamlessly upgrade legacy test users upon their next login. |
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.
Password Updates & Input Validation for Local Users
Why
Local users needed a way to change their istSOS password via the API. Since the codebase now supports both local and external OIDC users sharing the same
Usertable, the endpoint explicitly guards against OIDC users attempting to update a local credential — a concept that does not apply to them.Passwords are stored as
bcrypthashes in a newpasswordcolumn onsensorthings."User"and are hashed entirely on the Python side usingpasslib. The login flow (/Login) and the password-update flow both verify and write credentials against this column via the shared connection pool.For accounts created before this migration (
password IS NULL), a transparent JIT (Just-In-Time) upgrade path is provided: legacy credentials are verified against PostgreSQL'spg_authidvia a direct connection, and the bcrypt hash is written immediately on first successful authentication — zero disruption, no admin intervention required.What changed
database/migrations/002_user_password_column.sqlpassword VARCHAR(255)column tosensorthings."User"for bcrypt hashesapi/app/oauth.pyget_auth_connection()retained as a legacy fallback;authenticate_user()now checks bcrypt hash for modern accounts and falls back topg_authid+ JIT hash write for legacy accounts; OIDC guard addedapi/app/db/password_crud.pyupdate_local_password()— OIDC guard, bcrypt verify for modern accounts,pg_authidfallback for legacy accounts (simultaneous upgrade); parameterisedUPDATEapi/app/v1/endpoints/create/user.pyUser.passwordimmediately afterINSERT(same transaction)api/app/models/password.pyPasswordUpdateRequestPydantic v2 schema — enforces min 12 chars, 1 uppercase, 1 digit via@field_validatorapi/app/v1/endpoints/update/password.pyPATCH /Users/{id}/password— owner-or-admin authorization guard, returns 204api/tests/test_password_update.pyapi/tests/test_oauth_connection_leak.pyauthenticate_user()modern + legacy paths, OIDC guard, JIT hash-write assertionrequirements-test.txtpasslib[bcrypt]==1.7.4Edge cases handled
auth_provider IS NOT NULLcheck fires immediately:"External identities cannot update passwords locally."pwd_context.verify()(modern) orget_auth_connection()fallback (legacy)password IS NULL) → JIT upgrade — authenticated via PostgreSQLpg_authidfallback; bcrypt hash written inline so the next login usespwd_context.verify()directly/Login→ 401 —auth_provider IS NOT NULLguard inauthenticate_user()returnsNoneHow to test