From db0f47a2c976afcfbd93e914f10efb64fb90054c Mon Sep 17 00:00:00 2001 From: shopipoint Date: Mon, 11 May 2026 06:01:18 +0300 Subject: [PATCH 1/2] Add backend role-based access control --- .../versions/b3a8e8a9d5f2_add_user_role.py | 28 +++++++++++++ backend/app/api/deps.py | 22 +++++++++- backend/app/api/main.py | 3 +- backend/app/api/routes/metrics.py | 23 +++++++++++ backend/app/api/routes/users.py | 7 +++- backend/app/core/db.py | 23 ++++++++++- backend/app/crud.py | 14 ++++++- backend/app/models.py | 15 ++++++- backend/tests/api/routes/test_metrics.py | 28 +++++++++++++ backend/tests/api/routes/test_users.py | 41 ++++++++++++++++++- backend/tests/utils/user.py | 16 +++++++- 11 files changed, 208 insertions(+), 12 deletions(-) create mode 100644 backend/app/alembic/versions/b3a8e8a9d5f2_add_user_role.py create mode 100644 backend/app/api/routes/metrics.py create mode 100644 backend/tests/api/routes/test_metrics.py diff --git a/backend/app/alembic/versions/b3a8e8a9d5f2_add_user_role.py b/backend/app/alembic/versions/b3a8e8a9d5f2_add_user_role.py new file mode 100644 index 0000000000..8137763a0e --- /dev/null +++ b/backend/app/alembic/versions/b3a8e8a9d5f2_add_user_role.py @@ -0,0 +1,28 @@ +"""Add user role + +Revision ID: b3a8e8a9d5f2 +Revises: fe56fa70289e +Create Date: 2026-05-05 19:15:00.000000 + +""" +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "b3a8e8a9d5f2" +down_revision = "fe56fa70289e" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + "user", + sa.Column("role", sa.String(length=20), nullable=False, server_default="member"), + ) + op.execute("UPDATE \"user\" SET role = 'admin' WHERE is_superuser = true") + + +def downgrade(): + op.drop_column("user", "role") diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index c2b83c841d..534234587b 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -11,7 +11,7 @@ from app.core import security from app.core.config import settings from app.core.db import engine -from app.models import TokenPayload, User +from app.models import TokenPayload, User, UserRole reusable_oauth2 = OAuth2PasswordBearer( tokenUrl=f"{settings.API_V1_STR}/login/access-token" @@ -49,8 +49,26 @@ def get_current_user(session: SessionDep, token: TokenDep) -> User: CurrentUser = Annotated[User, Depends(get_current_user)] +def current_role(user: User) -> UserRole: + if user.is_superuser: + return UserRole.admin + return UserRole(user.role) + + +def require_roles(*allowed_roles: UserRole): + def role_dependency(current_user: CurrentUser) -> User: + if current_role(current_user) not in allowed_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="The user doesn't have enough privileges", + ) + return current_user + + return role_dependency + + def get_current_active_superuser(current_user: CurrentUser) -> User: - if not current_user.is_superuser: + if current_role(current_user) != UserRole.admin: raise HTTPException( status_code=403, detail="The user doesn't have enough privileges" ) diff --git a/backend/app/api/main.py b/backend/app/api/main.py index eac18c8e8f..d45ac306db 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.routes import items, login, private, users, utils +from app.api.routes import items, login, metrics, private, users, utils from app.core.config import settings api_router = APIRouter() @@ -8,6 +8,7 @@ api_router.include_router(users.router) api_router.include_router(utils.router) api_router.include_router(items.router) +api_router.include_router(metrics.router) if settings.ENVIRONMENT == "local": diff --git a/backend/app/api/routes/metrics.py b/backend/app/api/routes/metrics.py new file mode 100644 index 0000000000..1c3ad97fca --- /dev/null +++ b/backend/app/api/routes/metrics.py @@ -0,0 +1,23 @@ +from typing import Any + +from fastapi import APIRouter, Depends +from sqlmodel import func, select + +from app.api.deps import SessionDep, require_roles +from app.models import Item, User, UserRole + +router = APIRouter(prefix="/metrics", tags=["metrics"]) + + +@router.get("/", dependencies=[Depends(require_roles(UserRole.admin, UserRole.manager))]) +def read_metrics(session: SessionDep) -> dict[str, Any]: + """ + Return simple operational metrics for privileged users. + """ + user_count = session.exec(select(func.count()).select_from(User)).one() + item_count = session.exec(select(func.count()).select_from(Item)).one() + return { + "users": user_count, + "items": item_count, + "summary": "Demo insights visible to admins and managers.", + } diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 1748f58484..0e10a84784 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -8,7 +8,9 @@ from app.api.deps import ( CurrentUser, SessionDep, + current_role, get_current_active_superuser, + require_roles, ) from app.core.config import settings from app.core.security import get_password_hash, verify_password @@ -20,6 +22,7 @@ UserCreate, UserPublic, UserRegister, + UserRole, UsersPublic, UserUpdate, UserUpdateMe, @@ -31,7 +34,7 @@ @router.get( "/", - dependencies=[Depends(get_current_active_superuser)], + dependencies=[Depends(require_roles(UserRole.admin, UserRole.manager))], response_model=UsersPublic, ) def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any: @@ -169,7 +172,7 @@ def read_user_by_id( user = session.get(User, user_id) if user == current_user: return user - if not current_user.is_superuser: + if current_role(current_user) not in {UserRole.admin, UserRole.manager}: raise HTTPException( status_code=403, detail="The user doesn't have enough privileges", diff --git a/backend/app/core/db.py b/backend/app/core/db.py index ba991fb36d..e679ce8c11 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -2,7 +2,7 @@ from app import crud from app.core.config import settings -from app.models import User, UserCreate +from app.models import User, UserCreate, UserRole engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)) @@ -29,5 +29,26 @@ def init_db(session: Session) -> None: email=settings.FIRST_SUPERUSER, password=settings.FIRST_SUPERUSER_PASSWORD, is_superuser=True, + role=UserRole.admin, ) user = crud.create_user(session=session, user_create=user_in) + elif user.role != UserRole.admin: + user.role = UserRole.admin + user.is_superuser = True + session.add(user) + session.commit() + + seed_users = [ + ("manager@example.com", "changethis", UserRole.manager, "Demo Manager"), + ("member@example.com", "changethis", UserRole.member, "Demo Member"), + ] + for email, password, role, full_name in seed_users: + existing_user = session.exec(select(User).where(User.email == email)).first() + if not existing_user: + user_in = UserCreate( + email=email, + password=password, + role=role, + full_name=full_name, + ) + crud.create_user(session=session, user_create=user_in) diff --git a/backend/app/crud.py b/backend/app/crud.py index a8ceba6444..252d44c78c 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -4,12 +4,18 @@ from sqlmodel import Session, select from app.core.security import get_password_hash, verify_password -from app.models import Item, ItemCreate, User, UserCreate, UserUpdate +from app.models import Item, ItemCreate, User, UserCreate, UserRole, UserUpdate def create_user(*, session: Session, user_create: UserCreate) -> User: + role = UserRole.admin if user_create.is_superuser else user_create.role db_obj = User.model_validate( - user_create, update={"hashed_password": get_password_hash(user_create.password)} + user_create, + update={ + "hashed_password": get_password_hash(user_create.password), + "role": role, + "is_superuser": role == UserRole.admin, + }, ) session.add(db_obj) session.commit() @@ -24,6 +30,10 @@ def update_user(*, session: Session, db_user: User, user_in: UserUpdate) -> Any: password = user_data["password"] hashed_password = get_password_hash(password) extra_data["hashed_password"] = hashed_password + if user_data.get("role") == UserRole.admin: + extra_data["is_superuser"] = True + elif user_data.get("role") in {UserRole.manager, UserRole.member}: + extra_data["is_superuser"] = False db_user.sqlmodel_update(user_data, update=extra_data) session.add(db_user) session.commit() diff --git a/backend/app/models.py b/backend/app/models.py index 0ae3cf6574..f2275f4221 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,8 +1,9 @@ import uuid from datetime import datetime, timezone +from enum import Enum from pydantic import EmailStr -from sqlalchemy import DateTime +from sqlalchemy import Column, DateTime, String from sqlmodel import Field, Relationship, SQLModel @@ -10,11 +11,23 @@ def get_datetime_utc() -> datetime: return datetime.now(timezone.utc) +class UserRole(str, Enum): + admin = "admin" + manager = "manager" + member = "member" + + # Shared properties class UserBase(SQLModel): email: EmailStr = Field(unique=True, index=True, max_length=255) is_active: bool = True is_superuser: bool = False + role: UserRole = Field( + default=UserRole.member, + sa_column=Column( + String(20), nullable=False, server_default=UserRole.member.value + ), + ) full_name: str | None = Field(default=None, max_length=255) diff --git a/backend/tests/api/routes/test_metrics.py b/backend/tests/api/routes/test_metrics.py new file mode 100644 index 0000000000..9439a0c303 --- /dev/null +++ b/backend/tests/api/routes/test_metrics.py @@ -0,0 +1,28 @@ +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app.core.config import settings +from app.models import UserRole +from tests.utils.user import create_user_with_role, user_authentication_headers + + +def test_manager_can_read_metrics(client: TestClient, db: Session) -> None: + manager, password = create_user_with_role(db=db, role=UserRole.manager) + headers = user_authentication_headers( + client=client, email=manager.email, password=password + ) + + r = client.get(f"{settings.API_V1_STR}/metrics/", headers=headers) + + assert r.status_code == 200 + assert "users" in r.json() + assert "items" in r.json() + + +def test_member_cannot_read_metrics( + client: TestClient, normal_user_token_headers: dict[str, str] +) -> None: + r = client.get(f"{settings.API_V1_STR}/metrics/", headers=normal_user_token_headers) + + assert r.status_code == 403 + assert r.json()["detail"] == "The user doesn't have enough privileges" diff --git a/backend/tests/api/routes/test_users.py b/backend/tests/api/routes/test_users.py index 9c4cdd5991..4c1a090175 100644 --- a/backend/tests/api/routes/test_users.py +++ b/backend/tests/api/routes/test_users.py @@ -7,8 +7,12 @@ from app import crud from app.core.config import settings from app.core.security import verify_password -from app.models import User, UserCreate -from tests.utils.user import create_random_user +from app.models import User, UserCreate, UserRole +from tests.utils.user import ( + create_random_user, + create_user_with_role, + user_authentication_headers, +) from tests.utils.utils import random_email, random_lower_string @@ -519,3 +523,36 @@ def test_delete_user_without_privileges( ) assert r.status_code == 403 assert r.json()["detail"] == "The user doesn't have enough privileges" + + +def test_manager_can_retrieve_users(client: TestClient, db: Session) -> None: + manager, password = create_user_with_role(db=db, role=UserRole.manager) + headers = user_authentication_headers( + client=client, email=manager.email, password=password + ) + + r = client.get(f"{settings.API_V1_STR}/users/", headers=headers) + + assert r.status_code == 200 + assert "data" in r.json() + + +def test_member_cannot_retrieve_users( + client: TestClient, normal_user_token_headers: dict[str, str] +) -> None: + r = client.get(f"{settings.API_V1_STR}/users/", headers=normal_user_token_headers) + + assert r.status_code == 403 + assert r.json()["detail"] == "The user doesn't have enough privileges" + + +def test_manager_cannot_create_user(client: TestClient, db: Session) -> None: + manager, password = create_user_with_role(db=db, role=UserRole.manager) + headers = user_authentication_headers( + client=client, email=manager.email, password=password + ) + data = {"email": random_email(), "password": random_lower_string()} + + r = client.post(f"{settings.API_V1_STR}/users/", headers=headers, json=data) + + assert r.status_code == 403 diff --git a/backend/tests/utils/user.py b/backend/tests/utils/user.py index 5867431ed8..e005d0f457 100644 --- a/backend/tests/utils/user.py +++ b/backend/tests/utils/user.py @@ -3,7 +3,7 @@ from app import crud from app.core.config import settings -from app.models import User, UserCreate, UserUpdate +from app.models import User, UserCreate, UserRole, UserUpdate from tests.utils.utils import random_email, random_lower_string @@ -27,6 +27,20 @@ def create_random_user(db: Session) -> User: return user +def create_user_with_role( + *, db: Session, role: UserRole, email: str | None = None +) -> tuple[User, str]: + password = random_lower_string() + user_in = UserCreate( + email=email or random_email(), + password=password, + role=role, + is_superuser=role == UserRole.admin, + ) + user = crud.create_user(session=db, user_create=user_in) + return user, password + + def authentication_token_from_email( *, client: TestClient, email: str, db: Session ) -> dict[str, str]: From fd97c115abe47be872e4b3c8adbacf26f62e9e21 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 03:03:36 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format=20and=20update?= =?UTF-8?q?=20with=20pre-commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/routes/metrics.py | 4 +++- frontend/src/client/schemas.gen.ts | 18 ++++++++++++++++++ frontend/src/client/sdk.gen.ts | 17 ++++++++++++++++- frontend/src/client/types.gen.ts | 9 +++++++++ 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/backend/app/api/routes/metrics.py b/backend/app/api/routes/metrics.py index 1c3ad97fca..c97f998db8 100644 --- a/backend/app/api/routes/metrics.py +++ b/backend/app/api/routes/metrics.py @@ -9,7 +9,9 @@ router = APIRouter(prefix="/metrics", tags=["metrics"]) -@router.get("/", dependencies=[Depends(require_roles(UserRole.admin, UserRole.manager))]) +@router.get( + "/", dependencies=[Depends(require_roles(UserRole.admin, UserRole.manager))] +) def read_metrics(session: SessionDep) -> dict[str, Any]: """ Return simple operational metrics for privileged users. diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index fb66c1f837..2c4a373a46 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -306,6 +306,10 @@ export const UserCreateSchema = { title: 'Is Superuser', default: false }, + role: { + '$ref': '#/components/schemas/UserRole', + default: 'member' + }, full_name: { anyOf: [ { @@ -348,6 +352,10 @@ export const UserPublicSchema = { title: 'Is Superuser', default: false }, + role: { + '$ref': '#/components/schemas/UserRole', + default: 'member' + }, full_name: { anyOf: [ { @@ -415,6 +423,12 @@ export const UserRegisterSchema = { title: 'UserRegister' } as const; +export const UserRoleSchema = { + type: 'string', + enum: ['admin', 'manager', 'member'], + title: 'UserRole' +} as const; + export const UserUpdateSchema = { properties: { email: { @@ -440,6 +454,10 @@ export const UserUpdateSchema = { title: 'Is Superuser', default: false }, + role: { + '$ref': '#/components/schemas/UserRole', + default: 'member' + }, full_name: { anyOf: [ { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index ba79e3f726..5c57c2b479 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen'; +import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MetricsReadMetricsResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen'; export class ItemsService { /** @@ -213,6 +213,21 @@ export class LoginService { } } +export class MetricsService { + /** + * Read Metrics + * Return simple operational metrics for privileged users. + * @returns unknown Successful Response + * @throws ApiError + */ + public static readMetrics(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/metrics/' + }); + } +} + export class PrivateService { /** * Create User diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 91b5ba34c2..85b908b11c 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -66,6 +66,7 @@ export type UserCreate = { email: string; is_active?: boolean; is_superuser?: boolean; + role?: UserRole; full_name?: (string | null); password: string; }; @@ -74,6 +75,7 @@ export type UserPublic = { email: string; is_active?: boolean; is_superuser?: boolean; + role?: UserRole; full_name?: (string | null); id: string; created_at?: (string | null); @@ -85,6 +87,8 @@ export type UserRegister = { full_name?: (string | null); }; +export type UserRole = 'admin' | 'manager' | 'member'; + export type UsersPublic = { data: Array; count: number; @@ -94,6 +98,7 @@ export type UserUpdate = { email?: (string | null); is_active?: boolean; is_superuser?: boolean; + role?: UserRole; full_name?: (string | null); password?: (string | null); }; @@ -171,6 +176,10 @@ export type LoginRecoverPasswordHtmlContentData = { export type LoginRecoverPasswordHtmlContentResponse = (string); +export type MetricsReadMetricsResponse = ({ + [key: string]: unknown; +}); + export type PrivateCreateUserData = { requestBody: PrivateUserCreate; };