Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
306b602
refactor: use list of permissions in nav route instead of str
b-rowan Mar 24, 2025
d14eb4b
refactor: move user handling to database
b-rowan Mar 24, 2025
828720a
refactor: add a more strict typing style for permissions
b-rowan Mar 24, 2025
b35d4ad
feature: add settings page to display user data
b-rowan Apr 1, 2025
43b4825
feature: add `/settings/users` to the API
b-rowan Apr 2, 2025
a7988a7
refactor: rework rollouts add modal to make it more similar to other …
b-rowan Apr 3, 2025
6443974
feature: allow creating new users via the UI and API
b-rowan Apr 3, 2025
ec1fbff
feature: allow deleting, enabling, and disabling users via the UI and…
b-rowan Apr 3, 2025
91bd4dc
tests: remove inverted permission tests
b-rowan Apr 7, 2025
64725a0
tests: modify tests for new permissions style
b-rowan Apr 7, 2025
696a89a
tests: add users tests for bff and api v1
b-rowan Apr 7, 2025
9e7e63f
ui: add setup page to set up initial user if none is set up
b-rowan Apr 7, 2025
42a9f45
refactor: remove `inital_user` setting and setup calls
b-rowan Apr 8, 2025
180cffb
refactor: pre-commit updates
b-rowan Apr 21, 2025
3010bab
ui: add create admin user prompt
b-rowan Apr 21, 2025
39aeccf
refactor: auto redirect a user to any page they are authorized to access
b-rowan Apr 21, 2025
d693a12
refactor: show settings if ser has enough permissions, and allow redi…
b-rowan Apr 21, 2025
b9a0cf1
ui: add user creation input validation
b-rowan Apr 21, 2025
cdbaddc
ui: clean up some minor issues
b-rowan Apr 21, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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


Expand All @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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,
)
7 changes: 2 additions & 5 deletions docker/demo/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: "*"
Expand Down
14 changes: 0 additions & 14 deletions goosebit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
18 changes: 14 additions & 4 deletions goosebit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 1 addition & 5 deletions goosebit/api/telemetry/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -28,7 +28,3 @@
"users.count",
description="The number of registered users",
)


async def init():
users_count.set(len(USERS))
5 changes: 3 additions & 2 deletions goosebit/api/v1/devices/device/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand All @@ -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:
Expand Down
9 changes: 5 additions & 4 deletions goosebit/api/v1/devices/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
9 changes: 5 additions & 4 deletions goosebit/api/v1/rollouts/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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()
Expand Down
3 changes: 2 additions & 1 deletion goosebit/api/v1/routes.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions goosebit/api/v1/settings/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .routes import router # noqa: F401
14 changes: 14 additions & 0 deletions goosebit/api/v1/settings/routes.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions goosebit/api/v1/settings/users/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .routes import router # noqa: F401
16 changes: 16 additions & 0 deletions goosebit/api/v1/settings/users/requests.py
Original file line number Diff line number Diff line change
@@ -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]
7 changes: 7 additions & 0 deletions goosebit/api/v1/settings/users/responses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from pydantic import BaseModel

from goosebit.schema.users import UserSchema


class SettingsUsersResponse(BaseModel):
users: list[UserSchema]
56 changes: 56 additions & 0 deletions goosebit/api/v1/settings/users/routes.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading