Skip to content

feat/public-access - Public Data Access - #201

Open
KinshukSS2 wants to merge 27 commits into
istSOS:mainfrom
KinshukSS2:feat/public-access
Open

feat/public-access - Public Data Access #201
KinshukSS2 wants to merge 27 commits into
istSOS:mainfrom
KinshukSS2:feat/public-access

Conversation

@KinshukSS2

Copy link
Copy Markdown
Contributor

What

Implements Path A of the ODRL dual-path architecture: unauthenticated users can read datasets flagged is_public = true. All access control is enforced at the PostgreSQL RLS level — no application-layer guards.

Why

The previous blanket anonymous_datastream policy used USING (true), exposing all rows to guests. This PR replaces it with a per-row is_public flag and fine-grained RLS policies.

Key Changes

database/migrations/004_public_access.sql

  • ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT false on "Datastream" (existing rows default to private)
  • Drops old blanket guest policies; creates new ones:
    • anonymous_datastream: USING (is_public = true)
    • anonymous_observation: USING (datastream_id IN (SELECT id FROM "Datastream" WHERE is_public = true))
  • GRANT INSERT ON "AuditLog" TO "guest" for audit events

api/app/oauth.py

  • Adds get_optional_current_user() — returns user dict or None (never raises 401/403). Handles missing header, expired/revoked/invalid tokens, and pending accounts silently.

api/app/v1/endpoints/read/read.py

  • Replaces the AUTHORIZATION/ANONYMOUS_VIEWER conditional with a universal Depends(get_optional_current_user)
  • When current_user is None: logs a PUBLIC_READ audit event (before role switch), then falls back to SET LOCAL ROLE "guest" to activate RLS
  • Fix (c73ba6e): Removed orphaned RESET ROLE inside the $value path — SET LOCAL ROLE is transaction-scoped and auto-reverts; calling RESET ROLE mid-transaction would have prematurely re-escalated privileges

All 10 read endpoints (datastream.py, observation.py, etc.) migrated to get_optional_current_user.

Reviewer Focus

  • RLS policies in migration 004 are the security boundary — verify USING clauses
  • get_optional_current_user must never raise HTTPException — verify all branches return None
  • Audit INSERT runs before SET LOCAL ROLE guest — intentional (pool-user privileges needed)
  • Migration is idempotent: IF NOT EXISTS / DROP … IF EXISTS guards throughout

Testing

# Anonymous — sees only public datastreams
curl http://localhost:8018/istsos4/v1.1/Datastreams

# Authenticated — sees all
curl http://localhost:8018/istsos4/v1.1/Datastreams -H "Authorization: Bearer $TOKEN"

# Invalid token — degrades to guest (HTTP 200, not 401)
curl http://localhost:8018/istsos4/v1.1/Datastreams -H "Authorization: Bearer INVALID"

# Audit log
SELECT action_type, actor_id FROM sensorthings."AuditLog" WHERE action_type = 'PUBLIC_READ';

Summary

This PR implements Path A of the ODRL dual-path architecture: unauthenticated users can now read datasets that an administrator has explicitly marked as is_public = true, while all other datasets remain securely hidden. Access control is enforced entirely at the PostgreSQL kernel level via Row-Level Security (RLS) — no application-layer if/else guards.


Motivation

Before this PR:

  • The system had a blanket anonymous_datastream RLS policy using USING (true), which exposed all Datastreams and Observations to unauthenticated callers.
  • Public access required the ANONYMOUS_VIEWER environment flag and a binary conditional in the read pipeline.
  • There was no audit trail for unauthenticated data access.

After this PR:

  • Anonymous users see only rows where is_public = true.
  • Authenticated users see the full row set their database role permits (unchanged).
  • Every anonymous read generates a PUBLIC_READ entry in sensorthings."AuditLog".
  • The feature works regardless of the ANONYMOUS_VIEWER / AUTHORIZATION flags — the optional-auth dependency gracefully degrades for any token state.

Changes

database/migrations/004_public_access.sql (new)

DDL Purpose
ALTER TABLE "Datastream" ADD COLUMN IF NOT EXISTS is_public BOOLEAN NOT NULL DEFAULT false Per-datastream public flag; defaults to private for all existing rows
DROP POLICY IF EXISTS anonymous_datastream ON "Datastream" Remove blanket allow-all guest policy
DROP POLICY IF EXISTS anonymous_observation ON "Observation" Remove blanket allow-all guest policy
CREATE POLICY anonymous_datastream … USING (is_public = true) Guest sees only public datastreams
CREATE POLICY anonymous_observation … USING (datastream_id IN (SELECT id … WHERE is_public = true)) Observations inherit parent datastream visibility — no per-observation flag drift
GRANT INSERT ON "AuditLog" TO "guest" Allows the guest session to write PUBLIC_READ audit events

The entire migration is guarded by IF current_setting('custom.authorization', true)::boolean (consistent with migrations 001–003) and is idempotent via the IF NOT EXISTS / DROP … IF EXISTS pattern.


api/app/oauth.py

Added oauth2_scheme_optional

oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="Login", auto_error=False)

auto_error=False prevents FastAPI from auto-rejecting requests with no Authorization header before our handler runs.

Added get_optional_current_user()

A soft version of get_current_user that returns None instead of raising 401/403 for:

Condition Behaviour
No Authorization header return None
Malformed / invalid signature except InvalidTokenError → return None
Expired token except InvalidTokenError → return None
Redis-revoked token return None
Unknown sub claim return None
Account in pending state return None (prevents pending users accessing guest data)

api/app/v1/endpoints/read/read.py

Module-level dependency — removed the AUTHORIZATION/ANONYMOUS_VIEWER conditional:

# Before
user = Header(default=None, include_in_schema=False)
if AUTHORIZATION and not ANONYMOUS_VIEWER:
    user = Depends(get_current_user)

# After
user = Depends(get_optional_current_user)

asyncpg_stream_results execution engine — updated anonymous branch:

if current_user is not None:
    await set_role(connection, current_user)   # authenticated path
else:
    # 1. Audit INSERT while still at pool-user privilege level
    await log_audit_event(conn, AUDIT_ACTION_PUBLIC_READ, actor_id=None, ...)
    # 2. Downgrade to guest → activates is_public RLS
    current_user = {"username": "guest"}
    await set_role(connection, current_user)

Fix (c73ba6e): Removed an orphaned RESET ROLE call in the $value early-return path. set_role() uses SET LOCAL ROLE (transaction-scoped), so an explicit RESET ROLE mid-transaction would have prematurely escalated back to pool-user privileges before the transaction committed.


api/app/v1/endpoints/read/{datastream,observation,thing,...}.py

All 10 specific-entity read endpoints now use:

from app.oauth import get_optional_current_user
user = Depends(get_optional_current_user)

The old AUTHORIZATION/ANONYMOUS_VIEWER conditionals are removed from all of them.


Security Invariants

Invariant Enforcement Layer
Private datastreams hidden from guests PostgreSQL RLS (kernel)
Observation visibility derived from parent Datastream Correlated sub-query in RLS policy
No privilege leak on $value path SET LOCAL ROLE auto-reverts; RESET ROLE removed
Expired/revoked tokens → guest fallback, not 401 get_optional_current_user
Pending users cannot use guest path as bypass PENDING_ROLE check in optional dep
Anonymous reads are audited log_audit_event(AUDIT_ACTION_PUBLIC_READ) before role downgrade
Audit trail is append-only UPDATE/DELETE revoked from all roles (migration 003)
Pool connection role never leaks SET LOCAL ROLE scoped to connection.transaction()

Test Plan

Unit / Manual

BASE="http://localhost:8018/istsos4/v1.1"
TOKEN=$(curl -s -X POST "${BASE%/v1.1}/Login" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "username=admin&password=admin" | jq -r .access_token)

# 1. Mark a datastream public
curl -s -X PATCH "$BASE/Datastreams(1)" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"is_public": true}'

# 2. Anonymous fetch — should return only public rows
curl -s "$BASE/Datastreams" | jq '.value | length'

# 3. Authenticated fetch — should return ALL rows
curl -s "$BASE/Datastreams" -H "Authorization: Bearer $TOKEN" | jq '.value | length'

# 4. Invalid token → graceful guest fallback (HTTP 200, not 401)
curl -s "$BASE/Datastreams" -H "Authorization: Bearer INVALID" | jq .

# 5. Observations — only from public Datastreams
curl -s "$BASE/Observations" | jq '.value | length'

# 6. Audit log
docker exec istsos4-database-1 psql -U postgres -d istsos -c \
  "SELECT action_type, actor_id, dataset_id FROM sensorthings.\"AuditLog\" WHERE action_type='PUBLIC_READ' LIMIT 5;"

Expected assertions

  • Anonymous: only is_public = true Datastreams visible; corresponding Observations visible; all else returns 0 rows or 404.
  • Authenticated admin: full row count.
  • Invalid token: HTTP 200 with guest-scoped rows.
  • AuditLog: PUBLIC_READ row with actor_id IS NULL for every anonymous read.

Migration Instructions

# Apply on a running stack
docker exec -i istsos4-database-1 psql -U postgres -d istsos \
  < database/migrations/004_public_access.sql

# Verify column exists
docker exec istsos4-database-1 psql -U postgres -d istsos -c \
  "\d sensorthings.\"Datastream\"" | grep is_public

# Verify new policies
docker exec istsos4-database-1 psql -U postgres -d istsos -c \
  "SELECT policyname, roles, qual FROM pg_policies WHERE tablename IN ('Datastream','Observation');"

Review Notes

  • CREATE POLICY is idempotent via DROP POLICY IF EXISTS before each CREATE POLICY.
  • ADD COLUMN IF NOT EXISTS is safe to re-run.
  • The GRANT INSERT ON "AuditLog" TO "guest" is guarded by an existence check on pg_roles.
  • No environment variable changes required — the feature activates whenever AUTHORIZATION=1.

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)
…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
BREAKING: set_role() now maps app-layer roles to PG group roles via
SET LOCAL ROLE instead of SET ROLE <username>. All 72 RESET ROLE
instances deleted across 41 endpoint files.

Refactor 1 — functions.py::set_role():
  - Maps viewer/editor → 'user', sensor/obs_manager → 'sensor', etc.
  - SET LOCAL ROLE (transaction-scoped, auto-reverts on COMMIT/ROLLBACK)
  - Removed inner connection.transaction() nested savepoint
  - Eliminated entire RESET ROLE call class (pool leak prevention)

Refactor 2 — password_crud.py:
  - Removed asyncpg.connect() credential verification
  - Removed ALTER USER … WITH ENCRYPTED PASSWORD DDL
  - Modern path: passlib/bcrypt verify + UPDATE User.password
  - Legacy fallback: get_auth_connection() for NULL password JIT migration

Refactor 3 — role_crud.py:
  - Removed REVOKE/GRANT DDL block (lines 167-196)
  - Role reassignment is pure UPDATE sensorthings."User" SET role
  - Removed _ADMIN_PG_ROLE, DB_ROLE_BY_RBAC_ROLE, pg_quote_ident imports

Refactor 4 — Test suite alignment:
  - test_set_role_sql_safety: asserts SET LOCAL ROLE + group role mapping
  - test_rbac_set_role_safety: parametrised 5 app roles → PG group roles
  - test_password_update: asserts UPDATE + bcrypt hash (not ALTER USER)
  - test_role_reassignment: asserts UPDATE only (not REVOKE/GRANT)
  - test_policy_role_switch: asserts SET LOCAL ROLE + zero RESET ROLE

49 files changed, 298 insertions(+), 442 deletions(-)
63/63 tests passing.
update/user.py lines 119-135 contained live REVOKE/GRANT statements
that were missed in the PR5 cleanup pass. These operated on individual
usernames as PostgreSQL role identifiers, which no longer exist under
the app-layer credential model.

Remove the entire DDL block. Role changes are reflected in the
sensorthings."User".role UPDATE above; set_role() maps the new role
to its PG group role dynamically at request time.
## Summary
Completes the authentication foundation required before building any
new access-control features.  Two self-contained changes:

  1. database/migrations/002_add_password_status.sql
  2. api/app/oauth.py — rewrite authenticate_user()

---

## 1. Migration: 002_add_password_status.sql

Adds two columns to sensorthings."User" inside the existing
custom.authorization guard block (mirrors migration 001):

  • password VARCHAR(255) DEFAULT NULL
      Stores the passlib/bcrypt hash of the user's local credential.
      NULL signals a legacy (pre-migration) account; on next login the
      pg_authid JIT fallback fires and backfills this column so
      subsequent logins never touch pg_authid again.

  • status VARCHAR(50) DEFAULT 'active'
      Account lifecycle flag.  'active' is the default so all existing
      rows are completely unaffected.  Future values: 'suspended',
      'deleted'.  Application-layer enforcement is a follow-up task.

Both ALTER TABLE statements use ADD COLUMN IF NOT EXISTS so the
migration is idempotent and safe to re-run against instances that
already received the columns via a prior manual hotfix.

---

## 2. authenticate_user() — bcrypt-first with JIT pg_authid fallback

Replaces the legacy pg_authid-only authentication flow with a
three-step process:

  Step 1 — Fetch from sensorthings."User"
    SELECT id, username, role, password WHERE username = $1.
    Unknown users return None immediately; we never attempt pg_authid
    for users not present in the application-layer table.

  Step 2 — Bcrypt verify (modern path, password IS NOT NULL)
    pwd_context.verify() is dispatched via asyncio.to_thread() to
    avoid blocking the event loop with bcrypt's intentional CPU cost.
    Correct hash → return user dict.  Wrong hash → return None.

  Step 3 — pg_authid JIT fallback (legacy path, password IS NULL)
    get_auth_connection() attempts a raw asyncpg.connect() to let
    PostgreSQL validate via pg_authid.
    • Failure → return None.
    • Success → asyncio.to_thread(pwd_context.hash, password) computes
      the bcrypt hash then writes it via the write pool
      (POSTGRES_PORT_WRITE pattern from role_crud / password_crud).
      Backfill failure is caught, logged, and swallowed — login still
      succeeds (best-effort JIT migration, never blocks the user).

  Circular import resolution
    pwd_context lives in password_crud.py which already imports
    get_auth_connection from oauth.py.  Both symbols are imported
    lazily inside the function body to break the cycle, following the
    identical pattern already used in password_crud.py line 115.

---

Breaking changes: none.
Existing users with password IS NULL continue to log in as before;
they are transparently migrated on first login.
Users with a bcrypt hash no longer require a pg_authid LOGIN role.
…ation errors

passlib 1.7.4 is incompatible with bcrypt >= 4.1 due to a wrap-bug
detection test that passes a >72-byte secret, which bcrypt 4+ rejects
with ValueError.  Pin bcrypt==4.0.1 — the last version that works with
passlib 1.7.4 without triggering the 72-byte guard.

A previous refactor left dangling 'if current_user is not None:' guards
with no body before the return statement, causing Python to raise
IndentationError at import time and crashing the entire API process.

Removed the dead guard in each case — the 404 / not-found response
should always be returned regardless of auth context.  The outer
exception handler already enforces auth context where needed.

Files fixed:
  api/app/v1/endpoints/create/bulk_observation.py  (ValueError catch)
  api/app/v1/endpoints/delete/observation.py        (404 guard)
  api/app/v1/endpoints/update/historical_location.py (404 guard)
  api/app/v1/endpoints/update/location.py           (404 guard)
  api/app/v1/endpoints/update/observation.py        (404 guard)
  api/app/v1/endpoints/update/observed_property.py  (404 guard)
  api/app/v1/endpoints/update/thing.py              (404 guard)
BREAKING: set_role() now maps app-layer roles to PG group roles via
SET LOCAL ROLE instead of SET ROLE <username>. All 72 RESET ROLE
instances deleted across 41 endpoint files.

Refactor 1 — functions.py::set_role():
  - Maps viewer/editor → 'user', sensor/obs_manager → 'sensor', etc.
  - SET LOCAL ROLE (transaction-scoped, auto-reverts on COMMIT/ROLLBACK)
  - Removed inner connection.transaction() nested savepoint
  - Eliminated entire RESET ROLE call class (pool leak prevention)

Refactor 2 — password_crud.py:
  - Removed asyncpg.connect() credential verification
  - Removed ALTER USER … WITH ENCRYPTED PASSWORD DDL
  - Modern path: passlib/bcrypt verify + UPDATE User.password
  - Legacy fallback: get_auth_connection() for NULL password JIT migration

Refactor 3 — role_crud.py:
  - Removed REVOKE/GRANT DDL block (lines 167-196)
  - Role reassignment is pure UPDATE sensorthings."User" SET role
  - Removed _ADMIN_PG_ROLE, DB_ROLE_BY_RBAC_ROLE, pg_quote_ident imports

Refactor 4 — Test suite alignment:
  - test_set_role_sql_safety: asserts SET LOCAL ROLE + group role mapping
  - test_rbac_set_role_safety: parametrised 5 app roles → PG group roles
  - test_password_update: asserts UPDATE + bcrypt hash (not ALTER USER)
  - test_role_reassignment: asserts UPDATE only (not REVOKE/GRANT)
  - test_policy_role_switch: asserts SET LOCAL ROLE + zero RESET ROLE

49 files changed, 298 insertions(+), 442 deletions(-)
63/63 tests passing.
## Summary
Implements the audit trail foundation for STAC/ODRL access governance.
Two self-contained additions stacked on feat/auth-foundation-phase0:

  1. database/migrations/003_audit_log.sql
  2. api/app/db/audit_crud.py

---

## 1. Migration: 003_audit_log.sql

Creates sensorthings."AuditLog" inside the custom.authorization guard
block (SET ROLE "administrator" for DDL, RESET ROLE before privileges).

Schema:
  id             UUID PRIMARY KEY DEFAULT gen_random_uuid()
                 gen_random_uuid() provided by pgcrypto (already loaded)
  actor_id       BIGINT FK → sensorthings."User"(id) ON DELETE SET NULL
                 Nullable for anonymous events (e.g. PUBLIC_READ)
  action_type    VARCHAR(50) NOT NULL CHECK IN (
                   'PUBLIC_READ', 'RESTRICTED_REQUEST', 'ADMIN_APPROVAL')
  dataset_id     TEXT  — STAC dataset identifier (nullable)
  odrl_policy_id TEXT  — ODRL policy reference (nullable)
  payload        JSONB DEFAULT NULL — arbitrary event metadata
  created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW()

Indexes:
  idx_auditlog_action_type  (btree) — filter by event category
  idx_auditlog_actor_id     (btree) — filter by user

Append-only enforcement (after RESET ROLE, following istsos_auth.sql
lines 398-444 pattern):
  REVOKE UPDATE, DELETE from: administrator, user, sensor, guest, qc
  GRANT INSERT to: user, sensor

---

## 2. Helper: audit_crud.py

async def log_audit_event(conn, action_type, actor_id, dataset_id,
                           odrl_policy_id, payload) -> None

  * Accepts a pre-acquired asyncpg connection so callers can include
    the audit INSERT in the same transaction as the action being logged.
  * Serialises payload dict via json.dumps() + $5::jsonb cast to avoid
    asyncpg TypeError on dict → JSONB (oidc_user_crud.py pattern).
  * None payload passes through as SQL NULL unchanged.
  * Exports three action-type constants (AUDIT_ACTION_PUBLIC_READ,
    AUDIT_ACTION_RESTRICTED_REQUEST, AUDIT_ACTION_ADMIN_APPROVAL)
    that match the DB CHECK constraint exactly.

---

Breaking changes: none.
No existing tables or application code are modified.
….sql

The 'qc' role only exists when AUTHORIZATION was enabled at DB init time.
Replace flat REVOKE statements with a DO block that checks pg_roles
before each REVOKE/GRANT so the migration is idempotent and safe on
any deployment configuration.
Implements POST /Register (Path B, Step 4 of the architecture plan).

Changes
-------
* api/app/models/register_request.py
  - ContactInfo BaseModel: 6 optional string fields (domain, company,
    address, telephone, telegram, linkedin).
  - RestrictedRegistrationRequest BaseModel: username, password,
    dataset_id, odrl_policy_id, explanation, contact_info.

* api/app/v1/endpoints/create/register_request.py
  - Public POST /Register handler (no auth dependency).
  - bcrypt hash via asyncio.to_thread (non-blocking).
  - Merges explanation into contact JSONB blob.
  - Single atomic transaction: INSERT User (pending/active),
    UPDATE uri, INSERT AuditLog (RESTRICTED_REQUEST).
  - Structured error handling: 409 UniqueViolation, 503, 504, 500.

* api/app/v1/api.py
  - Import + include_router for register_request.v1 under the
    AUTHORIZATION guard alongside other user-management routers.
PATCH /Users/{target_user_id}/policy-approval

* Add AdminApprovalRequest Pydantic model (models/approval_request.py)
  - assigned_role validated via validate_rbac_role (field_validator)
  - dataset_id + odrl_policy_id forwarded to AuditLog verbatim

* Add admin_approval endpoint (update/admin_approval.py)
  - Administrator-only guard (HTTP 403 on non-admin callers)
  - Single write-pool transaction:
      1. Fetch username for RLS policy name construction
      2. UPDATE User SET role=<role>, status='active'
         WHERE id=<id> AND role='pending'  RETURNING id
         → HTTP 404 if no row returned (not found / not pending)
      3. Apply POLICY_FN_MAP RLS function if role has a default policy
      4. log_audit_event(ADMIN_APPROVAL) — atomic with UPDATE

* Wire admin_approval.v1 router into api.py under AUTHORIZATION guard
  between update_role and delete_user
sensorthings.viewer_policy (and all role policy fns) issue:
  CREATE POLICY ... TO <username>
which requires <username> to be a PostgreSQL role.

Self-registered users created via POST /Register have zero DB footprint
by architectural design — no CREATE ROLE is ever issued.  Calling the
policy function for these users raises asyncpg.UndefinedObjectError.

Two fixes applied:
1. Catch UndefinedObjectError and log a WARNING — skip RLS gracefully.
2. Wrap the policy call in async with conn.transaction() (savepoint) so
   the caught error does NOT poison the outer transaction.  Without the
   savepoint, asyncpg marks the entire transaction aborted and the
   subsequent log_audit_event INSERT fails with InFailedSQLTransactionError.

The UPDATE (role/status) and AuditLog INSERT continue to commit atomically
when RLS is skipped.
- database/migrations/004_public_access.sql
  * ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT false to Datastream
  * DROP blanket anonymous_datastream / anonymous_observation policies
  * CREATE fine-grained guest RLS policies filtered by is_public
  * GRANT INSERT on AuditLog to guest for PUBLIC_READ audit events

- api/app/oauth.py
  * Add oauth2_scheme_optional (auto_error=False) for optional auth
  * Add get_optional_current_user(): returns user dict or None
    (never raises 401/403; covers missing/expired/revoked tokens
    and pending accounts — all treated as anonymous)

- api/app/v1/endpoints/read/read.py
  * Remove AUTHORIZATION/ANONYMOUS_VIEWER conditional branching
  * Use get_optional_current_user universally at module level
  * asyncpg_stream_results: always fall back to guest role when
    current_user is None (activates is_public RLS policies)
  * Log PUBLIC_READ audit event BEFORE SET LOCAL ROLE so the
    INSERT runs with pool-user privileges, not restricted guest role

- api/app/v1/endpoints/read/{datastream,observation,thing,...}.py
  * All specific-entity read endpoints already migrated to
    get_optional_current_user dependency injection
SET LOCAL ROLE (used by set_role()) is transaction-scoped and
auto-reverts when connection.transaction() exits. Calling RESET ROLE
mid-transaction in the $value early-return path would prematurely
escalate back to pool-user privileges before the transaction commits,
creating a privilege window inconsistent with all other exit paths.
@KinshukSS2
KinshukSS2 force-pushed the feat/public-access branch from c73ba6e to d0dcabd Compare July 21, 2026 13:36
…ader params

Keeps the one genuine improvement from the prior edit (deduplicated
revoked-token check in refresh_token) while reverting three regressions:
unconditional Redis calls with no REDIS-flag guard, a raw JWT exp value
passed straight to redis.set(ex=...) instead of clamped via ttl_from_exp,
and Header() (required) instead of Header(default=None), which broke the
custom 400 response for a missing Authorization header.
@KinshukSS2
KinshukSS2 force-pushed the feat/public-access branch from b597bb2 to 0577ed6 Compare July 27, 2026 10:29
RestrictedRegistrationRequest.password had no validation at all — any
string, including a single character, passed. Adds a shared
validate_password_strength() helper (app/validators.py) enforcing:
  - at least 8 characters
  - at least 1 digit
  - at least 1 symbol

Kept in a separate shared module rather than inline in this model so
the same rule can be applied to PasswordUpdateRequest.new_password
(feat/password-updates) without duplicating the check in two places
and letting them silently drift apart, which is what had happened
previously (password-change enforced 12 chars/1 upper/1 digit;
registration enforced nothing).

Verified live: 422 on <8 chars, 422 on no digit, 422 on no symbol,
201 on a password satisfying all three rules.
…trength()

PasswordUpdateRequest.new_password previously enforced its own inline
rule (12 chars, 1 uppercase, 1 digit). Extracts a shared
validate_password_strength() helper (app/validators.py) so the same
rule can be applied to a brand-new account's initial password at
registration (feat/restricted-registration) without the two checks
silently drifting apart, which is what had already happened (password
update enforced strength, registration enforced nothing at all).

New unified rule:
  - at least 8 characters
  - at least 1 digit
  - at least 1 symbol

Verified live via PATCH /Users/{id}/password: 422 on <8 chars, 422 on
no symbol, 204 on a password satisfying all three rules, old password
rejected afterward, new one accepted.
…oints

Both PATCH /Users/{id}/policy-approval and POST /Users/{id}/activate
only guarded on role == 'pending'. Since rejection (PATCH .../reject)
deliberately leaves role='pending' and only flips status to 'rejected',
a rejected user could be silently approved/activated anyway through
either endpoint, fully bypassing the rejection with no error.

Adds an explicit status == 'rejected' check (400) to both endpoints,
returned before any mutation is attempted.

Also fixes an unrelated crash found while testing this: POST
/Users/{id}/activate had no guard around the RLS policy-creation call
for self-registered ('zero DB footprint') users, so it 500'd with an
unhandled UndefinedObjectError instead of the graceful savepoint-based
skip that update/admin_approval.py already had. Ported that same
try/except savepoint pattern over — now activates self-registered
users correctly (verified live) instead of crashing, independent of
the new rejection guard.

Verified live: policy-approval on a rejected user -> 400 (was: silent
200 bypass). /activate on a rejected user -> 400 (was: 500 crash).
/activate on a fresh, non-rejected self-registered user -> 200,
correctly activated, RLS policy gracefully skipped with a log warning
(was: crashed for this case too, even without rejection involved).
…tion

Adds the negative counterpart to PATCH /Users/{id}/policy-approval:
a pending user's registration request can now be explicitly rejected
rather than only ever approved or left pending indefinitely.

- PATCH /Users/{id}/reject (admin-only): sets status='rejected' on a
  pending user, leaving role untouched (still 'pending' — rejection is
  a lifecycle transition, not an RBAC role assignment). Guarded by
  WHERE role='pending' so an already-approved user can't be silently
  rejected by mistake (404 otherwise). Logs an ADMIN_REJECTION audit
  event.

- database/migrations/004_admin_rejection.sql extends the
  AuditLog.action_type CHECK constraint to allow 'ADMIN_REJECTION'.
  No change needed to User.status itself (unconstrained VARCHAR(50)).

- oauth.py authenticate_user(): after password verification succeeds
  (both the bcrypt and pg_authid JIT-fallback paths), a rejected
  account raises 401 immediately. Password is checked first so a wrong
  password on a rejected account still returns the generic "incorrect
  username or password" instead of leaking rejection status to an
  unauthenticated guesser.

- register_request.py: POST /Register now checks for an existing
  'rejected' row (SELECT ... FOR UPDATE, to avoid a re-application
  race) before deciding INSERT vs UPDATE. A rejected user re-applying
  overwrites password/contact and resets status to 'active', instead
  of the blanket 409 Conflict every other existing-username case still
  gets.

Full loop verified live: register -> reject -> login blocked (401) ->
re-register (201, overwrite) -> old password rejected -> new password
succeeds. Non-admin reject attempt -> 403. Reject on an already-approved
user -> 404 (guard holds).
RestrictedRegistrationRequest.username had zero validation — any
string passed, including empty, whitespace, or special characters.
The admin-created-user path (create/user.py) already enforces
3-63 chars, letters/digits/underscores only, via validate_username().
Reuses that same function here instead of duplicating the pattern,
so the two entry points can't drift apart.

Verified live: empty string, "bad user" (space), "bad@user",
"bad-user", and "ab" (too short) all -> 422. A valid username
("valid_user_123") -> 201, unaffected.
…ad-end

create_pending_oidc_user() previously let any UniqueViolationError
bubble up unchanged, with its docstring telling callers to recover via
get_user_by_provider_sub() — but that lookup only checks the
(auth_provider, external_sub_id) pair. If the actual collision is on
username (an OIDC preferred_username claim matching an existing local
or other-provider username), that recovery path returns None again,
and the account can never be provisioned — every attempt hits the same
silent dead end.

Scope note: deliberately NOT auto-resolving the collision (e.g. by
appending a suffix to the username and retrying). Whether a username
collision here should link the OIDC identity to the existing local
account, mint a distinct suffixed account, or reject and ask the
applicant to pick a different handle is a product decision about
identity linking vs. fragmentation — not something to guess at in code
that has zero live callers yet (no OIDC callback route exists anywhere
in this codebase to design the behavior against). Left as an explicit
TODO for whoever wires up that route.

Adds OidcUsernameCollisionError, raised only when the UniqueViolationError
is specifically on User_username_key. Any other UniqueViolationError
(including the genuine (provider, sub) collision) is re-raised
unchanged, preserving the existing documented recovery path.

Since this function has no HTTP caller to test against, added unit
tests (tests/test_oidc_username_collision.py) mocking the connection
pool directly, following the same pattern already used in
test_oauth_connection_leak.py. All 4 pass; full existing suite run
alongside shows one pre-existing, unrelated failure
(test_issue7_exception_handling.py) confirmed present before this
change too (stashed and re-ran to verify).
…ration

This branch already imports the canonical POLICY_FN_MAP correctly in
create/user.py and activate_user.py (no local duplicate to remove
here) -- just adding the warning that applies wherever this map is
defined.
…ymbol rule

Stale from today's password-policy change (shared validate_password_strength:
8 chars, 1 digit, 1 symbol; no uppercase requirement) -- these two tests
still asserted the old 12-char/uppercase rule and were failing:
  - test_too_short_raises_422 checked for "12 characters" in the error
  - test_no_uppercase_raises_422 asserted an uppercase requirement that
    no longer exists; its own test password (18 chars, has digit+symbol)
    now correctly passes validation, so the test never even raised

Renamed the latter to test_no_symbol_raises_422 to cover the new third
rule instead. test_no_digit_raises_422 and test_valid_payload_passes
needed no changes -- their fixtures already satisfy both the old and
new rule. Full file: 9/9 passing.
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.

1 participant