Skip to content

Role Re-assignment for Active Users#190

Open
KinshukSS2 wants to merge 5 commits into
istSOS:mainfrom
KinshukSS2:feat/role-reassignment
Open

Role Re-assignment for Active Users#190
KinshukSS2 wants to merge 5 commits into
istSOS:mainfrom
KinshukSS2:feat/role-reassignment

Conversation

@KinshukSS2

@KinshukSS2 KinshukSS2 commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Role Re-assignment for Active Users

Summary

Implements administrator-only role reassignment for active istSOS users, along with the supporting OIDC user provisioning, admin activation flow, and password self-service endpoint. This PR is part of the RBAC GSoC project and builds on top of feat/password-updates.

Users in istSOS are application-level entities stored in sensorthings."User". The backend connects to PostgreSQL via a single master service account; all credential and role management operates on the application layer.


New Endpoints

Method Path Auth Description
PATCH /Users/{user_id}/role administrator Reassign an active user's application-layer role
PATCH /Users/{user_id}/password own account or admin Update local bcrypt credential
POST /Users/{user_id}/activate administrator Promote a pending OIDC user to an active role

Architecture

Role reassignment is a pure application-state mutation:

UPDATE sensorthings."User" SET role = $1 WHERE id = $2

Passwords are stored as bcrypt hashes in sensorthings."User"."password" and verified with passlib on the Python side.

OIDC users are provisioned in a pending waiting room with zero database footprint until an administrator explicitly activates them with a target role.


Security Design

PATCH /Users/{id}/role — Four independent layers

  1. JWT verificationget_current_user() validates the Bearer token on every request (HTTP 401 on failure).

  2. Live role fetch — The caller's role is fetched directly from the database on each request, not read from the JWT payload. A demoted administrator loses access on their next request with no token rotation needed.

  3. Administrator-only guard — Checked at the endpoint layer before any database interaction (HTTP 403 for non-admins).

  4. administrator is not API-assignable — The Pydantic schema rejects it at deserialization time (HTTP 422). Promotion to administrator is a deploy-time DBA operation via istsos_auth.sql; there is no application code path that can write role = 'administrator'.

Last-Administrator Lockout (Race-Safe)

When demoting an administrator, all administrator rows are locked inside a single transaction before the count is taken:

admin_rows = await conn.fetch(
    'SELECT id FROM sensorthings."User" WHERE role = \'administrator\' FOR UPDATE'
)
if len(admin_rows) <= 1:
    raise HTTPException(409, "Cannot demote the last administrator.")

This prevents two concurrent demotion requests from both reading a count of 2, both passing the guard, and both succeeding — which would leave the system with zero administrators.

Pending-User Gate

OIDC users in the pending state are blocked globally in get_current_user() (HTTP 403) before reaching any endpoint handler. They cannot access any resource until an administrator activates them.


Guards on PATCH /Users/{id}/role

Condition Response
Non-administrator caller HTTP 403
User not found HTTP 404
Target user is pending HTTP 400 — activate first
role is already new_role HTTP 204 — silent no-op, zero DB writes
Demoting the last administrator HTTP 409
role = 'administrator' in request body HTTP 422 — schema rejection
Success HTTP 204

Shared Constants

POLICY_FN_MAP — the mapping from application role to PostgreSQL RLS policy function — lives in app/rbac_roles.py as the single source of truth and is imported wherever RLS policies need to be applied.

# app/rbac_roles.py
POLICY_FN_MAP = {
    "viewer":      "sensorthings.viewer_policy",
    "editor":      "sensorthings.editor_policy",
    "obs_manager": "sensorthings.obs_manager_policy",
    "sensor":      "sensorthings.sensor_policy",
}

Database Migrations

File Description
001_identity_linking.sql Adds auth_provider, external_sub_id to sensorthings."User"; partial unique index on (auth_provider, external_sub_id)
002_user_password_column.sql Adds password VARCHAR(255) DEFAULT NULL to sensorthings."User" for bcrypt hashes

Files

api/app/db/
  role_crud.py          — update_user_role(): atomic UPDATE + last-admin lockout
  password_crud.py      — update_local_password(): passlib verify + parameterised UPDATE
  oidc_user_crud.py     — create_pending_oidc_user(), get_user_by_provider_sub()

api/app/models/
  role.py               — RoleUpdateRequest Pydantic schema
  password.py           — PasswordUpdateRequest Pydantic schema

api/app/v1/endpoints/
  update/role.py        — PATCH /Users/{id}/role
  update/password.py    — PATCH /Users/{id}/password
  create/activate_user.py — POST /Users/{id}/activate
  create/user.py        — auto-RLS policy dispatch via shared POLICY_FN_MAP

api/app/
  rbac_roles.py         — VALID_RBAC_ROLES, PENDING_ROLE, POLICY_FN_MAP
  oauth.py              — pending-user gate in get_current_user(); JIT bcrypt migration

database/migrations/
  001_identity_linking.sql
  002_user_password_column.sql

api/tests/
  test_role_reassignment.py   — 12 tests (schema, CRUD guards, endpoint auth)
  test_password_update.py     — 9 tests (schema, CRUD guards, endpoint auth)
  test_rls_policy_creation.py — RLS policy dispatch tests
  test_rbac_set_role_safety.py — SET ROLE injection safety tests

Test Results

46 passed in 0.74s

All tests run without a live database. Key assertions:

  • New role is always passed as a parameterised $1 argument — never interpolated.
  • REVOKE, GRANT, CREATE, ALTER are asserted absent from every SQL statement issued.
  • Concurrent last-admin demotion is blocked by row-level locking verified at the CRUD layer.

Manual Testing

# Reassign a user's role
curl -X PATCH http://localhost:8018/v1.1/Users/5/role \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"role": "obs_manager"}'
# → 204 No Content

# Attempt to promote to administrator (rejected at schema level)
curl -X PATCH http://localhost:8018/v1.1/Users/5/role \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"role": "administrator"}'
# → 422 Unprocessable Entity

# Attempt to demote the last administrator
curl -X PATCH http://localhost:8018/v1.1/Users/1/role \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"role": "viewer"}'
# → 409 Conflict

# Update own password
curl -X PATCH http://localhost:8018/v1.1/Users/5/password \
  -H "Authorization: Bearer $USER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"current_password": "OldPass1!", "new_password": "NewSecure1Pass!"}'
# → 204 No Content

# Activate a pending OIDC user
curl -X POST http://localhost:8018/v1.1/Users/7/activate \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"role": "viewer"}'
# → 200 {"message": "User '...' has been activated with role 'viewer'."}

Dependency / Merge Order

upstream/main
  └── PR #185  (Week 1: auto-RLS policy on user creation)
        └── feat/identity-linking-jit-provisioning
              └── feat/password-updates
                    └── feat/role-reassignment  ← this PR

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)
…le-JWT fix

- Add RoleUpdateRequest Pydantic v2 schema (models/role.py):
  delegates to validate_rbac_role(); blocks 'administrator' (bootstrap-only)
  and 'pending' (internal state) with HTTP 422 before any DB is touched.
  Docstring explains the security boundary explicitly.

- Add update_user_role() CRUD function (db/role_crud.py):
  All mutations run inside a single asyncpg transaction (FOR UPDATE lock):
  1. 404 if user not found.
  2. 400 if user is in 'pending' waiting room.
  3. No-op early return if current_role == new_role (no DDL issued).
  4. 409 if demoting the last administrator (lockout guard).
  5. UPDATE sensorthings."User" SET role = new_role.
  6. REVOKE <old_pg_group_role> / GRANT <new_pg_group_role> only when the
     underlying PostgreSQL group role changes (e.g. viewer→obs_manager).
     viewer→editor shares the same 'user' PG role — no DDL issued.
  pg_quote_ident used for all identifier interpolation.

- Add PATCH /Users/{id}/role endpoint (update/role.py):
  administrator-only guard at router layer; returns 204 No Content.

- Register update_role router in api.py inside AUTHORIZATION guard.

- Add comment to get_current_user() in oauth.py documenting that role is
  fetched live from the DB on every request (not from the JWT payload),
  eliminating stale-JWT vulnerabilities after role changes. No logic change.

- Add test_role_reassignment.py (12 tests, all pass, no live DB needed):
  schema: valid, administrator/pending/unknown blocked
  crud: 404, 400 pending, no-op, 409 last-admin, REVOKE+GRANT, no-DDL
  endpoint: 403 non-admin guard

Depends on: feat/password-updates (stacked)
@KinshukSS2 KinshukSS2 changed the title feat:Role Re-assignment for Active Users Role Re-assignment for Active Users Jun 8, 2026
@massimiliano-cannata

massimiliano-cannata commented Jun 9, 2026 via email

Copy link
Copy Markdown
Member

KinshukSS2 added a commit to KinshukSS2/istSOS4 that referenced this pull request Jun 10, 2026
Remove all PostgreSQL DDL (CREATE USER, REVOKE, GRANT, ALTER USER) from
the role and user management flows. istSOS users are application-level
entities; the backend connects via a single master service account.

Changes
-------
- rbac_roles.py: add shared POLICY_FN_MAP constant — single source of
  truth for RLS policy dispatch. Eliminates silent divergence between
  create/user.py and activate_user.py.

- create/user.py: remove CREATE USER … WITH ENCRYPTED PASSWORD and
  GRANT DDL. Import POLICY_FN_MAP from rbac_roles.py. Passwords are
  now stored as bcrypt hashes in sensorthings."User".password via a
  parameterised UPDATE (handled separately in password_crud.py).

- create/activate_user.py: remove CREATE ROLE … NOLOGIN and GRANT DDL.
  Activation is now a pure UPDATE on the role column plus an RLS policy
  call. Import POLICY_FN_MAP from rbac_roles.py.

- role_crud.py (already committed): last-admin lockout now locks ALL
  administrator rows with SELECT … FOR UPDATE before counting, fixing
  the race condition where two concurrent demotions could both pass a
  count of 2 and leave zero administrators.

- test_issue7_exception_handling.py: deleted (superseded by the new
  test_role_reassignment.py test suite which covers all guard paths).

No REVOKE, GRANT, CREATE ROLE, or ALTER USER statements remain in any
role or user management code path.

Refs: istSOS#190
…dentials

Users are strictly application-level entities managed via sensorthings."User".
The backend connects to PostgreSQL through a single master service account;
individual users have no PostgreSQL login roles.

- Add shared POLICY_FN_MAP in rbac_roles.py as single source of truth for
  RLS policy dispatch (used by create/user.py and activate_user.py)
- Role reassignment (PATCH /Users/{id}/role) is a pure UPDATE on User.role;
  last-admin lockout locks all admin rows via SELECT … FOR UPDATE before
  counting to prevent concurrent demotion race condition
- User activation (POST /Users/{id}/activate) updates User.role and applies
  the corresponding RLS policy function — no PostgreSQL DDL
- User creation stores bcrypt hash in User.password via parameterised UPDATE

Removed: CREATE USER, CREATE ROLE, REVOKE, GRANT, ALTER USER DDL.

Refs: istSOS#190
@KinshukSS2 KinshukSS2 force-pushed the feat/role-reassignment branch from f80e262 to 3392cb5 Compare June 10, 2026 19:31
@KinshukSS2

Copy link
Copy Markdown
Contributor Author

@massimiliano-cannata

  • removed all postgresql DDL (REVOKE/GRANT)...role changes are now pure application layer UPDATE queries
  • hardened security by blocking administrator assignment via the API schema
  • fixed the last-admin race condition with a FOR UPDATE database lock

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