diff --git a/conftest.py b/conftest.py index f2adf8e8..b1ba42b0 100644 --- a/conftest.py +++ b/conftest.py @@ -10,7 +10,9 @@ from tortoise.contrib.fastapi import RegisterTortoise from goosebit import app +from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS from goosebit.db.models import UpdateModeEnum, UpdateStateEnum +from goosebit.settings import PWD_CXT # Configure logging logging.basicConfig(level=logging.WARN) @@ -33,10 +35,14 @@ async def clear_cache(): @pytest_asyncio.fixture(scope="function") async def test_app(): + from goosebit.users import create_initial_user + async with RegisterTortoise( app=app, config=TORTOISE_CONF, ): + await Tortoise.generate_schemas() + await create_initial_user(username="testing@goosebit.test", hashed_pwd=PWD_CXT.hash("test")) yield app @@ -45,7 +51,7 @@ async def async_client(test_app): async with AsyncClient( transport=ASGITransport(app=test_app), base_url="http://test", follow_redirects=True ) as client: - login_data = {"username": "admin@goosebit.local", "password": "admin"} + login_data = {"username": "testing@goosebit.test", "password": "test"} response = await client.post("/login", data=login_data, follow_redirects=True) assert response.status_code == 200 @@ -66,7 +72,7 @@ async def db(): @pytest_asyncio.fixture(scope="function") async def test_data(db): - from goosebit.db.models import Device, Hardware, Rollout, Software + from goosebit.db.models import Device, Hardware, Rollout, Software, User # Create a temporary directory with tempfile.TemporaryDirectory() as temp_dir: @@ -119,6 +125,22 @@ async def test_data(db): auth_token="auth_token1", ) + user_admin = await User.create( + username="admin@goosebit.test", + hashed_pwd=PWD_CXT.hash("testadmin"), + permissions=[GOOSEBIT_PERMISSIONS()], + ) + + user_read_only = await User.create( + username="read_only@goosebit.test", + hashed_pwd=PWD_CXT.hash("testread"), + permissions=[ + GOOSEBIT_PERMISSIONS["device"]["read"](), + GOOSEBIT_PERMISSIONS["software"]["read"](), + GOOSEBIT_PERMISSIONS["rollout"]["read"](), + ], + ) + yield dict( hardware=hardware, software_release=software_release, @@ -129,4 +151,6 @@ async def test_data(db): device_assigned=device_assigned, device_authentication=device_assigned, device_no_authentication=device_rollout, + user_admin=user_admin, + user_read_only=user_read_only, ) diff --git a/docker/demo/docker-compose.yml b/docker/demo/docker-compose.yml index f34f6f3e..b60bcd4a 100644 --- a/docker/demo/docker-compose.yml +++ b/docker/demo/docker-compose.yml @@ -20,11 +20,8 @@ services: image: upstreamdata/goosebit environment: GOOSEBIT_DB_URI: postgres://db_user:db_pw@postgres:5432/goosebit - GOOSEBIT_USERS: |- - [ - {"username": "admin@goosebit.local", "password": "admin", "permissions": ["*"]}, - {"username": "ci", "password": "foo", "permissions": ["software.read", "software.write", "rollout.read", "rollout.write"]} - ] + GOOSEBIT_INITAL_USER: |- + {"username": "admin@goosebit.local", "password": "admin"} GOOSEBIT_SECRET_KEY: 1a7090da4fbe9c72f888a4772302eac3 GOOSEBIT_ARTIFACTS_DIR: /artifacts FORWARDED_ALLOW_IPS: "*" diff --git a/goosebit.yaml b/goosebit.yaml index cac930a2..d017677b 100644 --- a/goosebit.yaml +++ b/goosebit.yaml @@ -35,20 +35,6 @@ poll_time: 00:01:00 # Defaults to a randomized value. If this value is not set, user sessions will not persist when app restarts. #secret_key: my_very_top_secret_key123 -# User account for the frontend. Available permissions: -# "software.read", "software.write", "software.delete" -# "device.read", "device.write", "device.delete" -# "rollout.read", "rollout.write", "rollout.delete" -users: - - username: admin@goosebit.local - password: admin - permissions: - - "*" - - username: ops@goosebit.local - password: ops - permissions: - - "device.read" - ## Internal settings that usually don't need to be modified metrics: prometheus: diff --git a/goosebit/__init__.py b/goosebit/__init__.py index ccfeff7b..45d6a7db 100644 --- a/goosebit/__init__.py +++ b/goosebit/__init__.py @@ -14,12 +14,12 @@ from tortoise.exceptions import ValidationError from goosebit import api, db, ui, updater -from goosebit.api.telemetry import metrics from goosebit.auth import get_user_from_request, login_user, redirect_if_authenticated -from goosebit.settings import config +from goosebit.settings import PWD_CXT, config from goosebit.ui.nav import nav from goosebit.ui.static import static from goosebit.ui.templates import templates +from goosebit.users import create_initial_user logger = getLogger(__name__) @@ -29,7 +29,6 @@ async def lifespan(_: FastAPI): db_ready = await db.init() if not db_ready: logger.exception("DB does not exist, try running `poetry run aerich upgrade`.") - await metrics.init() if db_ready: yield await db.close() @@ -104,7 +103,18 @@ async def login_get(request: Request): @app.post("/login", tags=["login"]) async def login_post(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]): - return {"access_token": login_user(form_data.username, form_data.password), "token_type": "bearer"} + return {"access_token": await login_user(form_data.username, form_data.password), "token_type": "bearer"} + + +@app.get("/setup", include_in_schema=False) +async def setup_get(request: Request): + return templates.TemplateResponse(request, "setup.html.jinja") + + +@app.post("/setup", include_in_schema=False) +async def setup_post(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]): + await create_initial_user(form_data.username, PWD_CXT.hash(form_data.password)) + return {"access_token": await login_user(form_data.username, form_data.password), "token_type": "bearer"} @app.get("/logout", include_in_schema=False) diff --git a/goosebit/api/telemetry/metrics.py b/goosebit/api/telemetry/metrics.py index d5541b76..4925f76b 100644 --- a/goosebit/api/telemetry/metrics.py +++ b/goosebit/api/telemetry/metrics.py @@ -2,7 +2,7 @@ from opentelemetry.sdk.metrics import MeterProvider from opentelemetry.sdk.resources import SERVICE_NAME, Resource -from goosebit.settings import USERS, config +from goosebit.settings import config from . import prometheus @@ -28,7 +28,3 @@ "users.count", description="The number of registered users", ) - - -async def init(): - users_count.set(len(USERS)) diff --git a/goosebit/api/v1/devices/device/routes.py b/goosebit/api/v1/devices/device/routes.py index b0cc4c46..c5e8e7ef 100644 --- a/goosebit/api/v1/devices/device/routes.py +++ b/goosebit/api/v1/devices/device/routes.py @@ -5,6 +5,7 @@ from goosebit.api.v1.devices.device.responses import DeviceLogResponse, DeviceResponse from goosebit.auth import validate_user_permissions +from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS from goosebit.db import Device from goosebit.device_manager import get_device @@ -13,7 +14,7 @@ @router.get( "", - dependencies=[Security(validate_user_permissions, scopes=["device.read"])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()])], ) async def device_get(_: Request, device: Device = Depends(get_device)) -> DeviceResponse: if device is None: @@ -24,7 +25,7 @@ async def device_get(_: Request, device: Device = Depends(get_device)) -> Device @router.get( "/log", - dependencies=[Security(validate_user_permissions, scopes=["device.read"])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()])], ) async def device_logs(_: Request, device: Device = Depends(get_device)) -> DeviceLogResponse: if device is None: diff --git a/goosebit/api/v1/devices/routes.py b/goosebit/api/v1/devices/routes.py index 38d17077..209056db 100644 --- a/goosebit/api/v1/devices/routes.py +++ b/goosebit/api/v1/devices/routes.py @@ -8,6 +8,7 @@ from goosebit.api.responses import StatusResponse from goosebit.auth import validate_user_permissions +from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS from goosebit.db.models import Device, Software, UpdateModeEnum from goosebit.device_manager import DeviceManager, get_device from goosebit.schema.devices import DeviceSchema @@ -22,7 +23,7 @@ @router.get( "", - dependencies=[Security(validate_user_permissions, scopes=["device.read"])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()])], ) async def devices_get(_: Request) -> DevicesResponse: devices = await Device.all().prefetch_related("hardware", "assigned_software", "assigned_software__compatibility") @@ -42,7 +43,7 @@ async def set_assigned_sw(d: DeviceSchema): @router.delete( "", - dependencies=[Security(validate_user_permissions, scopes=["device.delete"])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["delete"]()])], ) async def devices_delete(_: Request, config: DevicesDeleteRequest) -> StatusResponse: await DeviceManager.delete_devices(config.devices) @@ -51,7 +52,7 @@ async def devices_delete(_: Request, config: DevicesDeleteRequest) -> StatusResp @router.patch( "", - dependencies=[Security(validate_user_permissions, scopes=["device.write"])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["write"]()])], ) async def devices_patch(_: Request, config: DevicesPatchRequest) -> StatusResponse: for dev_id in config.devices: @@ -81,7 +82,7 @@ async def devices_patch(_: Request, config: DevicesPatchRequest) -> StatusRespon @router.put( "", - dependencies=[Security(validate_user_permissions, scopes=["device.write"])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["write"]()])], ) async def devices_put(_: Request, config: DevicesPutRequest) -> StatusResponse: for dev_id in config.devices: diff --git a/goosebit/api/v1/rollouts/routes.py b/goosebit/api/v1/rollouts/routes.py index 6486a218..17f14a1c 100644 --- a/goosebit/api/v1/rollouts/routes.py +++ b/goosebit/api/v1/rollouts/routes.py @@ -3,6 +3,7 @@ from goosebit.api.responses import StatusResponse from goosebit.auth import validate_user_permissions +from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS from goosebit.db.models import Rollout, Software from .requests import RolloutsDeleteRequest, RolloutsPatchRequest, RolloutsPutRequest @@ -13,7 +14,7 @@ @router.get( "", - dependencies=[Security(validate_user_permissions, scopes=["rollout.read"])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["rollout"]["read"]()])], ) async def rollouts_get(_: Request) -> RolloutsResponse: rollouts = await Rollout.all().prefetch_related("software", "software__compatibility") @@ -22,7 +23,7 @@ async def rollouts_get(_: Request) -> RolloutsResponse: @router.post( "", - dependencies=[Security(validate_user_permissions, scopes=["rollout.write"])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["rollout"]["write"]()])], ) async def rollouts_put(_: Request, rollout: RolloutsPutRequest) -> RolloutsPutResponse: software = await Software.filter(id=rollout.software_id) @@ -38,7 +39,7 @@ async def rollouts_put(_: Request, rollout: RolloutsPutRequest) -> RolloutsPutRe @router.patch( "", - dependencies=[Security(validate_user_permissions, scopes=["rollout.write"])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["rollout"]["write"]()])], ) async def rollouts_patch(_: Request, rollouts: RolloutsPatchRequest) -> StatusResponse: await Rollout.filter(id__in=rollouts.ids).update(paused=rollouts.paused) @@ -47,7 +48,7 @@ async def rollouts_patch(_: Request, rollouts: RolloutsPatchRequest) -> StatusRe @router.delete( "", - dependencies=[Security(validate_user_permissions, scopes=["rollout.delete"])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["rollout"]["delete"]()])], ) async def rollouts_delete(_: Request, rollouts: RolloutsDeleteRequest) -> StatusResponse: await Rollout.filter(id__in=rollouts.ids).delete() diff --git a/goosebit/api/v1/routes.py b/goosebit/api/v1/routes.py index 7c32bba9..90bfad1d 100644 --- a/goosebit/api/v1/routes.py +++ b/goosebit/api/v1/routes.py @@ -1,9 +1,10 @@ from fastapi import APIRouter -from . import devices, download, rollouts, software +from . import devices, download, rollouts, settings, software router = APIRouter(prefix="/v1") router.include_router(software.router) router.include_router(devices.router) router.include_router(rollouts.router) router.include_router(download.router) +router.include_router(settings.router) diff --git a/goosebit/api/v1/settings/__init__.py b/goosebit/api/v1/settings/__init__.py new file mode 100644 index 00000000..2c102012 --- /dev/null +++ b/goosebit/api/v1/settings/__init__.py @@ -0,0 +1 @@ +from .routes import router # noqa: F401 diff --git a/goosebit/api/v1/settings/routes.py b/goosebit/api/v1/settings/routes.py new file mode 100644 index 00000000..00decedf --- /dev/null +++ b/goosebit/api/v1/settings/routes.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter + +from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS, Permission + +from . import users + +router = APIRouter(prefix="/settings", tags=["settings"]) + +router.include_router(users.router) + + +@router.get("/permissions", response_model_exclude_none=True) +async def settings_permissions_get() -> Permission: + return GOOSEBIT_PERMISSIONS diff --git a/goosebit/api/v1/settings/users/__init__.py b/goosebit/api/v1/settings/users/__init__.py new file mode 100644 index 00000000..2c102012 --- /dev/null +++ b/goosebit/api/v1/settings/users/__init__.py @@ -0,0 +1 @@ +from .routes import router # noqa: F401 diff --git a/goosebit/api/v1/settings/users/requests.py b/goosebit/api/v1/settings/users/requests.py new file mode 100644 index 00000000..4421dea9 --- /dev/null +++ b/goosebit/api/v1/settings/users/requests.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel + + +class UsersPutRequest(BaseModel): + username: str + password: str + permissions: list[str] + + +class UsersPatchRequest(BaseModel): + usernames: list[str] + enabled: bool + + +class UsersDeleteRequest(BaseModel): + usernames: list[str] diff --git a/goosebit/api/v1/settings/users/responses.py b/goosebit/api/v1/settings/users/responses.py new file mode 100644 index 00000000..686f47a9 --- /dev/null +++ b/goosebit/api/v1/settings/users/responses.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + +from goosebit.schema.users import UserSchema + + +class SettingsUsersResponse(BaseModel): + users: list[UserSchema] diff --git a/goosebit/api/v1/settings/users/routes.py b/goosebit/api/v1/settings/users/routes.py new file mode 100644 index 00000000..4038efee --- /dev/null +++ b/goosebit/api/v1/settings/users/routes.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Security +from fastapi.requests import Request + +from goosebit.api.responses import StatusResponse +from goosebit.auth import validate_user_permissions +from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS +from goosebit.db.models import User +from goosebit.users import UserManager, create_user + +from .requests import UsersDeleteRequest, UsersPatchRequest, UsersPutRequest +from .responses import SettingsUsersResponse + +router = APIRouter(prefix="/users") + + +@router.get( + "", + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["settings"]["users"]["read"]()])], +) +async def settings_users_get(_: Request) -> SettingsUsersResponse: + users = await User.all() + return SettingsUsersResponse(users=users) + + +@router.post( + "", + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["settings"]["users"]["write"]()])], +) +async def settings_users_put(_: Request, user: UsersPutRequest) -> StatusResponse: + await create_user(username=user.username, password=user.password, permissions=user.permissions) + return StatusResponse(success=True) + + +@router.delete( + "", + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["settings"]["users"]["delete"]()])], +) +async def settings_users_delete(_: Request, config: UsersDeleteRequest) -> StatusResponse: + await UserManager.delete_users(config.usernames) + return StatusResponse(success=True) + + +@router.patch( + "", + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["settings"]["users"]["delete"]()])], +) +async def settings_users_patch(_: Request, config: UsersPatchRequest) -> StatusResponse: + for username in config.usernames: + if await User.get_or_none(username=username) is None: + raise HTTPException(404, f"User with username {username} not found") + + user = await UserManager.get_user(username) + await UserManager.update_enabled(user, config.enabled) + return StatusResponse(success=True) diff --git a/goosebit/api/v1/software/routes.py b/goosebit/api/v1/software/routes.py index c15c2239..bc61683e 100644 --- a/goosebit/api/v1/software/routes.py +++ b/goosebit/api/v1/software/routes.py @@ -9,6 +9,7 @@ from goosebit.api.responses import StatusResponse from goosebit.auth import validate_user_permissions +from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS from goosebit.db.models import Rollout, Software from goosebit.settings import config from goosebit.updates import create_software_update @@ -21,7 +22,7 @@ @router.get( "", - dependencies=[Security(validate_user_permissions, scopes=["software.read"])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["software"]["read"]()])], ) async def software_get(_: Request) -> SoftwareResponse: software = await Software.all().prefetch_related("compatibility") @@ -30,7 +31,7 @@ async def software_get(_: Request) -> SoftwareResponse: @router.delete( "", - dependencies=[Security(validate_user_permissions, scopes=["software.delete"])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["software"]["delete"]()])], ) async def software_delete(_: Request, delete_req: SoftwareDeleteRequest) -> StatusResponse: success = False @@ -56,7 +57,7 @@ async def software_delete(_: Request, delete_req: SoftwareDeleteRequest) -> Stat @router.post( "", - dependencies=[Security(validate_user_permissions, scopes=["software.write"])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["software"]["write"]()])], ) async def post_update(_: Request, file: UploadFile | None = File(None), url: str | None = Form(None)): if url is not None: diff --git a/goosebit/auth/__init__.py b/goosebit/auth/__init__.py index 29d43ac8..243b760b 100644 --- a/goosebit/auth/__init__.py +++ b/goosebit/auth/__init__.py @@ -10,8 +10,9 @@ from joserfc import jwt from joserfc.errors import BadSignatureError -from goosebit.settings import PWD_CXT, USERS, config -from goosebit.settings.schema import User +from goosebit.db.models import User +from goosebit.settings import PWD_CXT, config +from goosebit.users import UserManager logger = logging.getLogger(__name__) @@ -31,25 +32,31 @@ def create_token(username: str) -> str: return jwt.encode(header={"alg": "HS256"}, claims={"username": username}, key=config.secret_key) -def get_user_from_token(token: str | None) -> User | None: +async def get_user_from_token(token: str | None) -> User | None: if token is None: return None try: token_data = jwt.decode(token, config.secret_key) username = token_data.claims["username"] - return USERS.get(username) + return await UserManager.get_user(username) except (BadSignatureError, LookupError, ValueError): return None -def login_user(username: str, password: str) -> str: - user = USERS.get(username) +async def login_user(username: str, password: str) -> str: + user = await UserManager.get_user(username) if user is None: raise HTTPException( status_code=401, detail="Invalid username or password", headers={"WWW-Authenticate": "Bearer"}, ) + if not user.enabled: + raise HTTPException( + status_code=401, + detail="User has been disabled, please contact your administrator", + headers={"WWW-Authenticate": "Bearer"}, + ) try: PWD_CXT.verify(user.hashed_pwd, password) except VerifyMismatchError: @@ -61,12 +68,12 @@ def login_user(username: str, password: str) -> str: return create_token(user.username) -def get_current_user( +async def get_current_user( session_token: Annotated[str | None, Depends(session_auth)] = None, oauth2_token: Annotated[str | None, Depends(oauth2_auth)] = None, ) -> User | None: - session_user = get_user_from_token(session_token) - oauth2_user = get_user_from_token(oauth2_token) + session_user = await get_user_from_token(session_token) + oauth2_user = await get_user_from_token(oauth2_token) user = session_user or oauth2_user return user @@ -74,34 +81,63 @@ def get_current_user( # using | Request because oauth2_auth.__call__ expects is async def get_user_from_request(connection: HTTPConnection | Request) -> User | None: token = await session_auth(connection) or await oauth2_auth(connection) - return get_user_from_token(token) + return await get_user_from_token(token) -def redirect_if_unauthenticated(connection: HTTPConnection, user: Annotated[User, Depends(get_current_user)]): +async def redirect_if_unauthenticated(connection: HTTPConnection, user: Annotated[User, Depends(get_current_user)]): if user is None: raise HTTPException( status_code=302, headers={"location": str(connection.url_for("login_get"))}, detail="Invalid username", ) + if not user.enabled: + raise HTTPException( + status_code=302, + headers={"location": str(connection.url_for("login_get"))}, + detail="Disabled user", + ) -def redirect_if_authenticated(connection: HTTPConnection, user: Annotated[User, Depends(get_current_user)]): +async def redirect_if_authenticated(connection: HTTPConnection, user: Annotated[User, Depends(get_current_user)]): if user is not None: + if not user.enabled: + return raise HTTPException( status_code=302, headers={"location": str(connection.url_for("ui_root"))}, detail="Already logged in", ) + if await User.all().count() == 0: + raise HTTPException( + status_code=302, + headers={"location": str(connection.url_for("setup_get"))}, + detail="No users set up", + ) -def validate_current_user(user: Annotated[User, Depends(get_current_user)]): +async def redirect_if_users_exist(connection: HTTPConnection): + if await User.all().count() > 0: + raise HTTPException( + status_code=302, + headers={"location": str(connection.url_for("login_get"))}, + detail="An admin user already exists", + ) + + +async def validate_current_user(user: Annotated[User, Depends(get_current_user)]): if user is None: raise HTTPException( status_code=401, detail="Not authenticated", headers={"WWW-Authenticate": "Bearer"}, ) + if not user.enabled: + raise HTTPException( + status_code=401, + detail="Disabled user", + headers={"WWW-Authenticate": "Bearer"}, + ) return user diff --git a/goosebit/auth/permissions.py b/goosebit/auth/permissions.py new file mode 100644 index 00000000..058c3219 --- /dev/null +++ b/goosebit/auth/permissions.py @@ -0,0 +1,80 @@ +from typing import Optional + +from pydantic import BaseModel, Field, computed_field + + +class Permission(BaseModel): + def model_post_init(self, ctx): + if self.sub_permissions is None: + return + for permission in self.sub_permissions: + permission.parent_permission = self + + def __call__(self, *args, **kwargs) -> str: + if self.parent_permission is None: + return self.name + return ".".join([self.parent_permission(), self.name]) + + def __getitem__(self, item): + return self.sub_permissions_by_name[item] + + @property + def sub_permissions_by_name(self) -> dict[str, "Permission"]: + if self.sub_permissions is None: + return {} + return {item.name: item for item in self.sub_permissions} + + @computed_field # type: ignore[misc] + @property + def value(self) -> str: + return self() + + @computed_field # type: ignore[misc] + @property + def parent(self) -> str | None: + if self.parent_permission is not None: + return self.parent_permission() + return None + + name: str + description: str + + parent_permission: Optional["Permission"] = Field(exclude=True, default=None) + sub_permissions: list["Permission"] | None = None + + +READ_PERMISSION = Permission(name="read", description="Read access") +WRITE_PERMISSION = Permission(name="write", description="Write access") +DELETE_PERMISSION = Permission(name="delete", description="Delete access") + +DEVICE_PERMISSIONS = Permission( + name="device", + description="Access to devices", + sub_permissions=[READ_PERMISSION.model_copy(), WRITE_PERMISSION.model_copy(), DELETE_PERMISSION.model_copy()], +) +SOFTWARE_PERMISSIONS = Permission( + name="software", + description="Access to software", + sub_permissions=[READ_PERMISSION.model_copy(), WRITE_PERMISSION.model_copy(), DELETE_PERMISSION.model_copy()], +) +ROLLOUT_PERMISSIONS = Permission( + name="rollout", + description="Access to rollouts", + sub_permissions=[READ_PERMISSION.model_copy(), WRITE_PERMISSION.model_copy(), DELETE_PERMISSION.model_copy()], +) +SETTINGS_USERS_PERMISSIONS = Permission( + name="users", + description="Access to user control", + sub_permissions=[READ_PERMISSION.model_copy(), WRITE_PERMISSION.model_copy(), DELETE_PERMISSION.model_copy()], +) +SETTING_PERMISSIONS = Permission( + name="settings", + description="Access to settings", + sub_permissions=[SETTINGS_USERS_PERMISSIONS], +) + +GOOSEBIT_PERMISSIONS = Permission( + name="goosebit", + description="Full access to GooseBit", + sub_permissions=[DEVICE_PERMISSIONS, SOFTWARE_PERMISSIONS, ROLLOUT_PERMISSIONS, SETTING_PERMISSIONS], +) diff --git a/goosebit/db/migrations/models/4_20250324110331_update.py b/goosebit/db/migrations/models/4_20250324110331_update.py new file mode 100644 index 00000000..0be828a0 --- /dev/null +++ b/goosebit/db/migrations/models/4_20250324110331_update.py @@ -0,0 +1,16 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + CREATE TABLE IF NOT EXISTS "user" ( + "username" VARCHAR(255) NOT NULL PRIMARY KEY, + "hashed_pwd" VARCHAR(255) NOT NULL, + "permissions" JSON NOT NULL, + "enabled" INT NOT NULL DEFAULT 1 +);""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP TABLE IF EXISTS "user";""" diff --git a/goosebit/db/models.py b/goosebit/db/models.py index a4c501b6..94358502 100644 --- a/goosebit/db/models.py +++ b/goosebit/db/models.py @@ -160,3 +160,10 @@ def path_user(self) -> str: @property def parsed_version(self) -> Version: return Version.parse(self.version) + + +class User(Model): + username = fields.CharField(max_length=255, primary_key=True, null=False) + hashed_pwd = fields.CharField(max_length=255, null=False) + permissions: fields.JSONField[list[str]] = fields.JSONField(default=[]) + enabled = fields.BooleanField(default=True) diff --git a/goosebit/schema/users.py b/goosebit/schema/users.py new file mode 100644 index 00000000..9249544a --- /dev/null +++ b/goosebit/schema/users.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel, ConfigDict + + +class UserSchema(BaseModel): + model_config = ConfigDict(from_attributes=True) + + username: str + enabled: bool + permissions: list[str] diff --git a/goosebit/settings/__init__.py b/goosebit/settings/__init__.py index f2739a50..af9d2ee4 100644 --- a/goosebit/settings/__init__.py +++ b/goosebit/settings/__init__.py @@ -12,6 +12,3 @@ if config.config_file is not None: logger.info(f"Loading settings from: {config.config_file}") - - -USERS = {u.username: u for u in config.users} diff --git a/goosebit/settings/schema.py b/goosebit/settings/schema.py index 8f69a2f0..303f306f 100644 --- a/goosebit/settings/schema.py +++ b/goosebit/settings/schema.py @@ -5,7 +5,7 @@ from typing import Annotated from joserfc.rfc7518.oct_key import OctKey -from pydantic import BaseModel, BeforeValidator, Field +from pydantic import BaseModel, BeforeValidator from pydantic_settings import ( BaseSettings, PydanticBaseSettingsSource, @@ -13,16 +13,7 @@ YamlConfigSettingsSource, ) -from .const import CURRENT_DIR, GOOSEBIT_ROOT_DIR, LOGGING_DEFAULT, PWD_CXT - - -class User(BaseModel): - username: str - hashed_pwd: Annotated[str, BeforeValidator(PWD_CXT.hash)] = Field(validation_alias="password") - permissions: set[str] - - def get_json_permissions(self): - return [str(p) for p in self.permissions] +from .const import CURRENT_DIR, GOOSEBIT_ROOT_DIR, LOGGING_DEFAULT class DeviceAuthMode(StrEnum): @@ -56,8 +47,6 @@ class GooseBitSettings(BaseSettings): secret_key: Annotated[OctKey, BeforeValidator(OctKey.import_key)] = secrets.token_hex(16) - users: list[User] = [] - db_uri: str = f"sqlite:///{GOOSEBIT_ROOT_DIR.joinpath('db.sqlite3')}" db_ssl_crt: Path | None = None diff --git a/goosebit/ui/bff/common/columns.py b/goosebit/ui/bff/common/columns.py index 49870d24..4619d0b3 100644 --- a/goosebit/ui/bff/common/columns.py +++ b/goosebit/ui/bff/common/columns.py @@ -42,3 +42,9 @@ class SoftwareColumns: version = DTColumnDescription(title="Version", data="version", name="version", searchable=True, orderable=True) compatibility = DTColumnDescription(title="Compatibility", name="compatibility", data="compatibility") size = DTColumnDescription(title="Size", name="size", data="size") + + +class SettingsUsersColumns: + username = DTColumnDescription(title="Username", data="username", searchable=True, orderable=True) + enabled = DTColumnDescription(title="Enabled", data="enabled") + permissions = DTColumnDescription(title="Permissions", data="permissions") diff --git a/goosebit/ui/bff/devices/device/routes.py b/goosebit/ui/bff/devices/device/routes.py index 54f73ce6..fad88b01 100644 --- a/goosebit/ui/bff/devices/device/routes.py +++ b/goosebit/ui/bff/devices/device/routes.py @@ -4,6 +4,7 @@ from goosebit.api.v1.devices.device import routes from goosebit.auth import validate_user_permissions +from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS router = APIRouter(prefix="/{dev_id}") @@ -11,6 +12,6 @@ "/log", routes.device_logs, methods=["GET"], - dependencies=[Security(validate_user_permissions, scopes=["device.read"])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()])], name="bff_device_logs", ) diff --git a/goosebit/ui/bff/devices/routes.py b/goosebit/ui/bff/devices/routes.py index 6aa0c563..046ca86a 100644 --- a/goosebit/ui/bff/devices/routes.py +++ b/goosebit/ui/bff/devices/routes.py @@ -10,6 +10,7 @@ from goosebit.api.responses import StatusResponse from goosebit.api.v1.devices import routes from goosebit.auth import validate_user_permissions +from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS from goosebit.db.models import Device, Software, UpdateModeEnum, UpdateStateEnum from goosebit.device_manager import DeviceManager, get_device from goosebit.schema.devices import DeviceSchema @@ -29,7 +30,7 @@ @router.get( "", - dependencies=[Security(validate_user_permissions, scopes=["device.read"])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()])], ) async def devices_get(dt_query: Annotated[DataTableRequest, Depends(parse_datatables_query)]) -> BFFDeviceResponse: def search_filter(search_value: str): @@ -60,7 +61,7 @@ async def set_assigned_sw(d: DeviceSchema): @router.patch( "", - dependencies=[Security(validate_user_permissions, scopes=["device.write"])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["write"]()])], ) async def devices_patch(_: Request, config: DevicesPatchRequest) -> StatusResponse: for dev_id in config.devices: @@ -92,14 +93,14 @@ async def devices_patch(_: Request, config: DevicesPatchRequest) -> StatusRespon "", routes.devices_delete, methods=["DELETE"], - dependencies=[Security(validate_user_permissions, scopes=["device.delete"])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["delete"]()])], name="bff_devices_delete", ) @router.get( "/columns", - dependencies=[Security(validate_user_permissions, scopes=["device.read"])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()])], response_model_exclude_none=True, ) async def devices_get_columns(request: Request) -> DTColumns: diff --git a/goosebit/ui/bff/rollouts/routes.py b/goosebit/ui/bff/rollouts/routes.py index 11ca352a..08ae85a0 100644 --- a/goosebit/ui/bff/rollouts/routes.py +++ b/goosebit/ui/bff/rollouts/routes.py @@ -5,6 +5,7 @@ from goosebit.api.v1.rollouts import routes from goosebit.auth import validate_user_permissions +from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS from goosebit.db.models import Rollout from goosebit.ui.bff.common.requests import DataTableRequest from goosebit.ui.bff.common.util import parse_datatables_query @@ -18,7 +19,7 @@ @router.get( "", - dependencies=[Security(validate_user_permissions, scopes=["rollout.read"])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["rollout"]["read"]()])], ) async def rollouts_get(dt_query: Annotated[DataTableRequest, Depends(parse_datatables_query)]) -> BFFRolloutsResponse: def search_filter(search_value): @@ -33,7 +34,7 @@ def search_filter(search_value): "", routes.rollouts_put, methods=["POST"], - dependencies=[Security(validate_user_permissions, scopes=["rollout.write"])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["rollout"]["write"]()])], name="bff_rollouts_post", ) @@ -42,7 +43,7 @@ def search_filter(search_value): "", routes.rollouts_patch, methods=["PATCH"], - dependencies=[Security(validate_user_permissions, scopes=["rollout.write"])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["rollout"]["write"]()])], name="bff_rollouts_patch", ) @@ -51,14 +52,14 @@ def search_filter(search_value): "", routes.rollouts_delete, methods=["DELETE"], - dependencies=[Security(validate_user_permissions, scopes=["rollout.delete"])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["rollout"]["delete"]()])], name="bff_rollouts_delete", ) @router.get( "/columns", - dependencies=[Security(validate_user_permissions, scopes=["rollout.read"])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["rollout"]["read"]()])], response_model_exclude_none=True, ) async def devices_get_columns() -> DTColumns: diff --git a/goosebit/ui/bff/routes.py b/goosebit/ui/bff/routes.py index 1e2d2985..ef1791ab 100644 --- a/goosebit/ui/bff/routes.py +++ b/goosebit/ui/bff/routes.py @@ -4,10 +4,11 @@ from goosebit.auth import validate_current_user -from . import devices, download, rollouts, software +from . import devices, download, rollouts, settings, software router = APIRouter(prefix="/bff", tags=["bff"], dependencies=[Depends(validate_current_user)]) router.include_router(devices.router) router.include_router(software.router) router.include_router(rollouts.router) router.include_router(download.router) +router.include_router(settings.router) diff --git a/goosebit/ui/bff/settings/__init__.py b/goosebit/ui/bff/settings/__init__.py new file mode 100644 index 00000000..2c102012 --- /dev/null +++ b/goosebit/ui/bff/settings/__init__.py @@ -0,0 +1 @@ +from .routes import router # noqa: F401 diff --git a/goosebit/ui/bff/settings/routes.py b/goosebit/ui/bff/settings/routes.py new file mode 100644 index 00000000..273f0c99 --- /dev/null +++ b/goosebit/ui/bff/settings/routes.py @@ -0,0 +1,20 @@ +from fastapi import APIRouter, Security + +from goosebit.api.v1.settings import routes +from goosebit.auth import validate_user_permissions +from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS + +from . import users + +router = APIRouter(prefix="/settings") + +router.include_router(users.router) + +router.add_api_route( + "/permissions", + routes.settings_permissions_get, + methods=["GET"], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["settings"]["users"]["read"]()])], + name="bff_settings_permissions_get", + response_model_exclude_none=True, +) diff --git a/goosebit/ui/bff/settings/users/__init__.py b/goosebit/ui/bff/settings/users/__init__.py new file mode 100644 index 00000000..2c102012 --- /dev/null +++ b/goosebit/ui/bff/settings/users/__init__.py @@ -0,0 +1 @@ +from .routes import router # noqa: F401 diff --git a/goosebit/ui/bff/settings/users/responses.py b/goosebit/ui/bff/settings/users/responses.py new file mode 100644 index 00000000..46bf9ba8 --- /dev/null +++ b/goosebit/ui/bff/settings/users/responses.py @@ -0,0 +1,33 @@ +from typing import Callable + +from pydantic import BaseModel, Field +from tortoise.queryset import QuerySet + +from goosebit.schema.users import UserSchema +from goosebit.ui.bff.common.requests import DataTableRequest + + +class BFFSettingsUsersResponse(BaseModel): + data: list[UserSchema] + draw: int + records_total: int = Field(serialization_alias="recordsTotal") + records_filtered: int = Field(serialization_alias="recordsFiltered") + + @classmethod + async def convert(cls, dt_query: DataTableRequest, query: QuerySet, search_filter: Callable): + total_records = await query.count() + if dt_query.search.value: + query = query.filter(search_filter(dt_query.search.value)) + + filtered_records = await query.count() + + if dt_query.order_query: + query = query.order_by(dt_query.order_query) + + if dt_query.length is not None: + query = query.limit(dt_query.length) + + rollouts = await query.offset(dt_query.start).all() + data = [UserSchema.model_validate(r) for r in rollouts] + + return cls(data=data, draw=dt_query.draw, records_total=total_records, records_filtered=filtered_records) diff --git a/goosebit/ui/bff/settings/users/routes.py b/goosebit/ui/bff/settings/users/routes.py new file mode 100644 index 00000000..cca37994 --- /dev/null +++ b/goosebit/ui/bff/settings/users/routes.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, Security +from tortoise.expressions import Q + +from goosebit.api.v1.settings.users import routes +from goosebit.auth import validate_user_permissions +from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS +from goosebit.db.models import User +from goosebit.ui.bff.common.columns import SettingsUsersColumns +from goosebit.ui.bff.common.requests import DataTableRequest +from goosebit.ui.bff.common.responses import DTColumns +from goosebit.ui.bff.common.util import parse_datatables_query +from goosebit.ui.bff.settings.users.responses import BFFSettingsUsersResponse + +router = APIRouter(prefix="/users") + + +@router.get( + "", + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["settings"]["users"]["read"]()])], +) +async def settings_users_get( + dt_query: Annotated[DataTableRequest, Depends(parse_datatables_query)], +) -> BFFSettingsUsersResponse: + filters: list[Q] = [] + + def search_filter(search_value): + base_filter = Q(Q(username__icontains=search_value), join_type="OR") + return Q(base_filter, *filters, join_type="AND") + + query = User.all() + + return await BFFSettingsUsersResponse.convert(dt_query, query, search_filter) + + +router.add_api_route( + "", + routes.settings_users_put, + methods=["POST"], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["settings"]["users"]["write"]()])], + name="bff_settings_users_put", +) + +router.add_api_route( + "", + routes.settings_users_delete, + methods=["DELETE"], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["settings"]["users"]["delete"]()])], + name="bff_settings_users_delete", +) + +router.add_api_route( + "", + routes.settings_users_patch, + methods=["PATCH"], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["settings"]["users"]["write"]()])], + name="bff_settings_users_patch", +) + + +@router.get( + "/columns", + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["settings"]["users"]["read"]()])], + response_model_exclude_none=True, +) +async def settings_users_get_columns() -> DTColumns: + columns = list( + filter( + None, + [ + SettingsUsersColumns.username, + SettingsUsersColumns.enabled, + SettingsUsersColumns.permissions, + ], + ) + ) + return DTColumns(columns=columns) diff --git a/goosebit/ui/bff/software/routes.py b/goosebit/ui/bff/software/routes.py index 446b46fd..531e930a 100644 --- a/goosebit/ui/bff/software/routes.py +++ b/goosebit/ui/bff/software/routes.py @@ -9,6 +9,7 @@ from goosebit.api.v1.software import routes from goosebit.auth import validate_user_permissions +from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS from goosebit.db.models import Hardware, Rollout, Software from goosebit.settings import config from goosebit.ui.bff.common.requests import DataTableRequest @@ -24,7 +25,7 @@ @router.get( "", - dependencies=[Security(validate_user_permissions, scopes=["software.read"])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["software"]["read"]()])], ) async def software_get( dt_query: Annotated[DataTableRequest, Depends(parse_datatables_query)], @@ -49,14 +50,14 @@ def search_filter(search_value): "", routes.software_delete, methods=["DELETE"], - dependencies=[Security(validate_user_permissions, scopes=["software.delete"])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["software"]["delete"]()])], name="bff_software_delete", ) @router.post( "", - dependencies=[Security(validate_user_permissions, scopes=["software.write"])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["software"]["write"]()])], ) async def post_update( request: Request, @@ -100,7 +101,7 @@ async def post_update( @router.get( "/columns", - dependencies=[Security(validate_user_permissions, scopes=["software.read"])], + dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["software"]["read"]()])], response_model_exclude_none=True, ) async def devices_get_columns() -> DTColumns: diff --git a/goosebit/ui/nav.py b/goosebit/ui/nav.py index f8adf86e..fadbd09a 100644 --- a/goosebit/ui/nav.py +++ b/goosebit/ui/nav.py @@ -1,10 +1,20 @@ +from pydantic import BaseModel + + +class NavigationItem(BaseModel): + function: str + text: str + permissions: list[str] + show: bool + + class Navigation: def __init__(self): self.items = [] - def route(self, text: str, permissions: str | None = None): + def route(self, text: str, permissions: list[str] | None = None, show: bool = True): def decorator(func): - self.items.append({"function": func.__name__, "text": text, "permissions": permissions}) + self.items.append(NavigationItem(function=func.__name__, text=text, permissions=permissions, show=show)) return func return decorator diff --git a/goosebit/ui/routes.py b/goosebit/ui/routes.py index 18011f47..e529d08e 100644 --- a/goosebit/ui/routes.py +++ b/goosebit/ui/routes.py @@ -1,19 +1,48 @@ -from fastapi import APIRouter, Depends, Security -from fastapi.requests import Request +import logging + +from fastapi import APIRouter, Depends, HTTPException, Security +from fastapi.requests import HTTPConnection, Request from fastapi.responses import RedirectResponse -from fastapi.security import OAuth2PasswordBearer +from fastapi.security import SecurityScopes -from goosebit.auth import redirect_if_unauthenticated, validate_user_permissions +from goosebit.auth import ( + check_permissions, + get_current_user, + redirect_if_unauthenticated, +) +from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS from goosebit.ui.nav import nav +from ..db.models import User from . import bff from .templates import templates -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login") - router = APIRouter(prefix="/ui", include_in_schema=False) router.include_router(bff.router) +logger = logging.getLogger(__name__) + + +def validate_user_permissions_with_nav_redirect( + connection: HTTPConnection, + security: SecurityScopes, + user: User = Depends(get_current_user), +): + if not check_permissions(security.scopes, user.permissions): + logger.warning(f"{user.username} does not have sufficient permissions") + for item in nav.items: + if check_permissions(item.permissions, user.permissions): + raise HTTPException( + status_code=302, + headers={"location": str(connection.url_for(item.function))}, + ) + raise HTTPException( + status_code=403, + detail="Not enough permissions", + headers={"WWW-Authenticate": "Bearer"}, + ) + return connection + @router.get("", dependencies=[Depends(redirect_if_unauthenticated)]) async def ui_root(request: Request): @@ -22,34 +51,58 @@ async def ui_root(request: Request): @router.get( "/devices", - dependencies=[Depends(redirect_if_unauthenticated), Security(validate_user_permissions, scopes=["device.read"])], + dependencies=[ + Depends(redirect_if_unauthenticated), + Security(validate_user_permissions_with_nav_redirect, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()]), + ], ) -@nav.route("Devices", permissions="device.read") +@nav.route("Devices", permissions=[GOOSEBIT_PERMISSIONS["device"]["read"]()]) async def devices_ui(request: Request): return templates.TemplateResponse(request, "devices.html.jinja", context={"title": "Devices"}) @router.get( "/software", - dependencies=[Depends(redirect_if_unauthenticated), Security(validate_user_permissions, scopes=["software.read"])], + dependencies=[ + Depends(redirect_if_unauthenticated), + Security(validate_user_permissions_with_nav_redirect, scopes=[GOOSEBIT_PERMISSIONS["software"]["read"]()]), + ], ) -@nav.route("Software", permissions="software.read") +@nav.route("Software", permissions=[GOOSEBIT_PERMISSIONS["software"]["read"]()]) async def software_ui(request: Request): return templates.TemplateResponse(request, "software.html.jinja", context={"title": "Software"}) @router.get( "/rollouts", - dependencies=[Depends(redirect_if_unauthenticated), Security(validate_user_permissions, scopes=["rollout.read"])], + dependencies=[ + Depends(redirect_if_unauthenticated), + Security(validate_user_permissions_with_nav_redirect, scopes=[GOOSEBIT_PERMISSIONS["rollout"]["read"]()]), + ], ) -@nav.route("Rollouts", permissions="rollout.read") +@nav.route("Rollouts", permissions=[GOOSEBIT_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=[Depends(redirect_if_unauthenticated), Security(validate_user_permissions, scopes=["device.read"])], + dependencies=[ + Depends(redirect_if_unauthenticated), + Security(validate_user_permissions_with_nav_redirect, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()]), + ], ) async def logs_ui(request: Request, dev_id: str): return templates.TemplateResponse(request, "logs.html.jinja", context={"title": "Log", "device": dev_id}) + + +@router.get( + "/settings", + dependencies=[ + Depends(redirect_if_unauthenticated), + Security(validate_user_permissions_with_nav_redirect, scopes=[GOOSEBIT_PERMISSIONS["settings"]()]), + ], +) +@nav.route("Settings", permissions=[GOOSEBIT_PERMISSIONS["settings"]()], show=False) +async def settings_ui(request: Request): + return templates.TemplateResponse(request, "settings.html.jinja", context={"title": "Settings"}) diff --git a/goosebit/ui/static/js/settings.js b/goosebit/ui/static/js/settings.js new file mode 100644 index 00000000..0b97e40e --- /dev/null +++ b/goosebit/ui/static/js/settings.js @@ -0,0 +1,322 @@ +let dataTable; + +const renderFunctions = { + enabled: (data, type) => { + if (type === "display") { + const color = data ? "success" : "muted"; + return ` +
+ ● +
+ `; + } + return data; + }, + permissions: (data, type) => { + return data.join(","); + }, +}; + +document.addEventListener("DOMContentLoaded", async () => { + const columnConfig = await get_request("/ui/bff/settings/users/columns"); + for (const col in columnConfig.columns) { + const colDesc = columnConfig.columns[col]; + const colName = colDesc.data; + if (renderFunctions[colName]) { + columnConfig.columns[col].render = renderFunctions[colName]; + } + } + + dataTable = new DataTable("#users-table", { + responsive: true, + paging: true, + processing: false, + serverSide: true, + order: { name: "username", dir: "asc" }, + scrollCollapse: true, + scroller: true, + scrollY: "65vh", + stateSave: true, + select: true, + rowId: "username", + ajax: { + url: "/ui/bff/settings/users", + data: (data) => { + // biome-ignore lint/performance/noDelete: really has to be deleted + delete data.columns; + }, + contentType: "application/json", + }, + initComplete: () => { + updateBtnState(); + }, + layout: { + top1Start: { + buttons: [], + }, + bottom1Start: { + buttons: [ + { + text: '', + action: async () => { + const permissionsSelection = document.getElementById("create-user-permissions"); + permissionsSelection.innerHTML = await createPermissions(); + new bootstrap.Modal("#create-user-modal").show(); + }, + className: "buttons-create-user", + titleAttr: "Add User", + }, + { + text: '', + action: (e, dt) => { + const selectedUsers = dt + .rows({ selected: true }) + .data() + .toArray() + .map((d) => d.username); + enableUsers(selectedUsers, true); + }, + className: "buttons-enable-users", + titleAttr: "Enable Users", + }, + { + text: '', + action: (e, dt) => { + const selectedUsers = dt + .rows({ selected: true }) + .data() + .toArray() + .map((d) => d.username); + enableUsers(selectedUsers, false); + }, + className: "buttons-disable-users", + titleAttr: "Disable Users", + }, + { + text: '', + action: async (e, dt) => { + const selectedUsers = dt + .rows({ selected: true }) + .data() + .toArray() + .map((d) => d.username); + deleteUsers(selectedUsers); + }, + className: "buttons-delete-users", + titleAttr: "Delete Users", + }, + ], + }, + }, + columnDefs: [ + { + targets: "_all", + searchable: false, + orderable: false, + render: (data) => data || "-", + }, + ], + columns: columnConfig.columns, + }); + + dataTable + .on("select", () => { + updateBtnState(); + }) + .on("deselect", () => { + updateBtnState(); + }); + + setInterval(() => { + updateUsersList(); + }, TABLE_UPDATE_TIME); + const form = document.getElementById("create-user-form"); + form.addEventListener("submit", (event) => { + const permissionsContainer = document.getElementById("create-user-permissions"); + const permissions = [ + ...permissionsContainer.querySelectorAll('input[type="checkbox"]:checked:not(:disabled)'), + ].map((checkbox) => checkbox.value); + const permissionsValidatorCheckbox = document.getElementById("create-user-permissions-validator"); + permissionsValidatorCheckbox.checked = permissions.length > 0; + + if (form.checkValidity() === false) { + if (permissions.length === 0) { + permissionsContainer.classList.add("is-invalid"); + } + event.preventDefault(); + event.stopPropagation(); + form.classList.add("was-validated"); + } else { + event.preventDefault(); + createUser(); + form.classList.remove("was-validated"); + permissionsContainer.classList.remove("is-invalid"); + form.reset(); + const modal = bootstrap.Modal.getInstance(document.getElementById("create-user-modal")); + modal.hide(); + } + }); +}); + +function updateBtnState() { + if (dataTable.rows({ selected: true }).any()) { + document.querySelector("button.buttons-delete-users").classList.remove("disabled"); + document.querySelector("button.buttons-disable-users").classList.remove("disabled"); + document.querySelector("button.buttons-enable-users").classList.remove("disabled"); + } else { + document.querySelector("button.buttons-delete-users").classList.add("disabled"); + document.querySelector("button.buttons-disable-users").classList.add("disabled"); + document.querySelector("button.buttons-enable-users").classList.add("disabled"); + } + if (dataTable.rows({ selected: true }).count() === 1) { + } else { + } +} + +function updateUsersList() { + const scrollPosition = $("#users-table").parent().scrollTop(); // Get current scroll position + + const selectedRows = dataTable + .rows({ selected: true }) + .data() + .toArray() + .map((d) => d.username); + + dataTable.ajax.reload(() => { + dataTable.rows().every(function () { + const rowData = this.data(); + if (selectedRows.includes(rowData.username)) { + this.select(); + } + }); + $("#users-table").parent().scrollTop(scrollPosition); // Restore scroll position after reload + }, false); +} + +async function createPermissions() { + const permissions = await get_request("/ui/bff/settings/permissions"); + + innerAccordion = document.createElement("div"); + innerAccordion.classList = "accordion-body p-0"; + + for (innerPermission in permissions.sub_permissions) { + dropdown = createPermissionDropdown(permissions.sub_permissions[innerPermission]); + innerAccordion.innerHTML += dropdown; + } + + return `
+
+ +
+
+
+
+ +
+
+ ${innerAccordion.outerHTML} +
+
+
+
`; +} + +function createPermissionDropdown(permission) { + if (!permission.sub_permissions) { + return `
+
+ +
+
+ ${permission.description} +
+
`; + } + + subAccordion = document.createElement("div"); + subAccordion.classList = "accordion-body p-0"; + + for (innerPermission in permission.sub_permissions) { + dropdown = createPermissionDropdown(permission.sub_permissions[innerPermission]); + subAccordion.innerHTML += dropdown; + } + permissionId = permission.value.replaceAll(".", "-"); + + return `
+
+ +
+
+
+
+ +
+
+ ${subAccordion.outerHTML} +
+
+
+
`; +} + +function permissionCheckOnUpdate(checkbox) { + const childPermissions = document.querySelectorAll(`input[data-permission-parent="${checkbox.value}"`); + for (const permissionCheckbox of childPermissions) { + permissionCheckbox.checked = checkbox.checked; + permissionCheckbox.disabled = checkbox.checked; + permissionCheckbox.dispatchEvent(new Event("change")); + } +} + +async function createUser() { + const username = document.getElementById("create-user-username").value; + const password = document.getElementById("create-user-password").value; + + const permissionsContainer = document.getElementById("create-user-permissions"); + const permissions = [...permissionsContainer.querySelectorAll('input[type="checkbox"]:checked:not(:disabled)')].map( + (checkbox) => checkbox.value, + ); + + try { + await post_request("/ui/bff/settings/users", { + username: username, + password: password, + permissions: permissions, + }); + } catch (error) { + console.error("User creation failed:", error); + } + + setTimeout(updateUsersList, 50); +} + +async function deleteUsers(usernames) { + try { + await delete_request("/ui/bff/settings/users", { usernames }); + } catch (error) { + console.error("Users deletion failed:", error); + } + + updateBtnState(); + setTimeout(updateUsersList, 50); +} + +async function enableUsers(usernames, enabled) { + try { + await patch_request("/ui/bff/settings/users", { usernames, enabled }); + } catch (error) { + console.error(`Users ${enabled ? "enabling" : "disabling"} failed:`, error); + } + + setTimeout(updateUsersList, 50); +} diff --git a/goosebit/ui/static/js/setup.js b/goosebit/ui/static/js/setup.js new file mode 100644 index 00000000..53af2ed8 --- /dev/null +++ b/goosebit/ui/static/js/setup.js @@ -0,0 +1,28 @@ +setupForm = document.getElementById("setup_form"); + +async function setup() { + const formData = new FormData(setupForm); + + if (!(formData.password === formData.password_confirm)) { + console.error("Passwords dont match"); + return; + } + + try { + const response = await fetch("/setup", { + method: "POST", + body: formData, + }); + tokenData = await response.json(); + document.cookie = `session_id=${tokenData.access_token}; path=/`; + window.location.assign("/"); + } catch (e) { + // handle form errors later + console.error(e); + } +} + +setupForm.addEventListener("submit", (event) => { + event.preventDefault(); + setup(); +}); diff --git a/goosebit/ui/static/js/util.js b/goosebit/ui/static/js/util.js index 9f22885a..e6c9d3cd 100644 --- a/goosebit/ui/static/js/util.js +++ b/goosebit/ui/static/js/util.js @@ -105,6 +105,27 @@ async function post_request(url, object) { throw new Error(`POST ${url} failed for ${JSON.stringify(object)}`); } } +async function put_request(url, object) { + const response = await fetch(url, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(object), + }); + + if (!response.ok) { + const result = await response.json(); + if (result.detail) { + Swal.fire({ + title: "Warning", + text: result.detail, + icon: "warning", + confirmButtonText: "Understood", + }); + } + + throw new Error(`PUT ${url} failed for ${JSON.stringify(object)}`); + } +} async function patch_request(url, object) { const response = await fetch(url, { method: "PATCH", diff --git a/goosebit/ui/templates/nav.html.jinja b/goosebit/ui/templates/nav.html.jinja index 4d7ff27b..bc4f24cb 100644 --- a/goosebit/ui/templates/nav.html.jinja +++ b/goosebit/ui/templates/nav.html.jinja @@ -1,7 +1,7 @@ - + {{ title }} @@ -98,14 +98,21 @@ diff --git a/goosebit/ui/templates/rollouts.html.jinja b/goosebit/ui/templates/rollouts.html.jinja index 11304aea..db4b1be0 100644 --- a/goosebit/ui/templates/rollouts.html.jinja +++ b/goosebit/ui/templates/rollouts.html.jinja @@ -9,8 +9,8 @@ {% if compare_permissions(["rollout.write"], request.user.permissions) %} -