Skip to content

Commit

Permalink
feat: add permissions to endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
flavien-hugs committed Nov 3, 2024
1 parent 9a848b7 commit 63ff39e
Show file tree
Hide file tree
Showing 14 changed files with 381 additions and 45 deletions.
2 changes: 1 addition & 1 deletion .isort.cfg
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
[settings]
known_third_party = beanie,boto3,botocore,fastapi,fastapi_pagination,httpx,mongomock_motor,motor,pydantic,pydantic_settings,pymongo,pytest,slugify,starlette,typer,uvicorn
known_third_party = beanie,boto3,botocore,fastapi,fastapi_pagination,httpx,mongomock_motor,motor,pydantic,pydantic_settings,pymongo,pytest,slugify,starlette,typer,uvicorn,yaml
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ ARG GID=10001
COPY --from=builder-base ${POETRY_HOME} ${POETRY_HOME}
COPY --from=builder-base ${VIRTUAL_ENV} ${VIRTUAL_ENV}
COPY --from=builder-base /app/src /app/src
COPY --from=builder-base /app/appdesc.yml /app/appdesc.yml
COPY --from=builder-base /app/pyproject.toml /app/pyproject.toml
COPY --from=builder-base /app/poetry.lock /app/poetry.lock

Expand Down
34 changes: 34 additions & 0 deletions appdesc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
- app:
name: "sfs"
title:
fr: "Service de stockage et gestion de fichiers"
en: "File storage and management service"
permissions:
- code: "sfs:can-read-bucket"
title:
fr: "Peut lire un dossier d'images"
en: "Can read an image folder"
- code: "sfs:can-create-bucket"
title:
fr: "Peut créer un dossier d'images"
en: "Can create an image folder"
- code: "sfs:can-delete-bucket"
title:
fr: "Peut supprimer un dossier d'images"
en: "Can delete an image folder"
- code: "sfs:can-write-bucket"
title:
fr: "Peut écrire dans un dossier d'images"
en: "Can write in an image folder"
- code: "sfs:can-read-file"
title:
fr: "Peut lire un fichier"
en: "Can read a file"
- code: "sfs:can-write-file"
title:
fr: "Peut ajouter un fichier dans un dossier d'images"
en: "Can add a file in an image folder"
- code: "sfs:can-delete-file"
title:
fr: "Peut supprimer un fichier"
en: "Can delete a file"
1 change: 1 addition & 0 deletions src/common/error_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ class SfsErrorCodes(StrEnum):
INTERNAL_SERVER_ERROR = "app/internal-server-error"
REQUEST_VALIDATION_ERROR = "app/request-validation-error"
SFS_BUCKET_NAME_ALREADY_EXIST = "sfs/bucket-name-alreay-exist"
AUTH_ACCESS_DENIED = "app/service-access-denied"
47 changes: 47 additions & 0 deletions src/common/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from typing import Set
from urllib.parse import urlencode, urljoin

import httpx
from fastapi import Header, status

from src.config import settings
from .error_codes import SfsErrorCodes
from .exception import CustomHTTPException


class CheckAccessAllow:
"""
This class is used to check if a user has the necessary permissions to access a resource.
"""

def __init__(self, permissions: Set, raise_exception: bool = True):
self.url = urljoin(settings.API_AUTH_URL_BASE, settings.API_AUTH_CHECK_ACCESS_ENDPOINT)
self.permissions = permissions
self.raise_exception = raise_exception

async def __call__(self, authorization: str = Header(...)):
headers = {"Authorization": authorization}
query_params = urlencode([("permission", item) for item in self.permissions])
url = f"{self.url}?{query_params}"

async with httpx.AsyncClient() as client:
response = await client.get(url, headers=headers)

if response.is_success is False:
if self.raise_exception:
raise CustomHTTPException(
error_code=SfsErrorCodes.AUTH_ACCESS_DENIED,
error_message="Access denied",
status_code=status.HTTP_403_FORBIDDEN,
)
return False

access = response.json()["access"]
if access is False and self.raise_exception:
raise CustomHTTPException(
error_code=SfsErrorCodes.AUTH_ACCESS_DENIED,
error_message="Access denied",
status_code=status.HTTP_403_FORBIDDEN,
)

return access
129 changes: 129 additions & 0 deletions src/common/setup_app_perms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import os.path
from pathlib import Path

import yaml
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorCollection
from slugify import slugify

BASE_DIR = Path(__file__).parent.parent.parent


async def __init_collection(client: AsyncIOMotorClient, collection_path: str) -> AsyncIOMotorCollection:
"""
Initialize a collection with a unique index on the 'app' field.
:param client: Motor client instance to connect to the database.
:rtype client: AsyncIOMotorClient
:param collection_path: Path to the collection in the format 'database.collection'.
:rtype collection_path: str
:return: Collection instance.
:rtype: AsyncIOMotorCollection
"""
database_name, collection_name = collection_path.split(".")
database = client[database_name]
collection = database[collection_name]

await collection.create_index("app", unique=True, background=True)

return collection


async def __load_app_description(filpath) -> dict:
"""
Load the app description from a JSON file.
:param filpath: Path to the JSON file.
:rtype filpath: str
:return: App description.
:rtype: dict
"""

if os.path.exists(filpath) is False:
raise ValueError("App description file not found.")

with open(filpath, "r") as f:
data = yaml.safe_load(f)

return data


async def __load_app_data(
mongodb_client: AsyncIOMotorClient,
collection_path: str,
filename: str,
data_key: str,
update_key: str,
update_value: callable,
):
"""
Load app data from a JSON file and update the database.
"""

coll = await __init_collection(mongodb_client, collection_path)

filepath = BASE_DIR / f"{filename}"
data = await __load_app_description(filepath)

if not (appname := data[0].get("app", {}).get("name", "").strip()):
raise ValueError(f"App name '{appname}' not found in {filepath}")

if not (value := data[0].get("app", {}).get(data_key, {})):
raise ValueError(f"{data_key} section for app '{appname}' not found in {filepath}")

await coll.update_one(
{"app": slugify(appname)},
{"$set": {update_key: update_value(value)}},
upsert=True,
)


async def load_app_description(
mongodb_client: AsyncIOMotorClient,
collection_path: str = None,
filename: str = "appdesc.yml",
):
"""
Load the app description from a JSON file and update the database.
"""

if not (coll_path := collection_path or os.environ.get("APP_DESC_DB_COLLECTION")):
raise ValueError("Invalid collection path")

await __init_collection(mongodb_client, coll_path)

await __load_app_data(
mongodb_client,
coll_path,
filename,
"title",
"title",
lambda value: value,
)


async def load_app_permissions(
mongodb_client: AsyncIOMotorClient,
collection_path: str = None,
filename: str = "appdesc.yml",
):
"""
Load the app permissions from a JSON file and update the database.
"""

if not (coll_path := collection_path or os.environ.get("PERMS_DB_COLLECTION")):
raise ValueError("Invalid collection path")

await __load_app_data(
mongodb_client,
coll_path,
filename,
"permissions",
"permissions",
lambda value: [
{
"code": slugify(perm.get("code", ""), regex_pattern=r"[^a-zA-Z0-9:]+"),
"desc": perm.get("desc", ""),
}
for perm in value
],
)
4 changes: 4 additions & 0 deletions src/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ class SfsBaseSettings(BaseSettings):
STORAGE_ROOT_PASSWORD: str = Field(..., alias="STORAGE_ROOT_PASSWORD")
STORAGE_REGION_NAME: Optional[str] = Field(default="af-south-1", alias="STORAGE_REGION_NAME")

# AUTH ENDPOINT CONFIG
API_AUTH_URL_BASE: str = Field(..., alias="API_AUTH_URL_BASE")
API_AUTH_CHECK_ACCESS_ENDPOINT: str = Field(..., alias="API_AUTH_CHECK_ACCESS_ENDPOINT")


@lru_cache()
def sfs_settings() -> SfsBaseSettings:
Expand Down
5 changes: 5 additions & 0 deletions src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from src.config.database import shutdown_db_client, startup_db_client
from src.routers import bucket_router, media_router
from src.common.exception import setup_exception_handlers
from src.common.setup_app_perms import load_app_description, load_app_permissions

from src.models import Bucket, Media

Expand All @@ -17,6 +18,10 @@
@asynccontextmanager
async def lifespan(app: FastAPI):
await startup_db_client(app=app, models=[Bucket, Media])

await load_app_description(mongodb_client=app.mongo_db_client)
await load_app_permissions(mongodb_client=app.mongo_db_client)

yield

await shutdown_db_client(app=app)
Expand Down
25 changes: 22 additions & 3 deletions src/routers/bucket.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from src.common.boto_client import get_boto_client
from src.common.functional import customize_page
from src.common.permissions import CheckAccessAllow
from src.common.utils import SortEnum
from src.models import Bucket
from src.schemas import BucketFilter, BucketSchema
Expand All @@ -22,6 +23,7 @@

@bucket_router.post(
"",
dependencies=[Depends(CheckAccessAllow(permissions={"sfs:can-create-bucket"}))],
response_model=Bucket,
summary="Create a bucket",
status_code=status.HTTP_201_CREATED,
Expand All @@ -31,7 +33,13 @@ async def create_bucket(bucket: BucketSchema = Body(...), botoclient: boto3.clie
return new_bucket


@bucket_router.get("", response_model=customize_page(Bucket), summary="List all buckets", status_code=status.HTTP_200_OK)
@bucket_router.get(
"",
dependencies=[Depends(CheckAccessAllow(permissions={"sfs:can-read-bucket"}))],
response_model=customize_page(Bucket),
summary="List all buckets",
status_code=status.HTTP_200_OK,
)
async def list_buckets(
query: BucketFilter = Depends(BucketFilter),
sort: Optional[SortEnum] = Query(default=SortEnum.DESC, alias="sort", description="Sort by 'asc' or 'desc"),
Expand All @@ -49,7 +57,13 @@ async def list_buckets(
return await paginate(buckets)


@bucket_router.get("/{bucket_name}", response_model=Bucket, summary="Get a bucket", status_code=status.HTTP_200_OK)
@bucket_router.get(
"/{bucket_name}",
dependencies=[Depends(CheckAccessAllow(permissions={"sfs:can-read-bucket"}))],
response_model=Bucket,
summary="Get a bucket",
status_code=status.HTTP_200_OK,
)
async def get_bucket(
bucket_name: str,
create_bucket_if_not_exist: bool = Query(default=False, description="Create the bucket if it doesn't exist"),
Expand All @@ -70,7 +84,12 @@ async def get_bucket(
return bucket


@bucket_router.delete("/{bucket_name}", summary="Delete a bucket", status_code=status.HTTP_200_OK)
@bucket_router.delete(
"/{bucket_name}",
dependencies=[Depends(CheckAccessAllow(permissions={"sfs:can-delete-bucket"}))],
summary="Delete a bucket",
status_code=status.HTTP_200_OK,
)
async def remove_bucket(bucket_name: str, botoclient: boto3.client = Depends(get_boto_client)):
await delete_bucket(bucket_name, botoclient)
response = {"message": f"Bucket '{bucket_name}' deleted successfully."}
Expand Down
22 changes: 18 additions & 4 deletions src/routers/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from src.common.boto_client import check_bucket_exists, get_boto_client
from src.common.error_codes import SfsErrorCodes
from src.common.permissions import CheckAccessAllow
from src.common.exception import CustomHTTPException
from src.common.functional import customize_page
from src.common.utils import SortEnum
Expand All @@ -24,6 +25,7 @@

@media_router.post(
"",
dependencies=[Depends(CheckAccessAllow(permissions={"sfs:can-write-bucket"}))],
response_model=Media,
summary="Upload a file to an S3 object.",
status_code=status.HTTP_202_ACCEPTED,
Expand Down Expand Up @@ -51,7 +53,13 @@ async def upload_file_to_buckect(
return result


@media_router.get("", response_model=customize_page(Media), summary="List media files", status_code=status.HTTP_200_OK)
@media_router.get(
"",
dependencies=[Depends(CheckAccessAllow(permissions={"sfs:can-read-file"}))],
response_model=customize_page(Media),
summary="List media files",
status_code=status.HTTP_200_OK,
)
async def list_media(
query: MediaFilter = Depends(MediaFilter),
sort: Optional[SortEnum] = Query(default=SortEnum.DESC, alias="sort", description="Sort by 'asc' or 'desc"),
Expand All @@ -73,14 +81,14 @@ async def list_media(

@media_router.get(
"/{bucket_name}/{filename}",
dependencies=[Depends(check_bucket_exists)],
dependencies=[Depends(CheckAccessAllow(permissions={"sfs:can-read-file"}))],
summary="Get media url",
status_code=status.HTTP_200_OK,
)
async def get_media_url(
bg: BackgroundTasks,
bucket_name: str,
filename: str,
bucket_name: str = Depends(check_bucket_exists),
download: bool = Query(default=False),
botoclient: boto3.client = Depends(get_boto_client),
):
Expand All @@ -89,13 +97,19 @@ async def get_media_url(
return await get_media(bucket_name=bucket_name, filename=filename, botoclient=botoclient)


@media_router.get("/{filename}", summary="Get media", status_code=status.HTTP_200_OK)
@media_router.get(
"/{filename}",
summary="Get media",
status_code=status.HTTP_200_OK,
dependencies=[Depends(CheckAccessAllow(permissions={"sfs:can-read-file"}))],
)
async def get_media_view(filename: str):
pass


@media_router.delete(
"/{bucket_name}/{filename}",
dependencies=[Depends(CheckAccessAllow(permissions={"sfs:can-delete-file"}))],
summary="Delete a file from a bucket",
status_code=status.HTTP_204_NO_CONTENT,
)
Expand Down
Loading

0 comments on commit 63ff39e

Please sign in to comment.