├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── alembic.ini ├── app ├── __init__.py ├── database.py ├── internal │ ├── __init__.py │ ├── auth.py │ ├── crud │ │ ├── __init__.py │ │ ├── base.py │ │ ├── permission.py │ │ ├── role.py │ │ └── user.py │ └── health.py ├── models │ ├── __init__.py │ ├── permission.py │ ├── role.py │ ├── role_permission.py │ ├── user.py │ └── user_role.py ├── routers │ ├── __init__.py │ ├── auth.py │ ├── health.py │ ├── healthcheck_heroku.py │ ├── permission.py │ ├── role.py │ ├── shutdown.py │ ├── startup.py │ └── user.py ├── schemas │ ├── __init__.py │ ├── auth.py │ ├── permission.py │ ├── renew_token.py │ ├── role.py │ ├── settings.py │ ├── token.py │ └── user.py └── settings.py ├── main.py ├── migrations ├── README ├── env.py ├── script.py.mako └── versions │ └── .gitkeep ├── requirements.txt ├── scripts ├── __init__.py └── initial_data.py └── tests ├── __init__.py ├── test_permission.py ├── test_role.py └── test_user.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | .idea/ 6 | main.build/ 7 | *.db 8 | # C extensions 9 | *.so 10 | *.c 11 | *.const 12 | # Prerequisites 13 | *.d 14 | *.h 15 | 16 | # Object files 17 | *.o 18 | *.ko 19 | *.obj 20 | *.elf 21 | 22 | # Linker output 23 | *.ilk 24 | *.map 25 | *.exp 26 | 27 | # Precompiled Headers 28 | *.gch 29 | *.pch 30 | 31 | # Libraries 32 | *.lib 33 | *.a 34 | *.la 35 | *.lo 36 | 37 | # Shared objects (inc. Windows DLLs) 38 | *.dll 39 | *.so 40 | *.so.* 41 | *.dylib 42 | 43 | # Executables 44 | *.exe 45 | *.out 46 | *.app 47 | *.i*86 48 | *.x86_64 49 | *.hex 50 | 51 | # Debug files 52 | *.dSYM/ 53 | *.su 54 | *.idb 55 | *.pdb 56 | 57 | # Kernel Module Compile Results 58 | *.mod* 59 | *.cmd 60 | .tmp_versions/ 61 | modules.order 62 | Module.symvers 63 | Mkfile.old 64 | dkms.conf 65 | # Distribution / packaging 66 | .Python 67 | build/ 68 | develop-eggs/ 69 | dist/ 70 | downloads/ 71 | eggs/ 72 | .eggs/ 73 | lib/ 74 | lib64/ 75 | parts/ 76 | sdist/ 77 | var/ 78 | wheels/ 79 | pip-wheel-metadata/ 80 | share/python-wheels/ 81 | *.egg-info/ 82 | .installed.cfg 83 | *.egg 84 | MANIFEST 85 | 86 | # PyInstaller 87 | # Usually these files are written by a python script from a template 88 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 89 | *.manifest 90 | *.spec 91 | 92 | # Installer logs 93 | pip-log.txt 94 | pip-delete-this-directory.txt 95 | 96 | # Unit test / coverage reports 97 | htmlcov/ 98 | .tox/ 99 | .nox/ 100 | .coverage 101 | .coverage.* 102 | .cache 103 | nosetests.xml 104 | coverage.xml 105 | *.cover 106 | *.py,cover 107 | .hypothesis/ 108 | .pytest_cache/ 109 | 110 | # Translations 111 | *.mo 112 | *.pot 113 | 114 | # Django stuff: 115 | *.log 116 | local_settings.py 117 | db.sqlite3 118 | db.sqlite3-journal 119 | 120 | # Flask stuff: 121 | instance/ 122 | .webassets-cache 123 | 124 | # Scrapy stuff: 125 | .scrapy 126 | 127 | # Sphinx documentation 128 | docs/_build/ 129 | 130 | # PyBuilder 131 | target/ 132 | 133 | # Jupyter Notebook 134 | .ipynb_checkpoints 135 | 136 | # IPython 137 | profile_default/ 138 | ipython_config.py 139 | 140 | # pyenv 141 | .python-version 142 | 143 | # pipenv 144 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 145 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 146 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 147 | # install all needed dependencies. 148 | #Pipfile.lock 149 | 150 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 151 | __pypackages__/ 152 | 153 | # Celery stuff 154 | celerybeat-schedule 155 | celerybeat.pid 156 | 157 | # SageMath parsed files 158 | *.sage.py 159 | 160 | # Environments 161 | .env 162 | .venv 163 | env/ 164 | venv/ 165 | ENV/ 166 | env.bak/ 167 | venv.bak/ 168 | 169 | # Spyder project settings 170 | .spyderproject 171 | .spyproject 172 | 173 | # Rope project settings 174 | .ropeproject 175 | 176 | # mkdocs documentation 177 | /site 178 | 179 | # mypy 180 | .mypy_cache/ 181 | .dmypy.json 182 | dmypy.json 183 | 184 | # Pyre type checker 185 | .pyre/ 186 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8-slim-buster 2 | 3 | ENV PYTHONDONTWRITEBYTECODE=1 4 | ENV PYTHONUNBUFFERED=1 5 | 6 | # install system dependencies 7 | RUN apt-get update \ 8 | && apt-get -y install gcc make \ 9 | && rm -rf /var/lib/apt/lists/*s 10 | 11 | #API env variables 12 | ENV ENVIRONMENT="development" 13 | ENV SQL_ALCHEMY_DATABASE_URL="sqlite:///.././sql_app.db" 14 | ENV ACCESS_TOKEN_EXPIRE_MINUTES=10 15 | ENV REFRESH_TOKEN_EXPIRE_MINUTES=30 16 | ENV API_DISABLE_DOC=Fasle 17 | ENV API_DEBUG=True 18 | 19 | ENV VIRTUAL_ENV=/opt/venv 20 | RUN python3 -m venv $VIRTUAL_ENV 21 | ENV PATH="$VIRTUAL_ENV/bin:$PATH" 22 | COPY requirements.txt . 23 | RUN pip install -r requirements.txt 24 | MAINTAINER Bastien_B 25 | EXPOSE 5010 26 | COPY app /app 27 | CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5010"] 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Bastien 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fastapi-RBAC-microservice WIP :construction_worker: 2 | 3 | 4 | Inspired by : 5 | 6 | [tiangolo/full-stack-fastapi-postgresql](https://github.com/tiangolo/full-stack-fastapi-postgresql/) 7 | 8 | [Kludex/fastapi-microservices ](https://github.com/Kludex/fastapi-microservices) 9 | 10 | [Jarmos-san/main](https://gist.github.com/Jarmos-san/0b655a3f75b698833188922b714562e5) 11 | 12 | 13 | This project is still in active development and subject to big updates! :construction_worker: -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = migrations 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # sys.path path, will be prepended to sys.path if present. 11 | # defaults to the current working directory. 12 | prepend_sys_path = . 13 | 14 | # timezone to use when rendering the date 15 | # within the migration file as well as the filename. 16 | # string value is passed to dateutil.tz.gettz() 17 | # leave blank for localtime 18 | # timezone = 19 | 20 | # max length of characters to apply to the 21 | # "slug" field 22 | # truncate_slug_length = 40 23 | 24 | # set to 'true' to run the environment during 25 | # the 'revision' command, regardless of autogenerate 26 | # revision_environment = false 27 | 28 | # set to 'true' to allow .pyc and .pyo files without 29 | # a source .py file to be detected as revisions in the 30 | # versions/ directory 31 | # sourceless = false 32 | 33 | # version location specification; this defaults 34 | # to migrations/versions. When using multiple version 35 | # directories, initial revisions must be specified with --version-path 36 | # version_locations = %(here)s/bar %(here)s/bat migrations/versions 37 | 38 | # the output encoding used when revision files 39 | # are written from script.py.mako 40 | # output_encoding = utf-8 41 | 42 | sqlalchemy.url = driver://user:pass@localhost/dbname 43 | 44 | 45 | [post_write_hooks] 46 | # post_write_hooks defines scripts or Python functions that are run 47 | # on newly generated revision scripts. See the documentation for further 48 | # detail and examples 49 | 50 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 51 | # hooks = black 52 | # black.type = console_scripts 53 | # black.entrypoint = black 54 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 55 | 56 | # Logging configuration 57 | [loggers] 58 | keys = root,sqlalchemy,alembic 59 | 60 | [handlers] 61 | keys = console 62 | 63 | [formatters] 64 | keys = generic 65 | 66 | [logger_root] 67 | level = WARN 68 | handlers = console 69 | qualname = 70 | 71 | [logger_sqlalchemy] 72 | level = WARN 73 | handlers = 74 | qualname = sqlalchemy.engine 75 | 76 | [logger_alembic] 77 | level = INFO 78 | handlers = 79 | qualname = alembic 80 | 81 | [handler_console] 82 | class = StreamHandler 83 | args = (sys.stderr,) 84 | level = NOTSET 85 | formatter = generic 86 | 87 | [formatter_generic] 88 | format = %(levelname)-5.5s [%(name)s] %(message)s 89 | datefmt = %H:%M:%S 90 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bastien-BO/fastapi-RBAC-microservice/f1722229aefd75505f355a4c4e4b9a940f28410a/app/__init__.py -------------------------------------------------------------------------------- /app/database.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import create_engine 2 | from sqlalchemy.orm import sessionmaker, declarative_base 3 | 4 | from app.settings import get_settings 5 | 6 | engine = create_engine( 7 | get_settings().sql_alchemy_database_url, 8 | connect_args={"check_same_thread": False}, 9 | ) 10 | SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) 11 | 12 | Base = declarative_base() 13 | 14 | 15 | def get_db(): 16 | db = SessionLocal() 17 | try: 18 | yield db 19 | finally: 20 | db.close() 21 | -------------------------------------------------------------------------------- /app/internal/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bastien-BO/fastapi-RBAC-microservice/f1722229aefd75505f355a4c4e4b9a940f28410a/app/internal/__init__.py -------------------------------------------------------------------------------- /app/internal/auth.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import Optional 3 | 4 | from fastapi import Depends, HTTPException 5 | from fastapi.security import OAuth2PasswordBearer 6 | from sqlalchemy.orm import Session 7 | 8 | from jose import JWTError, jwt 9 | from passlib.context import CryptContext 10 | from fastapi import status 11 | 12 | from app.database import get_db 13 | from app.internal.crud.user import crud_user 14 | from app.models.user import User as UserModel 15 | from app.schemas.token import TokenData 16 | from app.schemas.user import UserOut 17 | from app.settings import get_settings 18 | 19 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 20 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") 21 | 22 | 23 | def authenticate_user(db: Session, username: str, password: str): 24 | user: UserModel = crud_user.get(session=db, username=username) 25 | if not user: 26 | return False 27 | if not verify_password( 28 | db=db, password=password, password_hash=user.hashed_password 29 | ): 30 | return False 31 | return user 32 | 33 | 34 | def verify_password(db: Session, password: str, password_hash: str): 35 | if get_settings().api_debug: 36 | return True 37 | else: 38 | return pwd_context.verify(password, password_hash) 39 | 40 | 41 | def get_password_hash(password): 42 | return pwd_context.hash(password) 43 | 44 | 45 | def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): 46 | to_encode = data.copy() 47 | if expires_delta: 48 | expire = datetime.utcnow() + expires_delta 49 | else: 50 | expire = datetime.utcnow() + timedelta(minutes=15) 51 | to_encode.update({"exp": expire}) 52 | encoded_jwt = jwt.encode( 53 | to_encode, get_settings().token_generator_secret_key, algorithm="HS256" 54 | ) 55 | return encoded_jwt 56 | 57 | 58 | async def get_current_user( 59 | db: Session = Depends(get_db), token: str = Depends(oauth2_scheme) 60 | ): 61 | credentials_exception = HTTPException( 62 | status_code=status.HTTP_401_UNAUTHORIZED, 63 | detail="Could not validate credentials", 64 | headers={"WWW-Authenticate": "Bearer"}, 65 | ) 66 | try: 67 | payload = jwt.decode( 68 | token, 69 | get_settings().token_generator_secret_key, 70 | algorithms=["HS256"], 71 | ) 72 | username: str = payload.get("sub") 73 | if username is None: 74 | raise credentials_exception 75 | token_data = TokenData(username=username) 76 | except JWTError: 77 | raise credentials_exception 78 | user = crud_user.get(session=db, username=token_data.username) 79 | if user is None: 80 | raise credentials_exception 81 | return user 82 | 83 | 84 | async def get_current_active_user( 85 | current_user: UserOut = Depends(get_current_user), 86 | ): 87 | if not current_user.is_active: 88 | raise HTTPException(status_code=400, detail="user is deactivated") 89 | return current_user 90 | -------------------------------------------------------------------------------- /app/internal/crud/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bastien-BO/fastapi-RBAC-microservice/f1722229aefd75505f355a4c4e4b9a940f28410a/app/internal/crud/__init__.py -------------------------------------------------------------------------------- /app/internal/crud/base.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Generic, List, Optional, Type, TypeVar, Union 2 | 3 | from fastapi.encoders import jsonable_encoder 4 | from pydantic import BaseModel 5 | from sqlalchemy import select 6 | from sqlalchemy.orm import Session 7 | 8 | ModelType = TypeVar("ModelType") 9 | CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) 10 | UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel) 11 | 12 | 13 | class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): 14 | def __init__(self, model: Type[ModelType]) -> None: 15 | self._model = model 16 | 17 | def create(self, session: Session, obj_in: CreateSchemaType) -> ModelType: 18 | obj_in_data = dict(obj_in) 19 | db_obj = self._model(**obj_in_data) 20 | session.add(db_obj) 21 | session.commit() 22 | return db_obj 23 | 24 | def get(self, session: Session, *args, **kwargs) -> Optional[ModelType]: 25 | result = session.execute( 26 | select(self._model).filter(*args).filter_by(**kwargs) 27 | ) 28 | return result.scalars().first() 29 | 30 | def get_multi( 31 | self, 32 | session: Session, 33 | *args, 34 | offset: int = 0, 35 | limit: int = 100, 36 | **kwargs 37 | ) -> List[ModelType]: 38 | result = session.execute( 39 | select(self._model) 40 | .filter(*args) 41 | .filter_by(**kwargs) 42 | .offset(offset) 43 | .limit(limit) 44 | ) 45 | return result.scalars().all() 46 | 47 | def update( 48 | self, 49 | session: Session, 50 | *, 51 | obj_in: Union[UpdateSchemaType, Dict[str, Any]], 52 | db_obj: Optional[ModelType] = None, 53 | **kwargs 54 | ) -> Optional[ModelType]: 55 | db_obj = db_obj or self.get(session, **kwargs) 56 | if db_obj is not None: 57 | # compatibility for python<3.9 58 | obj_data = db_obj.__dict__ 59 | if isinstance(obj_in, dict): 60 | update_data = obj_in 61 | else: 62 | # compatibility for python<3.9 63 | update_data = obj_in.__dict__ 64 | for elem in update_data: 65 | if update_data[elem] is None or "": 66 | update_data.pop(elem) 67 | for field in obj_data: 68 | if field in update_data: 69 | setattr(db_obj, field, update_data[field]) 70 | session.add(db_obj) 71 | session.commit() 72 | return db_obj 73 | 74 | def delete( 75 | self, 76 | session: Session, 77 | *args, 78 | db_obj: Optional[ModelType] = None, 79 | **kwargs 80 | ) -> ModelType: 81 | db_obj = db_obj or self.get(session, *args, **kwargs) 82 | session.delete(db_obj) 83 | session.commit() 84 | return db_obj 85 | -------------------------------------------------------------------------------- /app/internal/crud/permission.py: -------------------------------------------------------------------------------- 1 | from app.internal.crud.base import CRUDBase 2 | from app.models.permission import Permission as PermissionModel 3 | from app.schemas.permission import PermissionCreate, PermissionUpdate 4 | 5 | CRUDPermission = CRUDBase[PermissionModel, PermissionCreate, PermissionUpdate] 6 | crud_permission = CRUDBase(PermissionModel) 7 | -------------------------------------------------------------------------------- /app/internal/crud/role.py: -------------------------------------------------------------------------------- 1 | from app.internal.crud.base import CRUDBase 2 | from app.models.role import Role as RoleModel 3 | from app.schemas.role import RoleUpdate, RoleCreate 4 | 5 | CRUDRole = CRUDBase[RoleModel, RoleCreate, RoleUpdate] 6 | crud_role = CRUDBase(RoleModel) 7 | -------------------------------------------------------------------------------- /app/internal/crud/user.py: -------------------------------------------------------------------------------- 1 | from app.internal.crud.base import CRUDBase 2 | from app.models.user import User 3 | from app.schemas.user import UserInDB, UserUpdateDB 4 | 5 | CRUDUser = CRUDBase[User, UserInDB, UserUpdateDB] 6 | crud_user = CRUDBase(User) 7 | -------------------------------------------------------------------------------- /app/internal/health.py: -------------------------------------------------------------------------------- 1 | def healthy_condition(): # just for testing puposes 2 | return {"database": "online"} 3 | 4 | 5 | def sick_condition(): # just for testing puposes 6 | return True 7 | -------------------------------------------------------------------------------- /app/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bastien-BO/fastapi-RBAC-microservice/f1722229aefd75505f355a4c4e4b9a940f28410a/app/models/__init__.py -------------------------------------------------------------------------------- /app/models/permission.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, String 2 | from sqlalchemy.orm import backref, relationship 3 | 4 | from app.database import Base 5 | from app.models.role_permission import role_permission 6 | 7 | 8 | class Permission(Base): 9 | __tablename__ = "permission" 10 | id = Column(Integer, autoincrement=True, primary_key=True) 11 | name = Column(String(100), nullable=False, unique=True) 12 | 13 | roles = relationship( 14 | "Role", 15 | secondary=role_permission, 16 | backref=backref("permissions", lazy=True), 17 | lazy="subquery", 18 | ) 19 | -------------------------------------------------------------------------------- /app/models/role.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Integer, Column, String 2 | 3 | from app.database import Base 4 | 5 | 6 | class Role(Base): 7 | __tablename__ = "role" 8 | id = Column(Integer, primary_key=True, autoincrement=True) 9 | name = Column(String(100), nullable=False, unique=True) 10 | -------------------------------------------------------------------------------- /app/models/role_permission.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Table, ForeignKey, Column 2 | 3 | from app.database import Base 4 | 5 | role_permission = Table( 6 | "role_permission", 7 | Base.metadata, 8 | Column("permission_id", ForeignKey("permission.id"), primary_key=True), 9 | Column("role_id", ForeignKey("role.id"), primary_key=True), 10 | ) 11 | -------------------------------------------------------------------------------- /app/models/user.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, Boolean, String 2 | from sqlalchemy.orm import relationship, backref 3 | 4 | from app.database import Base 5 | from app.models.user_role import user_role 6 | 7 | 8 | class User(Base): 9 | __tablename__ = "user" 10 | id = Column(Integer, primary_key=True, unique=True, autoincrement=True) 11 | is_active = Column(Boolean, default=True) 12 | username = Column(String, unique=True, nullable=False) 13 | firstname = Column(String, nullable=False) 14 | lastname = Column(String, nullable=False) 15 | email = Column(String, nullable=False) 16 | hashed_password = Column(String, unique=True, nullable=False) 17 | 18 | roles = relationship( 19 | "Role", 20 | secondary=user_role, 21 | backref=backref("user", lazy=True), 22 | lazy="subquery", 23 | ) 24 | -------------------------------------------------------------------------------- /app/models/user_role.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Table, Column, Integer, ForeignKey, PrimaryKeyConstraint 2 | 3 | from app.database import Base 4 | from app.models.role import Role 5 | 6 | user_role = Table( 7 | "user_role", 8 | Base.metadata, 9 | Column("user_id", Integer, ForeignKey("user.id")), 10 | Column("role_id", Integer, ForeignKey(Role.id)), 11 | PrimaryKeyConstraint("user_id", "role_id"), 12 | ) 13 | -------------------------------------------------------------------------------- /app/routers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bastien-BO/fastapi-RBAC-microservice/f1722229aefd75505f355a4c4e4b9a940f28410a/app/routers/__init__.py -------------------------------------------------------------------------------- /app/routers/auth.py: -------------------------------------------------------------------------------- 1 | """ 2 | All routes related to auth 3 | """ 4 | from datetime import timedelta 5 | 6 | from fastapi import APIRouter 7 | from fastapi import Depends 8 | from fastapi import HTTPException 9 | from fastapi import status 10 | from fastapi.security import OAuth2PasswordRequestForm 11 | from jose import JWTError 12 | from jose import jwt 13 | from sqlalchemy.orm import Session 14 | 15 | from app.database import get_db 16 | from app.internal.auth import authenticate_user 17 | from app.internal.auth import create_access_token 18 | from app.internal.crud.user import crud_user 19 | from app.schemas.renew_token import RenewToken 20 | from app.schemas.token import Token 21 | from app.schemas.token import TokenData 22 | from app.schemas.user import UserOut 23 | from app.settings import Settings 24 | from app.settings import get_settings 25 | 26 | router = APIRouter( 27 | tags=["auth"], 28 | ) 29 | 30 | 31 | @router.post("/token", response_model=Token) 32 | async def login_for_access_token( 33 | db: Session = Depends(get_db), 34 | form_data: OAuth2PasswordRequestForm = Depends(), 35 | ) -> Token: 36 | user = authenticate_user( 37 | db=db, username=form_data.username, password=form_data.password 38 | ) 39 | if not user: 40 | raise HTTPException( 41 | status_code=status.HTTP_401_UNAUTHORIZED, 42 | detail="Incorrect username or password", 43 | headers={"WWW-Authenticate": "Bearer"}, 44 | ) 45 | access_token_expires = timedelta( 46 | minutes=get_settings().access_token_expire_minutes 47 | ) 48 | refresh_token_expires = timedelta( 49 | minutes=get_settings().refresh_token_expire_minutes 50 | ) 51 | access_token = create_access_token( 52 | data={"sub": user.username}, expires_delta=access_token_expires 53 | ) 54 | refresh_token = create_access_token( 55 | data={"sub": user.username}, expires_delta=refresh_token_expires 56 | ) 57 | return Token(access_token=access_token, refresh_token=refresh_token) 58 | 59 | 60 | @router.post("/refresh", response_model=Token) 61 | async def refresh( 62 | renewtoken: RenewToken, 63 | db: Session = Depends(get_db), 64 | config: Settings = Depends(get_settings), 65 | ) -> Token: 66 | credentials_exception = HTTPException( 67 | status_code=status.HTTP_401_UNAUTHORIZED, 68 | detail="could not validate refresh token", 69 | headers={"www-Authenticate": "Bearer"}, 70 | ) 71 | try: 72 | playload = jwt.decode( 73 | renewtoken.refresh_token, 74 | config.token_generator_secret_key, 75 | algorithms=["HS256"], 76 | ) 77 | username: str = playload.get("sub") 78 | if username is None: 79 | raise credentials_exception 80 | token_data = TokenData(username=username) 81 | except JWTError: 82 | raise credentials_exception 83 | user: UserOut = crud_user.get(session=db, username=token_data.username) 84 | if user is None or not user.is_active: 85 | raise credentials_exception 86 | access_token_expires = timedelta( 87 | minutes=get_settings().access_token_expire_minutes 88 | ) 89 | refresh_token_expires = timedelta( 90 | minutes=get_settings().refresh_token_expire_minutes 91 | ) 92 | access_token = create_access_token( 93 | data={"sub": user.username}, expires_delta=access_token_expires 94 | ) 95 | refresh_token = create_access_token( 96 | data={"sub": user.username}, expires_delta=refresh_token_expires 97 | ) 98 | return Token(access_token=access_token, refresh_token=refresh_token) 99 | -------------------------------------------------------------------------------- /app/routers/health.py: -------------------------------------------------------------------------------- 1 | """ 2 | Health route 3 | """ 4 | from fastapi import APIRouter 5 | from fastapi import status 6 | from fastapi import Depends 7 | from fastapi_health import health 8 | 9 | from app.internal.health import healthy_condition 10 | from app.internal.health import sick_condition 11 | 12 | router = APIRouter( 13 | tags=["healthcheck"], 14 | ) 15 | 16 | 17 | @router.get("/health", status_code=status.HTTP_200_OK) 18 | def perform_api_healthcheck( 19 | health_endpoint=Depends(health([healthy_condition, sick_condition])) 20 | ): 21 | return health_endpoint 22 | -------------------------------------------------------------------------------- /app/routers/healthcheck_heroku.py: -------------------------------------------------------------------------------- 1 | """ 2 | Route for health check specific to Heroku 3 | """ 4 | from fastapi import APIRouter 5 | from fastapi import status 6 | 7 | router = APIRouter( 8 | tags=["healthcheck"], 9 | ) 10 | 11 | 12 | @router.get("/healthcheck", status_code=status.HTTP_200_OK) 13 | def perform_heroku_healthcheck() -> dict: 14 | """ 15 | Simple route for the GitHub Actions to healthcheck on. 16 | More info is available at: 17 | https://github.com/akhileshns/heroku-deploy#health-check 18 | It basically sends a GET request to the route & hopes to get a "200" 19 | response code. Failing to return a 200 response code just enables 20 | the GitHub Actions to rollback to the last version the project was 21 | found in a "working condition". It acts as a last line of defense in 22 | case something goes south. 23 | Additionally, it also returns a JSON response in the form of: 24 | { 25 | 'healtcheck': 'Everything OK!' 26 | } 27 | """ 28 | return {"healthcheck": "Everything OK!"} 29 | -------------------------------------------------------------------------------- /app/routers/permission.py: -------------------------------------------------------------------------------- 1 | """ 2 | All routes for Permissions 3 | """ 4 | from typing import List 5 | 6 | from fastapi import APIRouter 7 | from fastapi import Depends 8 | from fastapi import HTTPException 9 | from fastapi import status 10 | from requests import Session 11 | from sqlalchemy.exc import IntegrityError 12 | from starlette.status import HTTP_404_NOT_FOUND 13 | 14 | from app.database import get_db 15 | from app.internal.crud.permission import crud_permission 16 | from app.internal.crud.role import crud_role 17 | from app.schemas.permission import Permission 18 | from app.schemas.permission import PermissionCreate 19 | from app.schemas.permission import PermissionUpdate 20 | 21 | from app.schemas.role import Role 22 | 23 | router = APIRouter( 24 | prefix="/permission", 25 | tags=["permission"], 26 | ) 27 | 28 | 29 | @router.get("/", response_model=List[Permission]) 30 | def get_permission( 31 | skip: int = 0, limit: int = 100, db: Session = Depends(get_db) 32 | ) -> Permission: 33 | return crud_permission.get_multi(session=db, offset=skip, limit=limit) 34 | 35 | 36 | @router.get("/{id_permission}", response_model=Permission) 37 | def get_permission( 38 | id_permission: int, db: Session = Depends(get_db) 39 | ) -> Permission: 40 | permission_exception = HTTPException( 41 | status_code=status.HTTP_404_NOT_FOUND, 42 | detail=f"could not find Permission: {id_permission}", 43 | ) 44 | permission = crud_permission.get(session=db, id=id_permission) 45 | 46 | if permission: 47 | return permission 48 | else: 49 | raise permission_exception 50 | 51 | 52 | @router.post("/", response_model=Permission) 53 | def post_permission( 54 | permission: PermissionCreate, db: Session = Depends(get_db) 55 | ) -> Permission: 56 | permission_db = crud_permission.get(session=db, name=permission.name) 57 | 58 | if permission_db: 59 | raise HTTPException( 60 | status_code=status.HTTP_404_NOT_FOUND, 61 | detail=f"Permission {permission.name} already exist", 62 | ) 63 | try: 64 | permission_create: PermissionCreate = PermissionCreate( 65 | name=permission.name 66 | ) 67 | for nb in range(0, len(permission.roles)): 68 | role_found: Role = crud_role.get( 69 | session=db, id=permission.roles[nb].id 70 | ) 71 | if role_found: 72 | permission_create.roles.append(role_found) 73 | else: 74 | raise HTTPException( 75 | status_code=status.HTTP_409_CONFLICT, 76 | detail=f"Unable to find role {permission.roles[nb].id}", 77 | ) 78 | return crud_permission.create(session=db, obj_in=permission_create) 79 | except IntegrityError: 80 | raise HTTPException( 81 | status_code=status.HTTP_409_CONFLICT, 82 | detail="Unable to create permission", 83 | ) 84 | 85 | 86 | @router.patch("/{id_permission}", response_model=Permission) 87 | def update_permission( 88 | id_permission: int, 89 | permission_in: PermissionUpdate, 90 | db: Session = Depends(get_db), 91 | ) -> Permission: 92 | db_permission = crud_permission.get(session=db, id=id_permission) 93 | if db_permission is None: 94 | raise HTTPException( 95 | status_code=status.HTTP_404_NOT_FOUND, 96 | detail=f"Permission id:{id_permission} do not exist", 97 | ) 98 | try: 99 | permission_update = PermissionUpdate(name=permission_in.name) 100 | for nb in range(0, len(permission_in.roles)): 101 | role_found = crud_role.get( 102 | session=db, id=permission_in.roles[nb].id 103 | ) 104 | if role_found: 105 | permission_update.roles.append(role_found) 106 | else: 107 | raise HTTPException( 108 | status_code=status.HTTP_409_CONFLICT, 109 | detail=f"Unable to find role {permission_in.roles[nb].id}", 110 | ) 111 | return crud_permission.update( 112 | session=db, db_obj=db_permission, obj_in=permission_update 113 | ) 114 | # return crud_permission.create(session=db, obj_in=permission_create) 115 | except IntegrityError: 116 | raise HTTPException( 117 | status_code=status.HTTP_409_CONFLICT, 118 | detail="Unable to update permission", 119 | ) 120 | 121 | 122 | @router.delete("/{id_permission}", status_code=status.HTTP_204_NO_CONTENT) 123 | def delete_permission( 124 | id_permission: int, db: Session = Depends(get_db) 125 | ): 126 | 127 | permission = crud_permission.get(session=db, id=id_permission) 128 | 129 | if permission: 130 | crud_permission.delete(session=db, db_obj=permission) 131 | else: 132 | raise HTTPException( 133 | HTTP_404_NOT_FOUND, 134 | f"could not find Permission: {id_permission}", 135 | ) 136 | -------------------------------------------------------------------------------- /app/routers/role.py: -------------------------------------------------------------------------------- 1 | """ 2 | All routes for Role 3 | """ 4 | from typing import List 5 | 6 | from fastapi import status 7 | from fastapi import APIRouter 8 | from fastapi import Depends 9 | from fastapi import HTTPException 10 | from sqlalchemy.orm import Session 11 | from starlette.status import HTTP_404_NOT_FOUND, HTTP_400_BAD_REQUEST 12 | 13 | from app.database import get_db 14 | from app.internal.crud.role import crud_role 15 | from app.schemas.role import Role 16 | from app.schemas.role import RoleCreate 17 | from app.schemas.role import RoleUpdate 18 | 19 | router = APIRouter( 20 | prefix="/role", 21 | tags=["role"], 22 | ) 23 | 24 | 25 | @router.get("/", response_model=List[Role]) 26 | def get_role( 27 | skip: int = 0, limit: int = 100, db: Session = Depends(get_db) 28 | ) -> List[Role]: 29 | """ 30 | Create Roles 31 | """ 32 | return crud_role.get_multi(session=db, offset=skip, limit=limit) 33 | 34 | 35 | @router.get("/{id_role}", response_model=Role) 36 | def get_role(id_role: int, db: Session = Depends(get_db)) -> Role: 37 | """ 38 | Get a Role 39 | """ 40 | role = crud_role.get(session=db, id=id_role) 41 | 42 | if role: 43 | return role 44 | else: 45 | raise HTTPException(HTTP_404_NOT_FOUND, f"could not find role: {id_role}") 46 | 47 | 48 | @router.post("/", response_model=Role) 49 | def post_permission(role: RoleCreate, db: Session = Depends(get_db)) -> Role: 50 | """ 51 | Create a Role 52 | """ 53 | role_db = crud_role.get(session=db, name=role.name) 54 | if role_db: 55 | raise HTTPException(HTTP_404_NOT_FOUND, f"role {role.name} already exist") 56 | else: 57 | return crud_role.create(session=db, obj_in=role) 58 | 59 | 60 | @router.patch("/{id_role}", response_model=Role) 61 | def update_role( 62 | id_role: int, role_in: RoleUpdate, db: Session = Depends(get_db) 63 | ) -> Role: 64 | """ 65 | Update a Role 66 | """ 67 | db_role: Role = crud_role.get(session=db, id=id_role) 68 | if not db_role: 69 | raise HTTPException( 70 | status_code=HTTP_400_BAD_REQUEST, detail=f"role {db_role.name} do not exist" 71 | ) 72 | else: 73 | return crud_role.update(session=db, db_obj=db_role, obj_in=role_in) 74 | 75 | 76 | @router.delete("/{id_role}", status_code=status.HTTP_204_NO_CONTENT) 77 | def delete_role( 78 | id_role: int, db: Session = Depends(get_db) 79 | ): 80 | """ 81 | Delete a Role 82 | """ 83 | role = crud_role.get(session=db, id=id_role) 84 | 85 | if role: 86 | crud_role.delete(session=db, db_obj=role) 87 | else: 88 | raise HTTPException(HTTP_404_NOT_FOUND, f"could not find role: {id_role}") 89 | -------------------------------------------------------------------------------- /app/routers/shutdown.py: -------------------------------------------------------------------------------- 1 | """ 2 | Shutdown route 3 | """ 4 | from fastapi import APIRouter 5 | 6 | router = APIRouter() 7 | 8 | 9 | @router.on_event("shutdown") 10 | def shutdown(): 11 | pass # add shutdown functions here 12 | -------------------------------------------------------------------------------- /app/routers/startup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Startup Route 3 | """ 4 | from fastapi import APIRouter 5 | 6 | router = APIRouter() 7 | 8 | 9 | @router.on_event("startup") 10 | def startup(): 11 | pass # add startup functions here 12 | -------------------------------------------------------------------------------- /app/routers/user.py: -------------------------------------------------------------------------------- 1 | """ 2 | All routes for User 3 | """ 4 | from sqlalchemy.orm import Session 5 | from typing import List 6 | 7 | from fastapi import APIRouter 8 | from fastapi import Depends 9 | from fastapi import HTTPException 10 | from sqlalchemy.exc import IntegrityError 11 | from starlette import status 12 | from starlette.status import HTTP_409_CONFLICT, HTTP_404_NOT_FOUND, HTTP_403_FORBIDDEN 13 | 14 | from app.database import get_db 15 | from app.internal.auth import get_current_user 16 | from app.internal.auth import get_password_hash 17 | from app.internal.crud.user import crud_user 18 | from app.models.user import User 19 | from app.schemas.user import UserCreate 20 | from app.schemas.user import UserInDB 21 | from app.schemas.user import UserOut 22 | from app.schemas.user import UserUpdate 23 | 24 | router = APIRouter(prefix="/user", tags=["User"]) 25 | 26 | 27 | @router.get("/", response_model=List[UserOut]) 28 | async def read_users( 29 | offset: int = 0, limit: int = 100, session: Session = Depends(get_db) 30 | ) -> List[UserOut]: 31 | users = crud_user.get_multi(session, offset=offset, limit=limit) 32 | return users 33 | 34 | 35 | @router.post("/", response_model=UserOut) 36 | async def create_user( 37 | user_in: UserCreate, session: Session = Depends(get_db) 38 | ) -> UserOut: 39 | user = crud_user.get(session, username=user_in.username) 40 | if user is not None: 41 | raise HTTPException( 42 | status_code=HTTP_409_CONFLICT, 43 | detail="The user with this username already exists in the system", 44 | ) 45 | obj_in = UserInDB( 46 | **user_in.model_dump(), hashed_password=get_password_hash(user_in.password) 47 | ) 48 | return crud_user.create(session, obj_in) 49 | 50 | 51 | @router.get("/{user_id}/", response_model=UserOut) 52 | async def read_user( 53 | user_id: int, 54 | current_user: User = Depends(get_current_user), 55 | session: Session = Depends(get_db), 56 | ) -> UserOut: 57 | if current_user.id == user_id: 58 | return current_user 59 | 60 | user = crud_user.get(session, id=user_id) 61 | if not current_user.is_superuser: 62 | raise HTTPException( 63 | status_code=HTTP_403_FORBIDDEN, detail="The user doesn't have enough privileges" 64 | ) 65 | if user is None: 66 | raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail="User not found") 67 | return user 68 | 69 | 70 | @router.patch("/{user_id}/", response_model=UserOut) 71 | async def update_user( 72 | user_id: int, user_in: UserUpdate, session: Session = Depends(get_db) 73 | ) -> UserOut: 74 | user = crud_user.get(session, id=user_id) 75 | if user is None: 76 | raise HTTPException( 77 | status_code=HTTP_404_NOT_FOUND, 78 | detail="The user with this username does not exist in the system", 79 | ) 80 | try: 81 | user = crud_user.update( 82 | session, 83 | db_obj=user, 84 | obj_in={ 85 | **user_in.model_dump(exclude={"password"}, exclude_none=True), 86 | "hashed_password": get_password_hash(user_in.password), 87 | }, 88 | ) 89 | except IntegrityError: 90 | raise HTTPException( 91 | status_code=HTTP_409_CONFLICT, detail="User with this username already exits" 92 | ) 93 | return user 94 | 95 | 96 | @router.delete("/{user_id}/", status_code=status.HTTP_204_NO_CONTENT) 97 | async def delete_user( 98 | user_id: int, 99 | current_user: User = Depends(get_current_user), 100 | session: Session = Depends(get_db), 101 | ): 102 | user = crud_user.get(session, id=user_id) 103 | if user is None: 104 | raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail="User not found") 105 | if current_user.id == user_id: 106 | raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="User can't delete itself") 107 | crud_user.delete(session, db_obj=user) 108 | -------------------------------------------------------------------------------- /app/schemas/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bastien-BO/fastapi-RBAC-microservice/f1722229aefd75505f355a4c4e4b9a940f28410a/app/schemas/__init__.py -------------------------------------------------------------------------------- /app/schemas/auth.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | """ 4 | from pydantic import BaseModel 5 | 6 | 7 | class Auth(BaseModel): 8 | access_token: str 9 | refresh_token: str 10 | token_type: str = "bearer" 11 | -------------------------------------------------------------------------------- /app/schemas/permission.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from pydantic import BaseModel 4 | 5 | from app.schemas.role import Role 6 | 7 | 8 | class PermissionBase(BaseModel): 9 | name: str 10 | 11 | 12 | class PermissionCreate(PermissionBase): 13 | roles: List[Role] = [] 14 | 15 | 16 | class PermissionUpdate(PermissionBase): 17 | roles: List[Role] = [] 18 | 19 | 20 | class Permission(PermissionBase): 21 | id: int 22 | roles: List[Role] = [] 23 | 24 | class ConfigDict: 25 | from_attributes = True 26 | -------------------------------------------------------------------------------- /app/schemas/renew_token.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class RenewToken(BaseModel): 5 | refresh_token: str 6 | -------------------------------------------------------------------------------- /app/schemas/role.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class RoleBase(BaseModel): 5 | name: str 6 | 7 | 8 | class RoleCreate(RoleBase): 9 | pass 10 | 11 | 12 | class RoleUpdate(RoleBase): 13 | pass 14 | 15 | 16 | class Role(RoleBase): 17 | id: int 18 | 19 | class ConfigDict: 20 | from_attributes = True 21 | -------------------------------------------------------------------------------- /app/schemas/settings.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class Environment(str, Enum): 5 | production: str = "production" 6 | development: str = "development" 7 | -------------------------------------------------------------------------------- /app/schemas/token.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class Token(BaseModel): 7 | access_token: str 8 | refresh_token: str 9 | token_type: str = "bearer" 10 | 11 | 12 | class TokenData(BaseModel): 13 | username: Optional[str] = None 14 | -------------------------------------------------------------------------------- /app/schemas/user.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List 2 | 3 | from pydantic import BaseModel, EmailStr 4 | 5 | from app.schemas.role import Role 6 | 7 | 8 | class UserBase(BaseModel): 9 | firstname: str 10 | lastname: str 11 | username: str 12 | email: EmailStr 13 | 14 | 15 | class UserCreate(UserBase): 16 | password: str 17 | 18 | 19 | class UserOut(UserBase): 20 | id: int 21 | is_active: bool 22 | 23 | class ConfigDict: 24 | from_attributes = True 25 | 26 | 27 | class UserInDB(UserBase): 28 | is_active: bool = True 29 | hashed_password: str 30 | 31 | 32 | class UserUpdate(UserBase): 33 | password: Optional[str] = None 34 | roles: List[Role] 35 | 36 | 37 | class UserUpdateDB(UserBase): 38 | hashed_password: str 39 | -------------------------------------------------------------------------------- /app/settings.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | from functools import lru_cache 3 | 4 | from pydantic_settings import BaseSettings 5 | 6 | from app.schemas.settings import Environment 7 | 8 | 9 | class Settings(BaseSettings): 10 | access_token_expire_minutes: int = 10 11 | api_debug: bool = True 12 | api_disable_docs: bool = False 13 | environment: Environment = "development" 14 | refresh_token_expire_minutes: int = 30 15 | sql_alchemy_database_url: str = "sqlite:///././sql_database.db" 16 | token_generator_secret_key: str = secrets.token_hex(64) 17 | 18 | class ConfigDict: 19 | env_file = ".env" 20 | env_file_encoding = "utf-8" 21 | use_enum_values = True 22 | 23 | 24 | @lru_cache() 25 | def get_settings(): 26 | return Settings() 27 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!user/bin/dev/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import uvicorn 5 | 6 | from fastapi import FastAPI 7 | from fastapi.middleware.cors import CORSMiddleware 8 | from fastapi_responses import custom_openapi 9 | 10 | from app.database import Base, engine 11 | from app.routers.auth import router as router_auth 12 | from app.routers.health import router as router_health 13 | from app.routers.healthcheck_heroku import router as router_healthcheck_heroku 14 | from app.routers.permission import router as router_permission 15 | from app.routers.role import router as router_role 16 | from app.routers.shutdown import router as router_shutdown 17 | from app.routers.startup import router as router_startup 18 | from app.routers.user import router as router_user 19 | from app.settings import get_settings 20 | 21 | 22 | Base.metadata.create_all(bind=engine) 23 | 24 | 25 | app = FastAPI( 26 | debug=get_settings().api_debug, 27 | description="Role Base Access Control API to handle users, roles and permission", 28 | title="RBAC API", 29 | version="Bêta", 30 | ) 31 | 32 | app.openapi = custom_openapi(app) 33 | 34 | app.add_middleware( 35 | CORSMiddleware, 36 | allow_credentials=True, 37 | allow_headers=["*"], 38 | allow_methods=["*"], 39 | allow_origins=["*"], 40 | ) 41 | 42 | app.include_router(router_auth) 43 | app.include_router(router_health) 44 | app.include_router(router_healthcheck_heroku) 45 | app.include_router(router_permission) 46 | app.include_router(router_role) 47 | app.include_router(router_shutdown) 48 | app.include_router(router_startup) 49 | app.include_router(router_user) 50 | 51 | 52 | if __name__ == "__main__": 53 | uvicorn.run("main:app", host="127.0.0.1", port=5000, log_level="info") 54 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | 3 | from sqlalchemy import engine_from_config 4 | from sqlalchemy import pool 5 | 6 | from alembic import context 7 | 8 | # this is the Alembic Config object, which provides 9 | # access to the values within the .ini file in use. 10 | config = context.config 11 | 12 | # Interpret the config file for Python logging. 13 | # This line sets up loggers basically. 14 | fileConfig(config.config_file_name) 15 | 16 | # add your model's MetaData object here 17 | # for 'autogenerate' support 18 | # from myapp import mymodel 19 | # target_metadata = mymodel.Base.metadata 20 | target_metadata = None 21 | 22 | # other values from the config, defined by the needs of env.py, 23 | # can be acquired: 24 | # my_important_option = config.get_main_option("my_important_option") 25 | # ... etc. 26 | 27 | 28 | def run_migrations_offline(): 29 | """Run migrations in 'offline' mode. 30 | 31 | This configures the context with just a URL 32 | and not an Engine, though an Engine is acceptable 33 | here as well. By skipping the Engine creation 34 | we don't even need a DBAPI to be available. 35 | 36 | Calls to context.execute() here emit the given string to the 37 | script output. 38 | 39 | """ 40 | url = config.get_main_option("sqlalchemy.url") 41 | context.configure( 42 | url=url, 43 | target_metadata=target_metadata, 44 | literal_binds=True, 45 | dialect_opts={"paramstyle": "named"}, 46 | ) 47 | 48 | with context.begin_transaction(): 49 | context.run_migrations() 50 | 51 | 52 | def run_migrations_online(): 53 | """Run migrations in 'online' mode. 54 | 55 | In this scenario we need to create an Engine 56 | and associate a connection with the context. 57 | 58 | """ 59 | connectable = engine_from_config( 60 | config.get_section(config.config_ini_section), 61 | prefix="sqlalchemy.", 62 | poolclass=pool.NullPool, 63 | ) 64 | 65 | with connectable.connect() as connection: 66 | context.configure( 67 | connection=connection, target_metadata=target_metadata 68 | ) 69 | 70 | with context.begin_transaction(): 71 | context.run_migrations() 72 | 73 | 74 | if context.is_offline_mode(): 75 | run_migrations_offline() 76 | else: 77 | run_migrations_online() 78 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /migrations/versions/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bastien-BO/fastapi-RBAC-microservice/f1722229aefd75505f355a4c4e4b9a940f28410a/migrations/versions/.gitkeep -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | SQLAlchemy==2.0.19 2 | alembic==1.11.1 3 | bcrypt~=4.0.0 4 | fastapi_health~=0.4.0 5 | fastapi==0.100.0 6 | jose~=1.0.0 7 | passlib~=1.7.4 8 | pydantic[dotenv,email]==2.0.3 9 | pytest==7.4.0 10 | python-jose~=3.3.0 11 | python-multipart~=0.0.5 12 | requests==2.31.0 13 | uvicorn==0.23.1 14 | httpx==0.24.1 15 | pydantic-settings==2.0.2 16 | fastapi_responses==0.2.1 17 | starlette==0.30.0 -------------------------------------------------------------------------------- /scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bastien-BO/fastapi-RBAC-microservice/f1722229aefd75505f355a4c4e4b9a940f28410a/scripts/__init__.py -------------------------------------------------------------------------------- /scripts/initial_data.py: -------------------------------------------------------------------------------- 1 | """ 2 | Add basics permissions and roles 3 | """ 4 | import logging 5 | from typing import List 6 | 7 | from sqlalchemy.orm import Session 8 | 9 | from app.database import SessionLocal 10 | from app.internal.crud.permission import crud_permission 11 | from app.schemas.permission import PermissionCreate 12 | 13 | logging.basicConfig(level=logging.INFO) 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | def add_crud_permissions(session: Session, basic_permission_list: List[str]) -> None: 18 | crud_list: List[str] = ["create", "read", "update", "delete"] 19 | for crud in crud_list: 20 | for permission in basic_permission_list: 21 | perm: PermissionCreate = PermissionCreate(name=crud + "_" + permission) 22 | print(perm) 23 | if not crud_permission.get(session=session, name="test"): # soucis ici 24 | print(perm) 25 | crud_permission.create(session=session, obj_in=perm) 26 | logger.info("Permission" + crud + "_" + permission + " created!") 27 | 28 | 29 | def main() -> None: 30 | logger.info("Creating inital data") 31 | with SessionLocal() as session: 32 | add_crud_permissions(session=session, basic_permission_list=["permission"]) 33 | logger.info("Initial data created") 34 | 35 | 36 | if __name__ == "__main__": 37 | # get_settings() 38 | main() 39 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bastien-BO/fastapi-RBAC-microservice/f1722229aefd75505f355a4c4e4b9a940f28410a/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_permission.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | 3 | from main import app 4 | 5 | client = TestClient(app) 6 | 7 | 8 | def test_api_create_permission(): 9 | ... 10 | 11 | 12 | def test_api_delete_permission(): 13 | ... 14 | 15 | 16 | def test_api_delete_permissions(): 17 | ... 18 | 19 | 20 | def test_api_put_permission(): 21 | ... 22 | 23 | 24 | def test_api_get_permission(): 25 | ... 26 | 27 | 28 | def test_api_get_permissions(): 29 | ... 30 | 31 | -------------------------------------------------------------------------------- /tests/test_role.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | 3 | from main import app 4 | 5 | client = TestClient(app) 6 | 7 | 8 | def test_api_create_role(): 9 | ... 10 | 11 | 12 | def test_api_delete_role(): 13 | ... 14 | 15 | 16 | def test_api_delete_roles(): 17 | ... 18 | 19 | 20 | def test_api_put_role(): 21 | ... 22 | 23 | 24 | def test_api_get_role(): 25 | ... 26 | 27 | 28 | def test_api_get_roles(): 29 | ... 30 | 31 | -------------------------------------------------------------------------------- /tests/test_user.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | 3 | from main import app 4 | 5 | client = TestClient(app) 6 | 7 | 8 | def test_api_create_user(): 9 | ... 10 | 11 | 12 | def test_api_delete_user(): 13 | ... 14 | 15 | 16 | def test_api_delete_users(): 17 | ... 18 | 19 | 20 | def test_api_put_user(): 21 | ... 22 | 23 | 24 | def test_api_get_user(): 25 | ... 26 | 27 | 28 | def test_api_get_users(): 29 | ... 30 | 31 | 32 | def test_api_me_user(): 33 | ... 34 | --------------------------------------------------------------------------------