diff --git a/app/api/routes/questions.py b/app/api/routes/questions.py index fbc36df..145c953 100644 --- a/app/api/routes/questions.py +++ b/app/api/routes/questions.py @@ -1,5 +1,6 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends +from app.rbac import check_for_permission from app.settings import settings from app.utils.http_client import make_request @@ -10,7 +11,9 @@ @router.get("/daily_questions") -def get_daily_questions(): +def get_daily_questions( + _check_permission=Depends(check_for_permission("VIEW_QUESTIONS")), +): """Fetch daily questions from the external generator service.""" url = f"{GENERATOR_SERVICE_URL}/api/v1/questions/get_daily_question" return make_request("GET", url) diff --git a/app/api/routes/user.py b/app/api/routes/user.py index fa03de0..747a67d 100644 --- a/app/api/routes/user.py +++ b/app/api/routes/user.py @@ -36,5 +36,7 @@ def read_users_me(current_user=Depends(get_current_user)): "id": current_user.id, "email": current_user.email, "name": current_user.name, + "role": current_user.role, + "auth_type": current_user.auth_type, } return {"user": current_user} diff --git a/app/migrations/versions/417469bac4de_removed_auth_type_from_otp.py b/app/migrations/versions/417469bac4de_removed_auth_type_from_otp.py new file mode 100644 index 0000000..f7bca05 --- /dev/null +++ b/app/migrations/versions/417469bac4de_removed_auth_type_from_otp.py @@ -0,0 +1,40 @@ +"""removed auth-type from OTP + +Revision ID: 417469bac4de +Revises: a6d1c6c13842 +Create Date: 2025-08-31 09:57:49.661025 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "417469bac4de" +down_revision: Union[str, Sequence[str], None] = "a6d1c6c13842" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("otps", "provider_id") + op.drop_column("otps", "auth_type") + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "otps", + sa.Column("auth_type", sa.VARCHAR(), autoincrement=False, nullable=False), + ) + op.add_column( + "otps", + sa.Column("provider_id", sa.VARCHAR(), autoincrement=False, nullable=True), + ) + # ### end Alembic commands ### diff --git a/app/migrations/versions/a6d1c6c13842_add_role_field.py b/app/migrations/versions/a6d1c6c13842_add_role_field.py new file mode 100644 index 0000000..47d592e --- /dev/null +++ b/app/migrations/versions/a6d1c6c13842_add_role_field.py @@ -0,0 +1,38 @@ +"""add role field + +Revision ID: a6d1c6c13842 +Revises: a4b5fabfdeac +Create Date: 2025-08-31 09:15:05.419403 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "a6d1c6c13842" +down_revision: Union[str, Sequence[str], None] = "a4b5fabfdeac" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + userrole_enum = sa.Enum("ADMIN", "USER", name="userrole") + userrole_enum.create(op.get_bind()) + + # Then add the column using the enum type + op.add_column("users", sa.Column("role", userrole_enum, nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("users", "role") + # Then drop the enum type + sa.Enum(name="userrole").drop(op.get_bind()) + # ### end Alembic commands ### diff --git a/app/models/otp.py b/app/models/otp.py index d2f221f..bb09e56 100644 --- a/app/models/otp.py +++ b/app/models/otp.py @@ -13,8 +13,6 @@ class OTP(Base): name = Column(String, nullable=False) password = Column(String, nullable=True) is_active = Column(Integer, default=1) # 1 for True, 0 for False - provider_id = Column(String, nullable=True) - auth_type = Column(String, nullable=False, default="local") created_at = Column(DateTime, default=datetime.utcnow) expires_at = Column(DateTime, nullable=False) diff --git a/app/models/user.py b/app/models/user.py index 044f4d0..7e173d3 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -1,18 +1,9 @@ -from enum import Enum - from sqlalchemy import Boolean, Column, DateTime from sqlalchemy import Enum as SQLEnum from sqlalchemy import Integer, String from app.config.database import Base - - -# TODO: removed as AuthType is now in app.schemas.users -class AuthType(Enum): - LOCAL = "local" - GOOGLE = "google" - GITHUB = "github" - # Add more as needed +from app.utils.enums import AuthType, UserRole class User(Base): @@ -27,4 +18,5 @@ class User(Base): String, unique=True, nullable=True ) # For OAuth or external providers auth_type = Column(SQLEnum(AuthType), nullable=False, default=AuthType.LOCAL) + role = Column(SQLEnum(UserRole), nullable=False, default=UserRole.USER) created_at = Column(DateTime, nullable=False) diff --git a/app/rbac.py b/app/rbac.py new file mode 100644 index 0000000..672d420 --- /dev/null +++ b/app/rbac.py @@ -0,0 +1,30 @@ +from fastapi import Depends, HTTPException, status + +from app.services.user import get_current_user +from app.utils.enums import UserRole + +FEATURES = [ + "VIEW_QUESTIONS", + "MODIFY_QUESTIONS", +] + +USER_FEATURES = [ + "VIEW_QUESTIONS", +] + +FEATURE_ROLE_MAP = { + UserRole.ADMIN: FEATURES, + UserRole.USER: USER_FEATURES, +} + + +def check_for_permission(feature_name: str): + def checker(current_user=Depends(get_current_user)): + allowed_features = FEATURE_ROLE_MAP.get(current_user.role, []) + if feature_name not in allowed_features: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions" + ) + return True + + return checker diff --git a/app/schemas/users.py b/app/schemas/users.py index 49da157..6c9189e 100644 --- a/app/schemas/users.py +++ b/app/schemas/users.py @@ -1,28 +1,23 @@ from datetime import datetime -from enum import Enum from typing import Optional from pydantic import BaseModel, EmailStr +from app.utils.enums import AuthType, UserRole + class OTPVerifyRequest(BaseModel): email: EmailStr otp_code: str -class AuthType(str, Enum): - local = "local" - google = "google" - github = "github" - # Add more as needed - - class UserBase(BaseModel): email: EmailStr name: str is_active: bool = True - auth_type: AuthType = AuthType.local + auth_type: AuthType = AuthType.LOCAL provider_id: Optional[str] = None + role: UserRole = UserRole.USER class UserCreate(UserBase): @@ -36,6 +31,7 @@ class UserUpdate(BaseModel): is_active: Optional[bool] = True auth_type: Optional[AuthType] = None provider_id: Optional[str] = None + role: Optional[UserRole] = None class UserInDB(UserBase): diff --git a/app/services/auth.py b/app/services/auth.py index b2ec8ea..9354751 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -7,9 +7,10 @@ from app.config.github import github from app.config.google import google from app.models.user import User -from app.schemas.users import AuthType, UserCreate +from app.schemas.users import UserCreate from app.services.user import create_user from app.utils.auth import create_access_token, verify_password +from app.utils.enums import AuthType def authenticate_user(db, email: str, password: str): @@ -41,7 +42,7 @@ def create_github_user(profile, email: str, db): name=name, is_active=True, provider_id=provider_id, - auth_type=AuthType.github, + auth_type=AuthType.GITHUB, ) user = create_user(db, user_in) return user @@ -56,7 +57,7 @@ def create_google_user(profile, email: str, db): name=name, is_active=True, provider_id=provider_id, - auth_type=AuthType.google, + auth_type=AuthType.GOOGLE, ) user = create_user(db, user_in) return user diff --git a/app/services/user.py b/app/services/user.py index f0599f1..f09786c 100644 --- a/app/services/user.py +++ b/app/services/user.py @@ -28,7 +28,7 @@ def create_user(db: Session, user_in: UserCreate) -> User: if existing: raise HTTPException(status_code=400, detail="Email already registered") - hashed_password = hash_password(user_in.password) if user_in.password else None + hashed_password = user_in.password if user_in.password else None user = User( email=user_in.email, name=user_in.name, @@ -96,9 +96,6 @@ def register_with_otp(db: Session, user_in: UserCreate): name=user_in.name, password=hashed_password, is_active=1 if getattr(user_in, "is_active", True) else 0, - provider_id=getattr(user_in, "provider_id", None), - # TODO: update this to use only local auth type - auth_type=user_in.auth_type.value if hasattr(user_in, "auth_type") else "local", created_at=datetime.utcnow(), expires_at=expires_at, ) @@ -128,8 +125,6 @@ def verify_otp_and_create_user(db: Session, email: str, otp_code: str): name=otp_entry.name, password=otp_entry.password, # Already hashed is_active=bool(otp_entry.is_active), - provider_id=otp_entry.provider_id, - auth_type=otp_entry.auth_type, ) try: user = create_user(db, user_in) diff --git a/app/utils/enums.py b/app/utils/enums.py new file mode 100644 index 0000000..4613201 --- /dev/null +++ b/app/utils/enums.py @@ -0,0 +1,14 @@ +from enum import Enum + + +# TODO: removed as AuthType is now in app.schemas.users +class AuthType(Enum): + LOCAL = "local" + GOOGLE = "google" + GITHUB = "github" + # Add more as needed + + +class UserRole(Enum): + ADMIN = "admin" + USER = "user"