From d47606ecbafd89ff986365a562d7618a2303a4ad Mon Sep 17 00:00:00 2001 From: Upstream Data Date: Mon, 26 Aug 2024 09:15:10 -0600 Subject: [PATCH 1/7] Change permissions to strings, and add better parsing method. Use strings for permissions as they are more extensible than enums. Less granular permissions are also available, `*` is all permissions, `software` or `software.*` gives access to all software endpoints, and `software.read` gives access to read-only software information. `*.read` can also be used to give read access to all endpoints. --- goosebit/api/v1/devices/device/routes.py | 5 +- goosebit/api/v1/devices/routes.py | 7 +-- goosebit/api/v1/rollouts/routes.py | 9 ++- goosebit/api/v1/software/routes.py | 5 +- goosebit/auth/__init__.py | 37 +++++++++--- goosebit/permissions.py | 77 ------------------------ goosebit/realtime/logs.py | 3 +- goosebit/settings/schema.py | 13 +--- goosebit/ui/bff/devices/routes.py | 3 +- goosebit/ui/bff/rollouts/routes.py | 3 +- goosebit/ui/bff/software/routes.py | 3 +- goosebit/ui/routes.py | 23 ++++--- goosebit/ui/templates/__init__.py | 10 ++- goosebit/ui/templates/nav.html.jinja | 4 +- 14 files changed, 65 insertions(+), 137 deletions(-) delete mode 100644 goosebit/permissions.py diff --git a/goosebit/api/v1/devices/device/routes.py b/goosebit/api/v1/devices/device/routes.py index 2047d652..61acd97b 100644 --- a/goosebit/api/v1/devices/device/routes.py +++ b/goosebit/api/v1/devices/device/routes.py @@ -5,7 +5,6 @@ from goosebit.api.v1.devices.device.responses import DeviceLogResponse, DeviceResponse from goosebit.auth import validate_user_permissions -from goosebit.permissions import Permissions from goosebit.updater.manager import UpdateManager, get_update_manager router = APIRouter(prefix="/{dev_id}") @@ -13,7 +12,7 @@ @router.get( "", - dependencies=[Security(validate_user_permissions, scopes=[Permissions.HOME.READ])], + dependencies=[Security(validate_user_permissions, scopes=["home.read"])], ) async def device_get(_: Request, updater: UpdateManager = Depends(get_update_manager)) -> DeviceResponse: return await DeviceResponse.convert(await updater.get_device()) @@ -21,7 +20,7 @@ async def device_get(_: Request, updater: UpdateManager = Depends(get_update_man @router.get( "/log", - dependencies=[Security(validate_user_permissions, scopes=[Permissions.HOME.READ])], + dependencies=[Security(validate_user_permissions, scopes=["home.read"])], ) async def device_logs(_: Request, updater: UpdateManager = Depends(get_update_manager)) -> DeviceLogResponse: device = await updater.get_device() diff --git a/goosebit/api/v1/devices/routes.py b/goosebit/api/v1/devices/routes.py index 3b741cfa..0ca8626f 100644 --- a/goosebit/api/v1/devices/routes.py +++ b/goosebit/api/v1/devices/routes.py @@ -6,7 +6,6 @@ from goosebit.api.responses import StatusResponse from goosebit.auth import validate_user_permissions from goosebit.models import Device, Software, UpdateModeEnum -from goosebit.permissions import Permissions from goosebit.updater.manager import delete_devices, get_update_manager from . import device @@ -18,7 +17,7 @@ @router.get( "", - dependencies=[Security(validate_user_permissions, scopes=[Permissions.HOME.READ])], + dependencies=[Security(validate_user_permissions, scopes=["home.read"])], ) async def devices_get(_: Request) -> DevicesResponse: return await DevicesResponse.convert(await Device.all().prefetch_related("assigned_software", "hardware")) @@ -26,7 +25,7 @@ async def devices_get(_: Request) -> DevicesResponse: @router.patch( "", - dependencies=[Security(validate_user_permissions, scopes=[Permissions.DEVICE.WRITE])], + dependencies=[Security(validate_user_permissions, scopes=["device.write"])], ) async def devices_patch(_: Request, config: DevicesPatchRequest) -> StatusResponse: for uuid in config.devices: @@ -52,7 +51,7 @@ async def devices_patch(_: Request, config: DevicesPatchRequest) -> StatusRespon @router.delete( "", - dependencies=[Security(validate_user_permissions, scopes=[Permissions.DEVICE.DELETE])], + dependencies=[Security(validate_user_permissions, scopes=["device.delete"])], ) async def devices_delete(_: Request, config: DevicesDeleteRequest) -> StatusResponse: await delete_devices(config.devices) diff --git a/goosebit/api/v1/rollouts/routes.py b/goosebit/api/v1/rollouts/routes.py index e4dd24cc..ccaa0c61 100644 --- a/goosebit/api/v1/rollouts/routes.py +++ b/goosebit/api/v1/rollouts/routes.py @@ -4,7 +4,6 @@ from goosebit.api.responses import StatusResponse from goosebit.auth import validate_user_permissions from goosebit.models import Rollout -from goosebit.permissions import Permissions from .requests import RolloutsDeleteRequest, RolloutsPatchRequest, RolloutsPutRequest from .responses import RolloutsPutResponse, RolloutsResponse @@ -14,7 +13,7 @@ @router.get( "", - dependencies=[Security(validate_user_permissions, scopes=[Permissions.ROLLOUT.READ])], + dependencies=[Security(validate_user_permissions, scopes=["rollout.read"])], ) async def rollouts_get(_: Request) -> RolloutsResponse: return await RolloutsResponse.convert(await Rollout.all().prefetch_related("software")) @@ -22,7 +21,7 @@ async def rollouts_get(_: Request) -> RolloutsResponse: @router.post( "", - dependencies=[Security(validate_user_permissions, scopes=[Permissions.ROLLOUT.WRITE])], + dependencies=[Security(validate_user_permissions, scopes=["rollout.write"])], ) async def rollouts_put(_: Request, rollout: RolloutsPutRequest) -> RolloutsPutResponse: rollout = await Rollout.create( @@ -35,7 +34,7 @@ async def rollouts_put(_: Request, rollout: RolloutsPutRequest) -> RolloutsPutRe @router.patch( "", - dependencies=[Security(validate_user_permissions, scopes=[Permissions.ROLLOUT.WRITE])], + dependencies=[Security(validate_user_permissions, scopes=["rollout.write"])], ) async def rollouts_patch(_: Request, rollouts: RolloutsPatchRequest) -> StatusResponse: await Rollout.filter(id__in=rollouts.ids).update(paused=rollouts.paused) @@ -44,7 +43,7 @@ async def rollouts_patch(_: Request, rollouts: RolloutsPatchRequest) -> StatusRe @router.delete( "", - dependencies=[Security(validate_user_permissions, scopes=[Permissions.ROLLOUT.DELETE])], + dependencies=[Security(validate_user_permissions, scopes=["rollout.delete"])], ) async def rollouts_delete(_: Request, rollouts: RolloutsDeleteRequest) -> StatusResponse: await Rollout.filter(id__in=rollouts.ids).delete() diff --git a/goosebit/api/v1/software/routes.py b/goosebit/api/v1/software/routes.py index 98d9bb13..6cf4df4b 100644 --- a/goosebit/api/v1/software/routes.py +++ b/goosebit/api/v1/software/routes.py @@ -4,7 +4,6 @@ from goosebit.api.responses import StatusResponse from goosebit.auth import validate_user_permissions from goosebit.models import Rollout, Software -from goosebit.permissions import Permissions from .requests import SoftwareDeleteRequest from .responses import SoftwareResponse @@ -14,7 +13,7 @@ @router.get( "", - dependencies=[Security(validate_user_permissions, scopes=[Permissions.SOFTWARE.READ])], + dependencies=[Security(validate_user_permissions, scopes=["software.read"])], ) async def software_get(_: Request) -> SoftwareResponse: return await SoftwareResponse.convert(await Software.all().prefetch_related("compatibility")) @@ -22,7 +21,7 @@ async def software_get(_: Request) -> SoftwareResponse: @router.delete( "", - dependencies=[Security(validate_user_permissions, scopes=[Permissions.SOFTWARE.DELETE])], + dependencies=[Security(validate_user_permissions, scopes=["software.delete"])], ) async def software_delete(_: Request, config: SoftwareDeleteRequest) -> StatusResponse: success = False diff --git a/goosebit/auth/__init__.py b/goosebit/auth/__init__.py index a75b5657..e64fceef 100644 --- a/goosebit/auth/__init__.py +++ b/goosebit/auth/__init__.py @@ -106,13 +106,30 @@ def validate_user_permissions( security: SecurityScopes, user: User = Depends(get_current_user), ) -> HTTPConnection: - if security.scopes is None: - return connection - for scope in security.scopes: - if scope not in user.permissions: - logger.warning(f"User {user.username} does not have permission {scope}") - raise HTTPException( - status_code=403, - detail="Not enough permissions", - headers={"WWW-Authenticate": "Bearer"}, - ) + if not compare_permissions(security.scopes, user.permissions): + logger.warning(f"{user.username} does not have sufficient permissions") + raise HTTPException( + status_code=403, + detail="Not enough permissions", + headers={"WWW-Authenticate": "Bearer"}, + ) + return connection + + +def compare_permissions(scopes: list[str] | None, permissions: set[str]) -> bool: + if scopes is None: + return True + for scope in scopes: + if not any([compare_permission(scope, permission) for permission in permissions]): + return False + return True + + +def compare_permission(scope: str, permission: str): + split_scope = scope.split(".") + for idx, permission in enumerate(permission.split(".")): + if permission == "*": + continue + if not split_scope[idx] == permission: + return False + return True diff --git a/goosebit/permissions.py b/goosebit/permissions.py deleted file mode 100644 index c11b6ae0..00000000 --- a/goosebit/permissions.py +++ /dev/null @@ -1,77 +0,0 @@ -from enum import Enum -from typing import ClassVar, TypeVar, cast - -from pydantic import BaseModel - -T = TypeVar("T", bound="PermissionsBase") - - -class PermissionsBase(str, Enum): - @classmethod - def full(cls) -> set[T]: - all_items = set[T]() - for permission in cls: - all_items.add(cast(T, permission)) - return all_items - - def __str__(self): - return self.value - - -class SoftwarePermissions(PermissionsBase): - READ = "software.read" - WRITE = "software.write" - DELETE = "software.delete" - - -class DevicePermissions(PermissionsBase): - READ = "device.read" - WRITE = "device.write" - DELETE = "device.delete" - - -class RolloutPermissions(PermissionsBase): - READ = "rollout.read" - WRITE = "rollout.write" - DELETE = "rollout.delete" - - -class HomePermissions(PermissionsBase): - READ = "home.read" - - -class Permissions(BaseModel): - HOME: ClassVar = HomePermissions - SOFTWARE: ClassVar = SoftwarePermissions - DEVICE: ClassVar = DevicePermissions - ROLLOUT: ClassVar = RolloutPermissions - - @classmethod - def full(cls) -> set[T]: - all_items = set() - for item in [cls.HOME, cls.SOFTWARE, cls.DEVICE, cls.ROLLOUT]: - all_items.update(item.full()) - return all_items - - @classmethod - def from_str(cls, permission: str) -> set[T]: - if permission == "*": - return cls.full() - area, action = permission.upper().split(".") - if area == "SOFTWARE": - return {SoftwarePermissions[action]} - if area == "DEVICE": - return {DevicePermissions[action]} - if area == "ROLLOUT": - return {RolloutPermissions[action]} - if area == "HOME": - return {HomePermissions[action]} - - -ADMIN = Permissions.full() -MONITORING = [ - *Permissions.HOME.full(), - *Permissions.SOFTWARE.full(), - *Permissions.DEVICE.full(), -] -READONLY = [Permissions.HOME.READ] diff --git a/goosebit/realtime/logs.py b/goosebit/realtime/logs.py index a888e205..8ca42d8b 100644 --- a/goosebit/realtime/logs.py +++ b/goosebit/realtime/logs.py @@ -6,7 +6,6 @@ from websockets.exceptions import ConnectionClosed from goosebit.auth import validate_user_permissions -from goosebit.permissions import Permissions from goosebit.updater.manager import get_update_manager router = APIRouter(prefix="/logs") @@ -20,7 +19,7 @@ class RealtimeLogModel(BaseModel): @router.websocket( "/{dev_id}", - dependencies=[Security(validate_user_permissions, scopes=[Permissions.HOME.READ])], + dependencies=[Security(validate_user_permissions, scopes=["home.read"])], ) async def device_logs(websocket: WebSocket, dev_id: str): await websocket.accept() diff --git a/goosebit/settings/schema.py b/goosebit/settings/schema.py index 7581bf34..09bcc0c2 100644 --- a/goosebit/settings/schema.py +++ b/goosebit/settings/schema.py @@ -1,6 +1,6 @@ import secrets from pathlib import Path -from typing import Annotated, Iterable +from typing import Annotated from joserfc.rfc7518.oct_key import OctKey from pydantic import BaseModel, BeforeValidator, Field @@ -11,22 +11,13 @@ YamlConfigSettingsSource, ) -from goosebit.permissions import Permissions, PermissionsBase - from .const import BASE_DIR, LOGGING_DEFAULT, PWD_CXT -def parse_permissions(items: Iterable[str]): - permissions = set() - for p in items: - permissions.update(Permissions.from_str(p)) - return permissions - - class User(BaseModel): username: str hashed_pwd: Annotated[str, BeforeValidator(PWD_CXT.hash)] = Field(validation_alias="password") - permissions: Annotated[set[PermissionsBase], BeforeValidator(parse_permissions)] + permissions: set[str] def get_json_permissions(self): return [str(p) for p in self.permissions] diff --git a/goosebit/ui/bff/devices/routes.py b/goosebit/ui/bff/devices/routes.py index 4415dc1c..e713eb49 100644 --- a/goosebit/ui/bff/devices/routes.py +++ b/goosebit/ui/bff/devices/routes.py @@ -6,7 +6,6 @@ from goosebit.auth import validate_user_permissions from goosebit.models import Device, UpdateModeEnum, UpdateStateEnum -from goosebit.permissions import Permissions from .responses import BFFDeviceResponse @@ -15,7 +14,7 @@ @router.get( "", - dependencies=[Security(validate_user_permissions, scopes=[Permissions.HOME.READ])], + dependencies=[Security(validate_user_permissions, scopes=["home.read"])], ) async def devices_get(request: Request) -> BFFDeviceResponse: def search_filter(search_value): diff --git a/goosebit/ui/bff/rollouts/routes.py b/goosebit/ui/bff/rollouts/routes.py index 9f70cc2e..759409f7 100644 --- a/goosebit/ui/bff/rollouts/routes.py +++ b/goosebit/ui/bff/rollouts/routes.py @@ -4,7 +4,6 @@ from goosebit.auth import validate_user_permissions from goosebit.models import Rollout -from goosebit.permissions import Permissions from .responses import BFFRolloutsResponse @@ -13,7 +12,7 @@ @router.get( "", - dependencies=[Security(validate_user_permissions, scopes=[Permissions.ROLLOUT.READ])], + dependencies=[Security(validate_user_permissions, scopes=["rollout.read"])], ) async def rollouts_get(request: Request) -> BFFRolloutsResponse: def search_filter(search_value): diff --git a/goosebit/ui/bff/software/routes.py b/goosebit/ui/bff/software/routes.py index 87b663cd..7b37ec15 100644 --- a/goosebit/ui/bff/software/routes.py +++ b/goosebit/ui/bff/software/routes.py @@ -6,7 +6,6 @@ from goosebit.auth import validate_user_permissions from goosebit.models import Software -from goosebit.permissions import Permissions from goosebit.ui.bff.software.responses import BFFSoftwareResponse router = APIRouter(prefix="/software") @@ -14,7 +13,7 @@ @router.get( "", - dependencies=[Security(validate_user_permissions, scopes=[Permissions.SOFTWARE.READ])], + dependencies=[Security(validate_user_permissions, scopes=["software.read"])], ) async def software_get(request: Request) -> BFFSoftwareResponse: def search_filter(search_value): diff --git a/goosebit/ui/routes.py b/goosebit/ui/routes.py index 55d88ddf..1ce95444 100644 --- a/goosebit/ui/routes.py +++ b/goosebit/ui/routes.py @@ -6,7 +6,6 @@ from goosebit.auth import redirect_if_unauthenticated, validate_user_permissions from goosebit.models import Rollout, Software -from goosebit.permissions import Permissions from goosebit.settings import config from goosebit.ui.nav import nav from goosebit.updates import create_software_update @@ -27,34 +26,34 @@ async def ui_root(request: Request): @router.get( "/home", - dependencies=[Security(validate_user_permissions, scopes=[Permissions.HOME.READ])], + dependencies=[Security(validate_user_permissions, scopes=["home.read"])], ) -@nav.route("Home", permissions=Permissions.HOME.READ) +@nav.route("Home", permissions=["home.read"]) async def home_ui(request: Request): return templates.TemplateResponse(request, "index.html.jinja", context={"title": "Home"}) @router.get( "/devices", - dependencies=[Security(validate_user_permissions, scopes=[Permissions.DEVICE.READ])], + dependencies=[Security(validate_user_permissions, scopes=["device.read"])], ) -@nav.route("Devices", permissions=Permissions.DEVICE.READ) +@nav.route("Devices", permissions=["device.read"]) async def devices_ui(request: Request): return templates.TemplateResponse(request, "devices.html.jinja", context={"title": "Devices"}) @router.get( "/software", - dependencies=[Security(validate_user_permissions, scopes=[Permissions.SOFTWARE.READ])], + dependencies=[Security(validate_user_permissions, scopes=["software.read"])], ) -@nav.route("Software", permissions=Permissions.SOFTWARE.READ) +@nav.route("Software", permissions=["software.read"]) async def software_ui(request: Request): return templates.TemplateResponse(request, "software.html.jinja", context={"title": "Software"}) @router.post( "/upload/local", - dependencies=[Security(validate_user_permissions, scopes=[Permissions.SOFTWARE.WRITE])], + dependencies=[Security(validate_user_permissions, scopes=["software.write"])], ) async def upload_update_local( request: Request, @@ -83,7 +82,7 @@ async def upload_update_local( @router.post( "/upload/remote", - dependencies=[Security(validate_user_permissions, scopes=[Permissions.SOFTWARE.WRITE])], + dependencies=[Security(validate_user_permissions, scopes=["software.write"])], ) async def upload_update_remote(request: Request, url: str = Form(...)): software = await Software.get_or_none(uri=url) @@ -99,16 +98,16 @@ async def upload_update_remote(request: Request, url: str = Form(...)): @router.get( "/rollouts", - dependencies=[Security(validate_user_permissions, scopes=[Permissions.ROLLOUT.READ])], + dependencies=[Security(validate_user_permissions, scopes=["rollout.read"])], ) -@nav.route("Rollouts", permissions=Permissions.ROLLOUT.READ) +@nav.route("Rollouts", permissions=["rollout.read"]) async def rollouts_ui(request: Request): return templates.TemplateResponse(request, "rollouts.html.jinja", context={"title": "Rollouts"}) @router.get( "/logs/{dev_id}", - dependencies=[Security(validate_user_permissions, scopes=[Permissions.DEVICE.READ])], + dependencies=[Security(validate_user_permissions, scopes=["device.read"])], ) async def logs_ui(request: Request, dev_id: str): return templates.TemplateResponse(request, "logs.html.jinja", context={"title": "Log", "device": dev_id}) diff --git a/goosebit/ui/templates/__init__.py b/goosebit/ui/templates/__init__.py index e127b6c5..8efb777f 100644 --- a/goosebit/ui/templates/__init__.py +++ b/goosebit/ui/templates/__init__.py @@ -1,5 +1,13 @@ from pathlib import Path +from fastapi.requests import Request from fastapi.templating import Jinja2Templates -templates = Jinja2Templates(str(Path(__file__).resolve().parent)) +from goosebit.auth import compare_permissions + + +def attach_permissions_comparison(_: Request): + return {"compare_permissions": compare_permissions} + + +templates = Jinja2Templates(str(Path(__file__).resolve().parent), context_processors=[attach_permissions_comparison]) diff --git a/goosebit/ui/templates/nav.html.jinja b/goosebit/ui/templates/nav.html.jinja index 11224a0b..0c02e3e9 100644 --- a/goosebit/ui/templates/nav.html.jinja +++ b/goosebit/ui/templates/nav.html.jinja @@ -66,9 +66,7 @@ -