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 ``;
+}
+
+function createPermissionDropdown(permission) {
+ if (!permission.sub_permissions) {
+ return ``;
+ }
+
+ 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 ``;
+}
+
+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 @@
{% for nav_item in request.nav %}
- {% if compare_permissions([nav_item.permissions], request.user.permissions) %}
-
{{ nav_item.text }}
+ {% if nav_item.show %}
+ {% if compare_permissions(nav_item.permissions, request.user.permissions) %}
+
{{ nav_item.text }}
+ {% endif %}
{% endif %}
{% endfor %}
-
Logout
+ {% if compare_permissions("goosebit.settings", request.user.permissions) %}
+
Settings
+ {% endif %}
+
Logout
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) %}
-
-
+
+
-
diff --git a/goosebit/ui/templates/settings.html.jinja b/goosebit/ui/templates/settings.html.jinja
new file mode 100644
index 00000000..d5eed251
--- /dev/null
+++ b/goosebit/ui/templates/settings.html.jinja
@@ -0,0 +1,88 @@
+{% extends "nav.html.jinja" %}
+{% block content %}
+
+
+ {% if compare_permissions(["settings.write"], request.user.permissions) %}
+
+ {% else %}
+
+
+
+
+
+
You do not have permission to add users.
+
+
+
+
+ {% endif %}
+
+{% endblock content %}
diff --git a/goosebit/ui/templates/setup.html.jinja b/goosebit/ui/templates/setup.html.jinja
new file mode 100644
index 00000000..d8f02dcf
--- /dev/null
+++ b/goosebit/ui/templates/setup.html.jinja
@@ -0,0 +1,71 @@
+
+
+
+
+
+
Login
+
+
+
+
+
+
+
+
+
+
+
+
+
gooseBit
+
+
+
 }})
+
+
+
+
+
Welcome to gooseBit. Please create an admin user.
+
+
+
+
+
+
+
+
+
+
diff --git a/goosebit/users/__init__.py b/goosebit/users/__init__.py
new file mode 100644
index 00000000..a66f5aa5
--- /dev/null
+++ b/goosebit/users/__init__.py
@@ -0,0 +1,63 @@
+from aiocache import caches
+
+from goosebit.api.telemetry.metrics import users_count
+from goosebit.db.models import User
+from goosebit.settings import PWD_CXT
+
+
+async def create_user(username: str, password: str, permissions: list[str]) -> User:
+ return await UserManager.setup_user(username=username, hashed_pwd=PWD_CXT.hash(password), permissions=permissions)
+
+
+async def create_initial_user(username: str, hashed_pwd: str) -> User:
+ return await UserManager.setup_user(username=username, hashed_pwd=hashed_pwd, permissions=["*"])
+
+
+class UserManager:
+ @staticmethod
+ async def save_user(user: User, update_fields: list[str]) -> None:
+ await user.save(update_fields=update_fields)
+
+ # only update cache after a successful database save
+ result = await caches.get("default").set(user.username, user, ttl=600)
+ assert result, "user being cached"
+
+ @staticmethod
+ async def update_enabled(user: User, enabled: bool) -> None:
+ user.enabled = enabled
+ await UserManager.save_user(user, update_fields=["enabled"])
+
+ @classmethod
+ async def setup_user(cls, username: str, hashed_pwd: str, permissions: list[str]) -> User:
+ user = (
+ await User.get_or_create(
+ username=username,
+ defaults={
+ "hashed_pwd": hashed_pwd,
+ "permissions": permissions,
+ },
+ )
+ )[0]
+ users_count.set(await User.all().count())
+ return user
+
+ @staticmethod
+ async def get_user(username: str) -> User:
+ cache = caches.get("default")
+ user = await cache.get(username)
+ if user:
+ return user
+
+ user = await User.get_or_none(username=username)
+ if user is not None:
+ result = await cache.set(user.username, user, ttl=600)
+ assert result, "user being cached"
+
+ return user
+
+ @staticmethod
+ async def delete_users(usernames: list[str]):
+ await User.filter(username__in=usernames).delete()
+ for username in usernames:
+ await caches.get("default").delete(username)
+ users_count.set(await User.all().count())
diff --git a/poetry.lock b/poetry.lock
index d96143b6..6a4cbe38 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand.
+# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand.
[[package]]
name = "aerich"
@@ -1590,19 +1590,19 @@ files = [
[[package]]
name = "pydantic"
-version = "2.11.1"
+version = "2.11.3"
description = "Data validation using Python type hints"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
- {file = "pydantic-2.11.1-py3-none-any.whl", hash = "sha256:5b6c415eee9f8123a14d859be0c84363fec6b1feb6b688d6435801230b56e0b8"},
- {file = "pydantic-2.11.1.tar.gz", hash = "sha256:442557d2910e75c991c39f4b4ab18963d57b9b55122c8b2a9cd176d8c29ce968"},
+ {file = "pydantic-2.11.3-py3-none-any.whl", hash = "sha256:a082753436a07f9ba1289c6ffa01cd93db3548776088aa917cc43b63f68fa60f"},
+ {file = "pydantic-2.11.3.tar.gz", hash = "sha256:7471657138c16adad9322fe3070c0116dd6c3ad8d649300e3cbdfe91f4db4ec3"},
]
[package.dependencies]
annotated-types = ">=0.6.0"
-pydantic-core = "2.33.0"
+pydantic-core = "2.33.1"
typing-extensions = ">=4.12.2"
typing-inspection = ">=0.4.0"
@@ -1612,111 +1612,111 @@ timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows
[[package]]
name = "pydantic-core"
-version = "2.33.0"
+version = "2.33.1"
description = "Core functionality for Pydantic validation and serialization"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
- {file = "pydantic_core-2.33.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:71dffba8fe9ddff628c68f3abd845e91b028361d43c5f8e7b3f8b91d7d85413e"},
- {file = "pydantic_core-2.33.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:abaeec1be6ed535a5d7ffc2e6c390083c425832b20efd621562fbb5bff6dc518"},
- {file = "pydantic_core-2.33.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:759871f00e26ad3709efc773ac37b4d571de065f9dfb1778012908bcc36b3a73"},
- {file = "pydantic_core-2.33.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dcfebee69cd5e1c0b76a17e17e347c84b00acebb8dd8edb22d4a03e88e82a207"},
- {file = "pydantic_core-2.33.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b1262b912435a501fa04cd213720609e2cefa723a07c92017d18693e69bf00b"},
- {file = "pydantic_core-2.33.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4726f1f3f42d6a25678c67da3f0b10f148f5655813c5aca54b0d1742ba821b8f"},
- {file = "pydantic_core-2.33.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e790954b5093dff1e3a9a2523fddc4e79722d6f07993b4cd5547825c3cbf97b5"},
- {file = "pydantic_core-2.33.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:34e7fb3abe375b5c4e64fab75733d605dda0f59827752debc99c17cb2d5f3276"},
- {file = "pydantic_core-2.33.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ecb158fb9b9091b515213bed3061eb7deb1d3b4e02327c27a0ea714ff46b0760"},
- {file = "pydantic_core-2.33.0-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:4d9149e7528af8bbd76cc055967e6e04617dcb2a2afdaa3dea899406c5521faa"},
- {file = "pydantic_core-2.33.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e81a295adccf73477220e15ff79235ca9dcbcee4be459eb9d4ce9a2763b8386c"},
- {file = "pydantic_core-2.33.0-cp310-cp310-win32.whl", hash = "sha256:f22dab23cdbce2005f26a8f0c71698457861f97fc6318c75814a50c75e87d025"},
- {file = "pydantic_core-2.33.0-cp310-cp310-win_amd64.whl", hash = "sha256:9cb2390355ba084c1ad49485d18449b4242da344dea3e0fe10babd1f0db7dcfc"},
- {file = "pydantic_core-2.33.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a608a75846804271cf9c83e40bbb4dab2ac614d33c6fd5b0c6187f53f5c593ef"},
- {file = "pydantic_core-2.33.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e1c69aa459f5609dec2fa0652d495353accf3eda5bdb18782bc5a2ae45c9273a"},
- {file = "pydantic_core-2.33.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9ec80eb5a5f45a2211793f1c4aeddff0c3761d1c70d684965c1807e923a588b"},
- {file = "pydantic_core-2.33.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e925819a98318d17251776bd3d6aa9f3ff77b965762155bdad15d1a9265c4cfd"},
- {file = "pydantic_core-2.33.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5bf68bb859799e9cec3d9dd8323c40c00a254aabb56fe08f907e437005932f2b"},
- {file = "pydantic_core-2.33.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1b2ea72dea0825949a045fa4071f6d5b3d7620d2a208335207793cf29c5a182d"},
- {file = "pydantic_core-2.33.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1583539533160186ac546b49f5cde9ffc928062c96920f58bd95de32ffd7bffd"},
- {file = "pydantic_core-2.33.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:23c3e77bf8a7317612e5c26a3b084c7edeb9552d645742a54a5867635b4f2453"},
- {file = "pydantic_core-2.33.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a7a7f2a3f628d2f7ef11cb6188bcf0b9e1558151d511b974dfea10a49afe192b"},
- {file = "pydantic_core-2.33.0-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:f1fb026c575e16f673c61c7b86144517705865173f3d0907040ac30c4f9f5915"},
- {file = "pydantic_core-2.33.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:635702b2fed997e0ac256b2cfbdb4dd0bf7c56b5d8fba8ef03489c03b3eb40e2"},
- {file = "pydantic_core-2.33.0-cp311-cp311-win32.whl", hash = "sha256:07b4ced28fccae3f00626eaa0c4001aa9ec140a29501770a88dbbb0966019a86"},
- {file = "pydantic_core-2.33.0-cp311-cp311-win_amd64.whl", hash = "sha256:4927564be53239a87770a5f86bdc272b8d1fbb87ab7783ad70255b4ab01aa25b"},
- {file = "pydantic_core-2.33.0-cp311-cp311-win_arm64.whl", hash = "sha256:69297418ad644d521ea3e1aa2e14a2a422726167e9ad22b89e8f1130d68e1e9a"},
- {file = "pydantic_core-2.33.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6c32a40712e3662bebe524abe8abb757f2fa2000028d64cc5a1006016c06af43"},
- {file = "pydantic_core-2.33.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ec86b5baa36f0a0bfb37db86c7d52652f8e8aa076ab745ef7725784183c3fdd"},
- {file = "pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4deac83a8cc1d09e40683be0bc6d1fa4cde8df0a9bf0cda5693f9b0569ac01b6"},
- {file = "pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:175ab598fb457a9aee63206a1993874badf3ed9a456e0654273e56f00747bbd6"},
- {file = "pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f36afd0d56a6c42cf4e8465b6441cf546ed69d3a4ec92724cc9c8c61bd6ecf4"},
- {file = "pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a98257451164666afafc7cbf5fb00d613e33f7e7ebb322fbcd99345695a9a61"},
- {file = "pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecc6d02d69b54a2eb83ebcc6f29df04957f734bcf309d346b4f83354d8376862"},
- {file = "pydantic_core-2.33.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a69b7596c6603afd049ce7f3835bcf57dd3892fc7279f0ddf987bebed8caa5a"},
- {file = "pydantic_core-2.33.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ea30239c148b6ef41364c6f51d103c2988965b643d62e10b233b5efdca8c0099"},
- {file = "pydantic_core-2.33.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:abfa44cf2f7f7d7a199be6c6ec141c9024063205545aa09304349781b9a125e6"},
- {file = "pydantic_core-2.33.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20d4275f3c4659d92048c70797e5fdc396c6e4446caf517ba5cad2db60cd39d3"},
- {file = "pydantic_core-2.33.0-cp312-cp312-win32.whl", hash = "sha256:918f2013d7eadea1d88d1a35fd4a1e16aaf90343eb446f91cb091ce7f9b431a2"},
- {file = "pydantic_core-2.33.0-cp312-cp312-win_amd64.whl", hash = "sha256:aec79acc183865bad120b0190afac467c20b15289050648b876b07777e67ea48"},
- {file = "pydantic_core-2.33.0-cp312-cp312-win_arm64.whl", hash = "sha256:5461934e895968655225dfa8b3be79e7e927e95d4bd6c2d40edd2fa7052e71b6"},
- {file = "pydantic_core-2.33.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f00e8b59e1fc8f09d05594aa7d2b726f1b277ca6155fc84c0396db1b373c4555"},
- {file = "pydantic_core-2.33.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a73be93ecef45786d7d95b0c5e9b294faf35629d03d5b145b09b81258c7cd6d"},
- {file = "pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff48a55be9da6930254565ff5238d71d5e9cd8c5487a191cb85df3bdb8c77365"},
- {file = "pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26a4ea04195638dcd8c53dadb545d70badba51735b1594810e9768c2c0b4a5da"},
- {file = "pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41d698dcbe12b60661f0632b543dbb119e6ba088103b364ff65e951610cb7ce0"},
- {file = "pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae62032ef513fe6281ef0009e30838a01057b832dc265da32c10469622613885"},
- {file = "pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f225f3a3995dbbc26affc191d0443c6c4aa71b83358fd4c2b7d63e2f6f0336f9"},
- {file = "pydantic_core-2.33.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5bdd36b362f419c78d09630cbaebc64913f66f62bda6d42d5fbb08da8cc4f181"},
- {file = "pydantic_core-2.33.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2a0147c0bef783fd9abc9f016d66edb6cac466dc54a17ec5f5ada08ff65caf5d"},
- {file = "pydantic_core-2.33.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:c860773a0f205926172c6644c394e02c25421dc9a456deff16f64c0e299487d3"},
- {file = "pydantic_core-2.33.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:138d31e3f90087f42aa6286fb640f3c7a8eb7bdae829418265e7e7474bd2574b"},
- {file = "pydantic_core-2.33.0-cp313-cp313-win32.whl", hash = "sha256:d20cbb9d3e95114325780f3cfe990f3ecae24de7a2d75f978783878cce2ad585"},
- {file = "pydantic_core-2.33.0-cp313-cp313-win_amd64.whl", hash = "sha256:ca1103d70306489e3d006b0f79db8ca5dd3c977f6f13b2c59ff745249431a606"},
- {file = "pydantic_core-2.33.0-cp313-cp313-win_arm64.whl", hash = "sha256:6291797cad239285275558e0a27872da735b05c75d5237bbade8736f80e4c225"},
- {file = "pydantic_core-2.33.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7b79af799630af263eca9ec87db519426d8c9b3be35016eddad1832bac812d87"},
- {file = "pydantic_core-2.33.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eabf946a4739b5237f4f56d77fa6668263bc466d06a8036c055587c130a46f7b"},
- {file = "pydantic_core-2.33.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8a1d581e8cdbb857b0e0e81df98603376c1a5c34dc5e54039dcc00f043df81e7"},
- {file = "pydantic_core-2.33.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:7c9c84749f5787781c1c45bb99f433402e484e515b40675a5d121ea14711cf61"},
- {file = "pydantic_core-2.33.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:64672fa888595a959cfeff957a654e947e65bbe1d7d82f550417cbd6898a1d6b"},
- {file = "pydantic_core-2.33.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26bc7367c0961dec292244ef2549afa396e72e28cc24706210bd44d947582c59"},
- {file = "pydantic_core-2.33.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ce72d46eb201ca43994303025bd54d8a35a3fc2a3495fac653d6eb7205ce04f4"},
- {file = "pydantic_core-2.33.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14229c1504287533dbf6b1fc56f752ce2b4e9694022ae7509631ce346158de11"},
- {file = "pydantic_core-2.33.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:085d8985b1c1e48ef271e98a658f562f29d89bda98bf120502283efbc87313eb"},
- {file = "pydantic_core-2.33.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31860fbda80d8f6828e84b4a4d129fd9c4535996b8249cfb8c720dc2a1a00bb8"},
- {file = "pydantic_core-2.33.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f200b2f20856b5a6c3a35f0d4e344019f805e363416e609e9b47c552d35fd5ea"},
- {file = "pydantic_core-2.33.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5f72914cfd1d0176e58ddc05c7a47674ef4222c8253bf70322923e73e14a4ac3"},
- {file = "pydantic_core-2.33.0-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:91301a0980a1d4530d4ba7e6a739ca1a6b31341252cb709948e0aca0860ce0ae"},
- {file = "pydantic_core-2.33.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7419241e17c7fbe5074ba79143d5523270e04f86f1b3a0dff8df490f84c8273a"},
- {file = "pydantic_core-2.33.0-cp39-cp39-win32.whl", hash = "sha256:7a25493320203005d2a4dac76d1b7d953cb49bce6d459d9ae38e30dd9f29bc9c"},
- {file = "pydantic_core-2.33.0-cp39-cp39-win_amd64.whl", hash = "sha256:82a4eba92b7ca8af1b7d5ef5f3d9647eee94d1f74d21ca7c21e3a2b92e008358"},
- {file = "pydantic_core-2.33.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e2762c568596332fdab56b07060c8ab8362c56cf2a339ee54e491cd503612c50"},
- {file = "pydantic_core-2.33.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5bf637300ff35d4f59c006fff201c510b2b5e745b07125458a5389af3c0dff8c"},
- {file = "pydantic_core-2.33.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62c151ce3d59ed56ebd7ce9ce5986a409a85db697d25fc232f8e81f195aa39a1"},
- {file = "pydantic_core-2.33.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ee65f0cc652261744fd07f2c6e6901c914aa6c5ff4dcfaf1136bc394d0dd26b"},
- {file = "pydantic_core-2.33.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:024d136ae44d233e6322027bbf356712b3940bee816e6c948ce4b90f18471b3d"},
- {file = "pydantic_core-2.33.0-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e37f10f6d4bc67c58fbd727108ae1d8b92b397355e68519f1e4a7babb1473442"},
- {file = "pydantic_core-2.33.0-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:502ed542e0d958bd12e7c3e9a015bce57deaf50eaa8c2e1c439b512cb9db1e3a"},
- {file = "pydantic_core-2.33.0-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:715c62af74c236bf386825c0fdfa08d092ab0f191eb5b4580d11c3189af9d330"},
- {file = "pydantic_core-2.33.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:bccc06fa0372151f37f6b69834181aa9eb57cf8665ed36405fb45fbf6cac3bae"},
- {file = "pydantic_core-2.33.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5d8dc9f63a26f7259b57f46a7aab5af86b2ad6fbe48487500bb1f4b27e051e4c"},
- {file = "pydantic_core-2.33.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30369e54d6d0113d2aa5aee7a90d17f225c13d87902ace8fcd7bbf99b19124db"},
- {file = "pydantic_core-2.33.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3eb479354c62067afa62f53bb387827bee2f75c9c79ef25eef6ab84d4b1ae3b"},
- {file = "pydantic_core-2.33.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0310524c833d91403c960b8a3cf9f46c282eadd6afd276c8c5edc617bd705dc9"},
- {file = "pydantic_core-2.33.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eddb18a00bbb855325db27b4c2a89a4ba491cd6a0bd6d852b225172a1f54b36c"},
- {file = "pydantic_core-2.33.0-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:ade5dbcf8d9ef8f4b28e682d0b29f3008df9842bb5ac48ac2c17bc55771cc976"},
- {file = "pydantic_core-2.33.0-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2c0afd34f928383e3fd25740f2050dbac9d077e7ba5adbaa2227f4d4f3c8da5c"},
- {file = "pydantic_core-2.33.0-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:7da333f21cd9df51d5731513a6d39319892947604924ddf2e24a4612975fb936"},
- {file = "pydantic_core-2.33.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4b6d77c75a57f041c5ee915ff0b0bb58eabb78728b69ed967bc5b780e8f701b8"},
- {file = "pydantic_core-2.33.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ba95691cf25f63df53c1d342413b41bd7762d9acb425df8858d7efa616c0870e"},
- {file = "pydantic_core-2.33.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:4f1ab031feb8676f6bd7c85abec86e2935850bf19b84432c64e3e239bffeb1ec"},
- {file = "pydantic_core-2.33.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58c1151827eef98b83d49b6ca6065575876a02d2211f259fb1a6b7757bd24dd8"},
- {file = "pydantic_core-2.33.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a66d931ea2c1464b738ace44b7334ab32a2fd50be023d863935eb00f42be1778"},
- {file = "pydantic_core-2.33.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0bcf0bab28995d483f6c8d7db25e0d05c3efa5cebfd7f56474359e7137f39856"},
- {file = "pydantic_core-2.33.0-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:89670d7a0045acb52be0566df5bc8b114ac967c662c06cf5e0c606e4aadc964b"},
- {file = "pydantic_core-2.33.0-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:b716294e721d8060908dbebe32639b01bfe61b15f9f57bcc18ca9a0e00d9520b"},
- {file = "pydantic_core-2.33.0-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fc53e05c16697ff0c1c7c2b98e45e131d4bfb78068fffff92a82d169cbb4c7b7"},
- {file = "pydantic_core-2.33.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:68504959253303d3ae9406b634997a2123a0b0c1da86459abbd0ffc921695eac"},
- {file = "pydantic_core-2.33.0.tar.gz", hash = "sha256:40eb8af662ba409c3cbf4a8150ad32ae73514cd7cb1f1a2113af39763dd616b3"},
+ {file = "pydantic_core-2.33.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3077cfdb6125cc8dab61b155fdd714663e401f0e6883f9632118ec12cf42df26"},
+ {file = "pydantic_core-2.33.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ffab8b2908d152e74862d276cf5017c81a2f3719f14e8e3e8d6b83fda863927"},
+ {file = "pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5183e4f6a2d468787243ebcd70cf4098c247e60d73fb7d68d5bc1e1beaa0c4db"},
+ {file = "pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:398a38d323f37714023be1e0285765f0a27243a8b1506b7b7de87b647b517e48"},
+ {file = "pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87d3776f0001b43acebfa86f8c64019c043b55cc5a6a2e313d728b5c95b46969"},
+ {file = "pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c566dd9c5f63d22226409553531f89de0cac55397f2ab8d97d6f06cfce6d947e"},
+ {file = "pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0d5f3acc81452c56895e90643a625302bd6be351e7010664151cc55b7b97f89"},
+ {file = "pydantic_core-2.33.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d3a07fadec2a13274a8d861d3d37c61e97a816beae717efccaa4b36dfcaadcde"},
+ {file = "pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f99aeda58dce827f76963ee87a0ebe75e648c72ff9ba1174a253f6744f518f65"},
+ {file = "pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:902dbc832141aa0ec374f4310f1e4e7febeebc3256f00dc359a9ac3f264a45dc"},
+ {file = "pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fe44d56aa0b00d66640aa84a3cbe80b7a3ccdc6f0b1ca71090696a6d4777c091"},
+ {file = "pydantic_core-2.33.1-cp310-cp310-win32.whl", hash = "sha256:ed3eb16d51257c763539bde21e011092f127a2202692afaeaccb50db55a31383"},
+ {file = "pydantic_core-2.33.1-cp310-cp310-win_amd64.whl", hash = "sha256:694ad99a7f6718c1a498dc170ca430687a39894a60327f548e02a9c7ee4b6504"},
+ {file = "pydantic_core-2.33.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e966fc3caaf9f1d96b349b0341c70c8d6573bf1bac7261f7b0ba88f96c56c24"},
+ {file = "pydantic_core-2.33.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bfd0adeee563d59c598ceabddf2c92eec77abcb3f4a391b19aa7366170bd9e30"},
+ {file = "pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91815221101ad3c6b507804178a7bb5cb7b2ead9ecd600041669c8d805ebd595"},
+ {file = "pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9fea9c1869bb4742d174a57b4700c6dadea951df8b06de40c2fedb4f02931c2e"},
+ {file = "pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d20eb4861329bb2484c021b9d9a977566ab16d84000a57e28061151c62b349a"},
+ {file = "pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb935c5591573ae3201640579f30128ccc10739b45663f93c06796854405505"},
+ {file = "pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c964fd24e6166420d18fb53996d8c9fd6eac9bf5ae3ec3d03015be4414ce497f"},
+ {file = "pydantic_core-2.33.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:681d65e9011f7392db5aa002b7423cc442d6a673c635668c227c6c8d0e5a4f77"},
+ {file = "pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e100c52f7355a48413e2999bfb4e139d2977a904495441b374f3d4fb4a170961"},
+ {file = "pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:048831bd363490be79acdd3232f74a0e9951b11b2b4cc058aeb72b22fdc3abe1"},
+ {file = "pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:bdc84017d28459c00db6f918a7272a5190bec3090058334e43a76afb279eac7c"},
+ {file = "pydantic_core-2.33.1-cp311-cp311-win32.whl", hash = "sha256:32cd11c5914d1179df70406427097c7dcde19fddf1418c787540f4b730289896"},
+ {file = "pydantic_core-2.33.1-cp311-cp311-win_amd64.whl", hash = "sha256:2ea62419ba8c397e7da28a9170a16219d310d2cf4970dbc65c32faf20d828c83"},
+ {file = "pydantic_core-2.33.1-cp311-cp311-win_arm64.whl", hash = "sha256:fc903512177361e868bc1f5b80ac8c8a6e05fcdd574a5fb5ffeac5a9982b9e89"},
+ {file = "pydantic_core-2.33.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1293d7febb995e9d3ec3ea09caf1a26214eec45b0f29f6074abb004723fc1de8"},
+ {file = "pydantic_core-2.33.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:99b56acd433386c8f20be5c4000786d1e7ca0523c8eefc995d14d79c7a081498"},
+ {file = "pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35a5ec3fa8c2fe6c53e1b2ccc2454398f95d5393ab398478f53e1afbbeb4d939"},
+ {file = "pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b172f7b9d2f3abc0efd12e3386f7e48b576ef309544ac3a63e5e9cdd2e24585d"},
+ {file = "pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9097b9f17f91eea659b9ec58148c0747ec354a42f7389b9d50701610d86f812e"},
+ {file = "pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc77ec5b7e2118b152b0d886c7514a4653bcb58c6b1d760134a9fab915f777b3"},
+ {file = "pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e3d15245b08fa4a84cefc6c9222e6f37c98111c8679fbd94aa145f9a0ae23d"},
+ {file = "pydantic_core-2.33.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef99779001d7ac2e2461d8ab55d3373fe7315caefdbecd8ced75304ae5a6fc6b"},
+ {file = "pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fc6bf8869e193855e8d91d91f6bf59699a5cdfaa47a404e278e776dd7f168b39"},
+ {file = "pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:b1caa0bc2741b043db7823843e1bde8aaa58a55a58fda06083b0569f8b45693a"},
+ {file = "pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ec259f62538e8bf364903a7d0d0239447059f9434b284f5536e8402b7dd198db"},
+ {file = "pydantic_core-2.33.1-cp312-cp312-win32.whl", hash = "sha256:e14f369c98a7c15772b9da98987f58e2b509a93235582838bd0d1d8c08b68fda"},
+ {file = "pydantic_core-2.33.1-cp312-cp312-win_amd64.whl", hash = "sha256:1c607801d85e2e123357b3893f82c97a42856192997b95b4d8325deb1cd0c5f4"},
+ {file = "pydantic_core-2.33.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d13f0276806ee722e70a1c93da19748594f19ac4299c7e41237fc791d1861ea"},
+ {file = "pydantic_core-2.33.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:70af6a21237b53d1fe7b9325b20e65cbf2f0a848cf77bed492b029139701e66a"},
+ {file = "pydantic_core-2.33.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:282b3fe1bbbe5ae35224a0dbd05aed9ccabccd241e8e6b60370484234b456266"},
+ {file = "pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b315e596282bbb5822d0c7ee9d255595bd7506d1cb20c2911a4da0b970187d3"},
+ {file = "pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1dfae24cf9921875ca0ca6a8ecb4bb2f13c855794ed0d468d6abbec6e6dcd44a"},
+ {file = "pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dd8ecfde08d8bfadaea669e83c63939af76f4cf5538a72597016edfa3fad516"},
+ {file = "pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f593494876eae852dc98c43c6f260f45abdbfeec9e4324e31a481d948214764"},
+ {file = "pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:948b73114f47fd7016088e5186d13faf5e1b2fe83f5e320e371f035557fd264d"},
+ {file = "pydantic_core-2.33.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e11f3864eb516af21b01e25fac915a82e9ddad3bb0fb9e95a246067398b435a4"},
+ {file = "pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:549150be302428b56fdad0c23c2741dcdb5572413776826c965619a25d9c6bde"},
+ {file = "pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:495bc156026efafd9ef2d82372bd38afce78ddd82bf28ef5276c469e57c0c83e"},
+ {file = "pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ec79de2a8680b1a67a07490bddf9636d5c2fab609ba8c57597e855fa5fa4dacd"},
+ {file = "pydantic_core-2.33.1-cp313-cp313-win32.whl", hash = "sha256:ee12a7be1742f81b8a65b36c6921022301d466b82d80315d215c4c691724986f"},
+ {file = "pydantic_core-2.33.1-cp313-cp313-win_amd64.whl", hash = "sha256:ede9b407e39949d2afc46385ce6bd6e11588660c26f80576c11c958e6647bc40"},
+ {file = "pydantic_core-2.33.1-cp313-cp313-win_arm64.whl", hash = "sha256:aa687a23d4b7871a00e03ca96a09cad0f28f443690d300500603bd0adba4b523"},
+ {file = "pydantic_core-2.33.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:401d7b76e1000d0dd5538e6381d28febdcacb097c8d340dde7d7fc6e13e9f95d"},
+ {file = "pydantic_core-2.33.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7aeb055a42d734c0255c9e489ac67e75397d59c6fbe60d155851e9782f276a9c"},
+ {file = "pydantic_core-2.33.1-cp313-cp313t-win_amd64.whl", hash = "sha256:338ea9b73e6e109f15ab439e62cb3b78aa752c7fd9536794112e14bee02c8d18"},
+ {file = "pydantic_core-2.33.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:5ab77f45d33d264de66e1884fca158bc920cb5e27fd0764a72f72f5756ae8bdb"},
+ {file = "pydantic_core-2.33.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7aaba1b4b03aaea7bb59e1b5856d734be011d3e6d98f5bcaa98cb30f375f2ad"},
+ {file = "pydantic_core-2.33.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fb66263e9ba8fea2aa85e1e5578980d127fb37d7f2e292773e7bc3a38fb0c7b"},
+ {file = "pydantic_core-2.33.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f2648b9262607a7fb41d782cc263b48032ff7a03a835581abbf7a3bec62bcf5"},
+ {file = "pydantic_core-2.33.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:723c5630c4259400818b4ad096735a829074601805d07f8cafc366d95786d331"},
+ {file = "pydantic_core-2.33.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d100e3ae783d2167782391e0c1c7a20a31f55f8015f3293647544df3f9c67824"},
+ {file = "pydantic_core-2.33.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177d50460bc976a0369920b6c744d927b0ecb8606fb56858ff542560251b19e5"},
+ {file = "pydantic_core-2.33.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3edde68d1a1f9af1273b2fe798997b33f90308fb6d44d8550c89fc6a3647cf6"},
+ {file = "pydantic_core-2.33.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a62c3c3ef6a7e2c45f7853b10b5bc4ddefd6ee3cd31024754a1a5842da7d598d"},
+ {file = "pydantic_core-2.33.1-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:c91dbb0ab683fa0cd64a6e81907c8ff41d6497c346890e26b23de7ee55353f96"},
+ {file = "pydantic_core-2.33.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9f466e8bf0a62dc43e068c12166281c2eca72121dd2adc1040f3aa1e21ef8599"},
+ {file = "pydantic_core-2.33.1-cp39-cp39-win32.whl", hash = "sha256:ab0277cedb698749caada82e5d099dc9fed3f906a30d4c382d1a21725777a1e5"},
+ {file = "pydantic_core-2.33.1-cp39-cp39-win_amd64.whl", hash = "sha256:5773da0ee2d17136b1f1c6fbde543398d452a6ad2a7b54ea1033e2daa739b8d2"},
+ {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c834f54f8f4640fd7e4b193f80eb25a0602bba9e19b3cd2fc7ffe8199f5ae02"},
+ {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:049e0de24cf23766f12cc5cc71d8abc07d4a9deb9061b334b62093dedc7cb068"},
+ {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a28239037b3d6f16916a4c831a5a0eadf856bdd6d2e92c10a0da3a59eadcf3e"},
+ {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d3da303ab5f378a268fa7d45f37d7d85c3ec19769f28d2cc0c61826a8de21fe"},
+ {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:25626fb37b3c543818c14821afe0fd3830bc327a43953bc88db924b68c5723f1"},
+ {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3ab2d36e20fbfcce8f02d73c33a8a7362980cff717926bbae030b93ae46b56c7"},
+ {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2f9284e11c751b003fd4215ad92d325d92c9cb19ee6729ebd87e3250072cdcde"},
+ {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:048c01eee07d37cbd066fc512b9d8b5ea88ceeb4e629ab94b3e56965ad655add"},
+ {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5ccd429694cf26af7997595d627dd2637e7932214486f55b8a357edaac9dae8c"},
+ {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3a371dc00282c4b84246509a5ddc808e61b9864aa1eae9ecc92bb1268b82db4a"},
+ {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f59295ecc75a1788af8ba92f2e8c6eeaa5a94c22fc4d151e8d9638814f85c8fc"},
+ {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08530b8ac922003033f399128505f513e30ca770527cc8bbacf75a84fcc2c74b"},
+ {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae370459da6a5466978c0eacf90690cb57ec9d533f8e63e564ef3822bfa04fe"},
+ {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e3de2777e3b9f4d603112f78006f4ae0acb936e95f06da6cb1a45fbad6bdb4b5"},
+ {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3a64e81e8cba118e108d7126362ea30e021291b7805d47e4896e52c791be2761"},
+ {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:52928d8c1b6bda03cc6d811e8923dffc87a2d3c8b3bfd2ce16471c7147a24850"},
+ {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:1b30d92c9412beb5ac6b10a3eb7ef92ccb14e3f2a8d7732e2d739f58b3aa7544"},
+ {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f995719707e0e29f0f41a8aa3bcea6e761a36c9136104d3189eafb83f5cec5e5"},
+ {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7edbc454a29fc6aeae1e1eecba4f07b63b8d76e76a748532233c4c167b4cb9ea"},
+ {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:ad05b683963f69a1d5d2c2bdab1274a31221ca737dbbceaa32bcb67359453cdd"},
+ {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df6a94bf9452c6da9b5d76ed229a5683d0306ccb91cca8e1eea883189780d568"},
+ {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7965c13b3967909a09ecc91f21d09cfc4576bf78140b988904e94f130f188396"},
+ {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3f1fdb790440a34f6ecf7679e1863b825cb5ffde858a9197f851168ed08371e5"},
+ {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:5277aec8d879f8d05168fdd17ae811dd313b8ff894aeeaf7cd34ad28b4d77e33"},
+ {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8ab581d3530611897d863d1a649fb0644b860286b4718db919bfd51ece41f10b"},
+ {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0483847fa9ad5e3412265c1bd72aad35235512d9ce9d27d81a56d935ef489672"},
+ {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:de9e06abe3cc5ec6a2d5f75bc99b0bdca4f5c719a5b34026f8c57efbdecd2ee3"},
+ {file = "pydantic_core-2.33.1.tar.gz", hash = "sha256:bcc9c6fdb0ced789245b02b7d6603e17d1563064ddcfc36f046b61c0c05dd9df"},
]
[package.dependencies]
@@ -2251,14 +2251,14 @@ files = [
[[package]]
name = "typing-extensions"
-version = "4.13.0"
+version = "4.13.1"
description = "Backported and Experimental Type Hints for Python 3.8+"
optional = false
python-versions = ">=3.8"
groups = ["main", "dev"]
files = [
- {file = "typing_extensions-4.13.0-py3-none-any.whl", hash = "sha256:c8dd92cc0d6425a97c18fbb9d1954e5ff92c1ca881a309c45f06ebc0b79058e5"},
- {file = "typing_extensions-4.13.0.tar.gz", hash = "sha256:0a4ac55a5820789d87e297727d229866c9650f6521b64206413c4fbada24d95b"},
+ {file = "typing_extensions-4.13.1-py3-none-any.whl", hash = "sha256:4b6cf02909eb5495cfbc3f6e8fd49217e6cc7944e145cdda8caa3734777f9e69"},
+ {file = "typing_extensions-4.13.1.tar.gz", hash = "sha256:98795af00fb9640edec5b8e31fc647597b4691f099ad75f469a2616be1a76dff"},
]
[[package]]
diff --git a/tests/api/v1/settings/__init__.py b/tests/api/v1/settings/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/api/v1/settings/test_users_routes.py b/tests/api/v1/settings/test_users_routes.py
new file mode 100644
index 00000000..d5561322
--- /dev/null
+++ b/tests/api/v1/settings/test_users_routes.py
@@ -0,0 +1,17 @@
+import pytest
+
+from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS
+
+
+@pytest.mark.asyncio
+async def test_create_user(async_client, test_data):
+ user_to_create = {"username": "created@goosebit.test", "permissions": [GOOSEBIT_PERMISSIONS["device"]["read"]()]}
+
+ response = await async_client.post(f"/api/v1/settings/users", json={"password": "testcreated", **user_to_create})
+ assert response.status_code == 200
+
+ user_to_create["enabled"] = True
+
+ response = await async_client.get(f"/api/v1/settings/users")
+ users = response.json()
+ assert user_to_create in users["users"]
diff --git a/tests/auth/test_permissions.py b/tests/auth/test_permissions.py
index 7974b66c..aea6d464 100644
--- a/tests/auth/test_permissions.py
+++ b/tests/auth/test_permissions.py
@@ -1,49 +1,43 @@
from goosebit.auth import check_permissions
+from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS
def test_single_permission():
- assert check_permissions(["device.read"], ["device.read"])
-
-
-def test_inverted_single_permission():
- assert not check_permissions(["device.read"], ["!device.read"])
+ assert check_permissions([GOOSEBIT_PERMISSIONS["device"]["read"]()], ["goosebit.device.read"])
def test_wildcard_sub_permission():
- assert check_permissions(["device.read"], ["device.*"])
-
-
-def test_inverted_wildcard_sub_permission():
- assert not check_permissions(["device.read"], ["!device.*"])
+ assert check_permissions([GOOSEBIT_PERMISSIONS["device"]["read"]()], ["goosebit.device.*"])
def test_root_permission():
- assert check_permissions(["device.read"], ["device"])
-
-
-def test_inverted_root_permission():
- assert not check_permissions(["device.read"], ["!device"])
+ assert check_permissions([GOOSEBIT_PERMISSIONS["device"]["read"]()], ["goosebit.device"])
def test_root_wildcard_permission():
- assert check_permissions(["device.read"], ["*"])
-
-
-def test_inverted_root_wildcard_permission():
- assert not check_permissions(["device.read"], ["!*"])
+ assert check_permissions([GOOSEBIT_PERMISSIONS["device"]["read"]()], ["*"])
def test_multiple_single_permissions():
- assert check_permissions(["device.read", "device.write"], ["device.read", "device.write"])
+ assert check_permissions(
+ [GOOSEBIT_PERMISSIONS["device"]["read"](), GOOSEBIT_PERMISSIONS["device"]["write"]()],
+ ["goosebit.device.read", "goosebit.device.write"],
+ )
def test_invalid_multiple_single_permissions():
- assert not check_permissions(["device.read", "device.write"], ["device.read", "device.read"])
-
-
-def test_inverted_multiple_permissions():
- assert not check_permissions(["device.read", "device.write"], ["device.read", "device", "!device.write"])
+ assert not check_permissions(
+ [GOOSEBIT_PERMISSIONS["device"]["read"](), GOOSEBIT_PERMISSIONS["device"]["write"]()],
+ ["goosebit.device.read", "goosebit.device.read"],
+ )
def test_multiple_root_wildcard_permissions():
- assert check_permissions(["device.write", "device.read", "software.read"], ["*.read", "device.write"])
+ assert check_permissions(
+ [
+ GOOSEBIT_PERMISSIONS["device"]["write"](),
+ GOOSEBIT_PERMISSIONS["device"]["read"](),
+ GOOSEBIT_PERMISSIONS["software"]["read"](),
+ ],
+ ["goosebit.*.read", "goosebit.device.write"],
+ )
diff --git a/tests/ui/__init__.py b/tests/ui/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/ui/bff/__init__.py b/tests/ui/bff/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/ui/bff/settings/__init__.py b/tests/ui/bff/settings/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/ui/bff/settings/test_users_routes.py b/tests/ui/bff/settings/test_users_routes.py
new file mode 100644
index 00000000..db558315
--- /dev/null
+++ b/tests/ui/bff/settings/test_users_routes.py
@@ -0,0 +1,23 @@
+import pytest
+
+
+@pytest.mark.asyncio
+async def test_list_users_username_asc(async_client, test_data):
+ response = await async_client.get(f"/ui/bff/settings/users?order[0][dir]=asc&order[0][name]=username")
+
+ assert response.status_code == 200
+ users = response.json()["data"]
+ assert len(users) == 2
+ assert users[0]["username"] == test_data["user_admin"].username
+ assert users[1]["username"] == test_data["user_read_only"].username
+
+
+@pytest.mark.asyncio
+async def test_list_users_username_desc(async_client, test_data):
+ response = await async_client.get(f"/ui/bff/settings/users?order[0][dir]=desc&order[0][name]=username")
+
+ assert response.status_code == 200
+ users = response.json()["data"]
+ assert len(users) == 2
+ assert users[0]["username"] == test_data["user_read_only"].username
+ assert users[1]["username"] == test_data["user_admin"].username