From cd0d9fdb66b5f81a70900f429ae394d2e9647c43 Mon Sep 17 00:00:00 2001 From: KinshukSS2 Date: Fri, 10 Apr 2026 22:54:20 +0530 Subject: [PATCH 1/2] feat(rbac): add GET /Permissions capability contract --- api/app/v1/api.py | 6 + api/app/v1/endpoints/read/permissions.py | 225 +++++++++++++++++++++++ api/tests/test_permissions_endpoint.py | 210 +++++++++++++++++++++ 3 files changed, 441 insertions(+) create mode 100644 api/app/v1/endpoints/read/permissions.py create mode 100644 api/tests/test_permissions_endpoint.py diff --git a/api/app/v1/api.py b/api/app/v1/api.py index 4efb6903..d3c3afd0 100644 --- a/api/app/v1/api.py +++ b/api/app/v1/api.py @@ -60,6 +60,7 @@ from app.v1.endpoints.read import network as read_network from app.v1.endpoints.read import observation as read_observation from app.v1.endpoints.read import observed_property as read_observed_property +from app.v1.endpoints.read import permissions as read_permissions from app.v1.endpoints.read import policy as read_policy from app.v1.endpoints.read import read from app.v1.endpoints.read import sensor as read_sensor @@ -94,6 +95,10 @@ "name": "Policies", "description": "Policies for the SensorThings API.", }, + { + "name": "Permissions", + "description": "Permission capabilities for the current authenticated user.", + }, ] else: tags_metadata = [] @@ -161,6 +166,7 @@ v1.include_router(update_user.v1) v1.include_router(delete_user.v1) v1.include_router(read_policy.v1) + v1.include_router(read_permissions.v1) v1.include_router(create_policy.v1) v1.include_router(update_policy.v1) v1.include_router(delete_policy.v1) diff --git a/api/app/v1/endpoints/read/permissions.py b/api/app/v1/endpoints/read/permissions.py new file mode 100644 index 00000000..a886f95a --- /dev/null +++ b/api/app/v1/endpoints/read/permissions.py @@ -0,0 +1,225 @@ +# Copyright 2025 SUPSI +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Iterable + +from app import AUTHORIZATION +from app.db.asyncpg_db import get_pool +from asyncpg.exceptions import InsufficientPrivilegeError +from fastapi import APIRouter, Depends, Header, status +from fastapi.responses import JSONResponse +from pydantic import BaseModel, ConfigDict + +v1 = APIRouter() + + +class _StrictBaseModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class CRUDPermission(_StrictBaseModel): + read: bool = False + create: bool = False + update: bool = False + delete: bool = False + + +class ReadPermission(_StrictBaseModel): + read: bool = False + + +class PermissionsPayload(_StrictBaseModel): + users: CRUDPermission = CRUDPermission() + policies: CRUDPermission = CRUDPermission() + things: CRUDPermission = CRUDPermission() + sensors: CRUDPermission = CRUDPermission() + observations: CRUDPermission = CRUDPermission() + datastreams: CRUDPermission = CRUDPermission() + locations: CRUDPermission = CRUDPermission() + observed_properties: CRUDPermission = CRUDPermission() + features_of_interest: CRUDPermission = CRUDPermission() + historical_locations: CRUDPermission = CRUDPermission() + audit_log: ReadPermission = ReadPermission() + perm_matrix: ReadPermission = ReadPermission() + + +class PermissionsResponse(_StrictBaseModel): + username: str + role: str + permissions: PermissionsPayload + + +user = Header(default=None, include_in_schema=False) +if AUTHORIZATION: + from app.oauth import get_current_user + + user = Depends(get_current_user) + + +_TABLE_TO_PERMISSION_KEY = { + "thing": "things", + "sensor": "sensors", + "observation": "observations", + "datastream": "datastreams", + "location": "locations", + "observedproperty": "observed_properties", + "featureofinterest": "features_of_interest", + "featuresofinterest": "features_of_interest", + "historicallocation": "historical_locations", +} + + +def _normalize_table_name(table_name: str) -> str: + return table_name.replace("_", "").replace('"', "").lower() + + +def _set_permissions_from_command(permission: CRUDPermission, command: str) -> None: + cmd = command.upper() + if cmd == "ALL": + permission.read = True + permission.create = True + permission.update = True + permission.delete = True + elif cmd == "SELECT": + permission.read = True + elif cmd == "INSERT": + permission.create = True + elif cmd == "UPDATE": + permission.update = True + elif cmd == "DELETE": + permission.delete = True + + +def _apply_admin_permissions(permissions: PermissionsPayload) -> None: + permissions.users.read = True + permissions.users.create = True + permissions.users.update = True + permissions.users.delete = True + + permissions.policies.read = True + permissions.policies.create = True + permissions.policies.update = True + permissions.policies.delete = True + + permissions.things.read = True + permissions.things.create = True + permissions.things.update = True + permissions.things.delete = True + + permissions.sensors.read = True + permissions.sensors.create = True + permissions.sensors.update = True + permissions.sensors.delete = True + + permissions.observations.read = True + permissions.observations.create = True + permissions.observations.update = True + permissions.observations.delete = True + + permissions.datastreams.read = True + permissions.datastreams.create = True + permissions.datastreams.update = True + permissions.datastreams.delete = True + + permissions.locations.read = True + permissions.locations.create = True + permissions.locations.update = True + permissions.locations.delete = True + + permissions.observed_properties.read = True + permissions.observed_properties.create = True + permissions.observed_properties.update = True + permissions.observed_properties.delete = True + + permissions.features_of_interest.read = True + permissions.features_of_interest.create = True + permissions.features_of_interest.update = True + permissions.features_of_interest.delete = True + + permissions.historical_locations.read = True + permissions.historical_locations.create = True + permissions.historical_locations.update = True + permissions.historical_locations.delete = True + + permissions.audit_log.read = True + permissions.perm_matrix.read = True + + +def _apply_policy_permissions( + permissions: PermissionsPayload, policy_rows: Iterable +) -> None: + for row in policy_rows: + table_name = row["tablename"] + command = row["cmd"] + + if table_name is None or command is None: + continue + + permission_key = _TABLE_TO_PERMISSION_KEY.get( + _normalize_table_name(str(table_name)) + ) + if permission_key is None: + continue + + permission = getattr(permissions, permission_key) + _set_permissions_from_command(permission, str(command)) + + +@v1.api_route( + "/Permissions", + methods=["GET"], + tags=["Permissions"], + summary="Get Permissions", + description="Get capability flags for the current user", + status_code=status.HTTP_200_OK, + response_model=PermissionsResponse, +) +async def get_permissions( + current_user=user, + pool=Depends(get_pool), +): + try: + permissions = PermissionsPayload() + + if current_user["role"] == "administrator": + _apply_admin_permissions(permissions) + else: + async with pool.acquire() as connection: + query = """ + SELECT tablename, cmd + FROM pg_policies + WHERE schemaname = 'sensorthings' + AND ($1 = ANY (roles) OR 'public' = ANY (roles)); + """ + policy_rows = await connection.fetch( + query, current_user["username"] + ) + + _apply_policy_permissions(permissions, policy_rows) + + return PermissionsResponse( + username=current_user["username"], + role=current_user["role"], + permissions=permissions, + ) + except InsufficientPrivilegeError: + return JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={"message": "Insufficient privileges."}, + ) + except Exception as e: + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={"message": str(e)}, + ) diff --git a/api/tests/test_permissions_endpoint.py b/api/tests/test_permissions_endpoint.py new file mode 100644 index 00000000..9b965a1a --- /dev/null +++ b/api/tests/test_permissions_endpoint.py @@ -0,0 +1,210 @@ +"""Tests for GET /Permissions capability contract.""" + +import importlib +import os +import sys +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from pydantic import ValidationError + +# Ensure api/ is on sys.path so 'app' resolves to api/app +API_DIR = str(Path(__file__).resolve().parents[1]) +if API_DIR not in sys.path: + sys.path.insert(0, API_DIR) + +# Force auth mode for this test module so /Permissions requires bearer auth +os.environ["AUTHORIZATION"] = "1" +os.environ.setdefault("SECRET_KEY", "test_secret_key_1234567890") +os.environ.setdefault("ALGORITHM", "HS256") +os.environ.setdefault("ACCESS_TOKEN_EXPIRE_MINUTES", "30") +os.environ.setdefault("POSTGRES_HOST", "localhost") +os.environ.setdefault("POSTGRES_PORT", "5432") +os.environ.setdefault("POSTGRES_DB", "istsos") +os.environ.setdefault("ISTSOS_ADMIN", "admin") +os.environ.setdefault("ISTSOS_ADMIN_PASSWORD", "secret") + +import app as app_package # noqa: E402 + +app_package = importlib.reload(app_package) + +import app.v1.endpoints.read.permissions as permissions # noqa: E402 + +permissions = importlib.reload(permissions) + + +class MockAcquire: + def __init__(self, connection): + self.connection = connection + + async def __aenter__(self): + return self.connection + + async def __aexit__(self, exc_type, exc, tb): + return False + + +class MockConnection: + def __init__(self, rows): + self.rows = rows + self.fetch = AsyncMock(return_value=rows) + + +class MockPool: + def __init__(self, rows): + self.connection = MockConnection(rows) + + def acquire(self): + return MockAcquire(self.connection) + + +def _build_test_client(rows, current_user_override=None): + test_app = FastAPI() + test_app.include_router(permissions.v1) + + pool = MockPool(rows) + + async def override_get_pool(): + return pool + + test_app.dependency_overrides[permissions.get_pool] = override_get_pool + + if current_user_override is not None: + + async def override_current_user(): + return current_user_override + + test_app.dependency_overrides[ + permissions.get_current_user + ] = override_current_user + + return TestClient(test_app), pool + + +def test_admin_gets_full_permissions(): + client, _ = _build_test_client( + rows=[], + current_user_override={"username": "admin", "role": "administrator"}, + ) + + response = client.get("/Permissions") + assert response.status_code == 200 + + payload = response.json() + assert payload["username"] == "admin" + assert payload["role"] == "administrator" + + perms = payload["permissions"] + + for resource in [ + "users", + "policies", + "things", + "sensors", + "observations", + "datastreams", + "locations", + "observed_properties", + "features_of_interest", + "historical_locations", + ]: + assert perms[resource]["read"] is True + assert perms[resource]["create"] is True + assert perms[resource]["update"] is True + assert perms[resource]["delete"] is True + + assert perms["audit_log"]["read"] is True + assert perms["perm_matrix"]["read"] is True + + +def test_non_admin_policy_mapping_and_all_command(): + rows = [ + {"tablename": "Thing", "cmd": "SELECT"}, + {"tablename": "Thing", "cmd": "INSERT"}, + {"tablename": "Observation", "cmd": "ALL"}, + {"tablename": "FeatureOfInterest", "cmd": "UPDATE"}, + ] + + client, pool = _build_test_client( + rows=rows, + current_user_override={"username": "alice", "role": "editor"}, + ) + + response = client.get("/Permissions") + assert response.status_code == 200 + + pool.connection.fetch.assert_awaited_once() + + perms = response.json()["permissions"] + + assert perms["things"] == { + "read": True, + "create": True, + "update": False, + "delete": False, + } + + assert perms["observations"] == { + "read": True, + "create": True, + "update": True, + "delete": True, + } + + assert perms["features_of_interest"] == { + "read": False, + "create": False, + "update": True, + "delete": False, + } + + assert perms["users"] == { + "read": False, + "create": False, + "update": False, + "delete": False, + } + + +def test_non_admin_with_no_policies_returns_safe_defaults(): + client, _ = _build_test_client( + rows=[], + current_user_override={"username": "viewer1", "role": "viewer"}, + ) + + response = client.get("/Permissions") + assert response.status_code == 200 + + perms = response.json()["permissions"] + + assert perms["things"] == { + "read": False, + "create": False, + "update": False, + "delete": False, + } + assert perms["audit_log"] == {"read": False} + assert perms["perm_matrix"] == {"read": False} + + +def test_permissions_endpoint_requires_auth_when_not_overridden(): + client, _ = _build_test_client(rows=[]) + + response = client.get("/Permissions") + + assert response.status_code == 401 + + +def test_response_schema_forbids_extra_fields(): + payload = { + "username": "alice", + "role": "editor", + "permissions": permissions.PermissionsPayload().model_dump(), + "unknown_key": True, + } + + with pytest.raises(ValidationError): + permissions.PermissionsResponse.model_validate(payload) From c66fd40b6268366042689086c4a88c3ecff0ee6f Mon Sep 17 00:00:00 2001 From: KinshukSS2 Date: Sat, 11 Apr 2026 00:55:43 +0530 Subject: [PATCH 2/2] fix(rbac): harden /Permissions auth handling and role lookup --- api/app/v1/endpoints/read/permissions.py | 36 ++++++++++++++++---- api/tests/test_permissions_endpoint.py | 43 ++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/api/app/v1/endpoints/read/permissions.py b/api/app/v1/endpoints/read/permissions.py index a886f95a..48274613 100644 --- a/api/app/v1/endpoints/read/permissions.py +++ b/api/app/v1/endpoints/read/permissions.py @@ -176,6 +176,21 @@ def _apply_policy_permissions( _set_permissions_from_command(permission, str(command)) +def _extract_identity(current_user): + if not isinstance(current_user, dict): + return None, None + + username = current_user.get("username") + role = current_user.get("role") + + if not isinstance(username, str) or not username: + return None, None + if not isinstance(role, str) or not role: + return None, None + + return username, role + + @v1.api_route( "/Permissions", methods=["GET"], @@ -190,9 +205,16 @@ async def get_permissions( pool=Depends(get_pool), ): try: + username, role = _extract_identity(current_user) + if username is None or role is None: + return JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={"message": "Could not validate credentials"}, + ) + permissions = PermissionsPayload() - if current_user["role"] == "administrator": + if role == "administrator": _apply_admin_permissions(permissions) else: async with pool.acquire() as connection: @@ -200,17 +222,17 @@ async def get_permissions( SELECT tablename, cmd FROM pg_policies WHERE schemaname = 'sensorthings' - AND ($1 = ANY (roles) OR 'public' = ANY (roles)); + AND ($1 = ANY (roles) + OR $2 = ANY (roles) + OR 'public' = ANY (roles)); """ - policy_rows = await connection.fetch( - query, current_user["username"] - ) + policy_rows = await connection.fetch(query, username, role) _apply_policy_permissions(permissions, policy_rows) return PermissionsResponse( - username=current_user["username"], - role=current_user["role"], + username=username, + role=role, permissions=permissions, ) except InsufficientPrivilegeError: diff --git a/api/tests/test_permissions_endpoint.py b/api/tests/test_permissions_endpoint.py index 9b965a1a..c029676f 100644 --- a/api/tests/test_permissions_endpoint.py +++ b/api/tests/test_permissions_endpoint.py @@ -36,6 +36,9 @@ permissions = importlib.reload(permissions) +_NO_OVERRIDE = object() + + class MockAcquire: def __init__(self, connection): self.connection = connection @@ -61,7 +64,7 @@ def acquire(self): return MockAcquire(self.connection) -def _build_test_client(rows, current_user_override=None): +def _build_test_client(rows, current_user_override=_NO_OVERRIDE): test_app = FastAPI() test_app.include_router(permissions.v1) @@ -72,7 +75,7 @@ async def override_get_pool(): test_app.dependency_overrides[permissions.get_pool] = override_get_pool - if current_user_override is not None: + if current_user_override is not _NO_OVERRIDE: async def override_current_user(): return current_user_override @@ -198,6 +201,42 @@ def test_permissions_endpoint_requires_auth_when_not_overridden(): assert response.status_code == 401 +def test_malformed_current_user_returns_401(): + client, _ = _build_test_client(rows=[], current_user_override={}) + + response = client.get("/Permissions") + + assert response.status_code == 401 + assert response.json() == {"message": "Could not validate credentials"} + + +def test_role_membership_is_used_for_policy_lookup(): + rows = [{"tablename": "Datastream", "cmd": "SELECT"}] + + client, pool = _build_test_client( + rows=rows, + current_user_override={"username": "alice", "role": "editor"}, + ) + + response = client.get("/Permissions") + assert response.status_code == 200 + + pool.connection.fetch.assert_awaited_once() + await_args = pool.connection.fetch.await_args + assert await_args is not None + fetch_args = await_args.args + assert fetch_args[1] == "alice" + assert fetch_args[2] == "editor" + + perms = response.json()["permissions"] + assert perms["datastreams"] == { + "read": True, + "create": False, + "update": False, + "delete": False, + } + + def test_response_schema_forbids_extra_fields(): payload = { "username": "alice",