diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index 50bde36072..0000000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,10 +0,0 @@ -blank_issues_enabled: false -contact_links: - - name: Security Contact - about: Please report security vulnerabilities to security@tiangolo.com - - name: Question or Problem - about: Ask a question or ask about a problem in GitHub Discussions. - url: https://github.com/fastapi/full-stack-fastapi-template/discussions/categories/questions - - name: Feature Request - about: To suggest an idea or ask about a feature, please start with a question saying what you would like to achieve. There might be a way to do it already. - url: https://github.com/fastapi/full-stack-fastapi-template/discussions/categories/questions diff --git a/.github/ISSUE_TEMPLATE/privileged.yml b/.github/ISSUE_TEMPLATE/privileged.yml deleted file mode 100644 index 6438848c83..0000000000 --- a/.github/ISSUE_TEMPLATE/privileged.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Privileged -description: You are @tiangolo or he asked you directly to create an issue here. If not, check the other options. 👇 -body: - - type: markdown - attributes: - value: | - Thanks for your interest in this project! 🚀 - - If you are not @tiangolo or he didn't ask you directly to create an issue here, please start the conversation in a [Question in GitHub Discussions](https://github.com/tiangolo/full-stack-fastapi-template/discussions/categories/questions) instead. - - type: checkboxes - id: privileged - attributes: - label: Privileged issue - description: Confirm that you are allowed to create an issue here. - options: - - label: I'm @tiangolo or he asked me directly to create an issue here. - required: true - - type: textarea - id: content - attributes: - label: Issue Content - description: Add the content of the issue here. diff --git a/.github/workflows/add-to-project.yml b/.github/workflows/add-to-project.yml deleted file mode 100644 index dccea83f35..0000000000 --- a/.github/workflows/add-to-project.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Add to Project - -on: - pull_request_target: - issues: - types: - - opened - - reopened - -jobs: - add-to-project: - name: Add to project - runs-on: ubuntu-latest - steps: - - uses: actions/add-to-project@v1.0.2 - with: - project-url: https://github.com/orgs/fastapi/projects/2 - github-token: ${{ secrets.PROJECTS_TOKEN }} diff --git a/.github/workflows/issue-manager.yml b/.github/workflows/issue-manager.yml deleted file mode 100644 index 109ac0e989..0000000000 --- a/.github/workflows/issue-manager.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Issue Manager - -on: - schedule: - - cron: "21 17 * * *" - issue_comment: - types: - - created - issues: - types: - - labeled - pull_request_target: - types: - - labeled - workflow_dispatch: - -permissions: - issues: write - pull-requests: write - -jobs: - issue-manager: - if: github.repository_owner == 'fastapi' - runs-on: ubuntu-latest - steps: - - name: Dump GitHub context - env: - GITHUB_CONTEXT: ${{ toJson(github) }} - run: echo "$GITHUB_CONTEXT" - - uses: tiangolo/issue-manager@0.5.1 - with: - token: ${{ secrets.GITHUB_TOKEN }} - config: > - { - "answered": { - "delay": 864000, - "message": "Assuming the original need was handled, this will be automatically closed now. But feel free to add more comments or create new issues or PRs." - }, - "waiting": { - "delay": 2628000, - "message": "As this PR has been waiting for the original user for a while but seems to be inactive, it's now going to be closed. But if there's anyone interested, feel free to create a new PR." - }, - "invalid": { - "delay": 0, - "message": "This was marked as invalid and will be closed now. If this is an error, please provide additional details." - } - } diff --git a/backend/README.md b/backend/README.md index 17210a2f2c..584b7e1145 100644 --- a/backend/README.md +++ b/backend/README.md @@ -170,3 +170,17 @@ The email templates are in `./backend/app/email-templates/`. Here, there are two Before continuing, ensure you have the [MJML extension](https://marketplace.visualstudio.com/items?itemName=attilabuti.vscode-mjml) installed in your VS Code. Once you have the MJML extension installed, you can create a new email template in the `src` directory. After creating the new email template and with the `.mjml` file open in your editor, open the command palette with `Ctrl+Shift+P` and search for `MJML: Export to HTML`. This will convert the `.mjml` file to a `.html` file and now you can save it in the build directory. + +## Additions + +OpenFGA helps to streamline and standardize the permissions structure on the backend. Controlling access to database objects using 1 comprehensive mapping. + +To deploy using OpenFGA in the backend, currently, compose does not support automation function passing backend configuration details to the environment at runtime. In other words, you'll have to start OpenFGA, input the env variabls OPENFGA_STORE_ID and OPENFGA_AUTHORIZATION_MODEL_ID, then finally deploy the rest of the containers. + +```bash + docker compose -f docker-compose.fga.yml up -d +``` + +```bash + docker compose up -d --build +``` \ No newline at end of file diff --git a/backend/app/alembic/versions/bc408f47ce8b_add_metadata_fields_to_item.py b/backend/app/alembic/versions/bc408f47ce8b_add_metadata_fields_to_item.py new file mode 100644 index 0000000000..606a06c227 --- /dev/null +++ b/backend/app/alembic/versions/bc408f47ce8b_add_metadata_fields_to_item.py @@ -0,0 +1,53 @@ +"""Add metadata fields to Item + +Revision ID: bc408f47ce8b +Revises: 1a31ce608336 +Create Date: 2025-06-16 19:13:48.082374 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = 'bc408f47ce8b' +down_revision = '1a31ce608336' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('item', sa.Column('location', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True)) + op.add_column('item', sa.Column('price', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True)) + op.add_column('item', sa.Column('difficulty', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True)) + op.add_column('item', sa.Column('type', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True)) + op.add_column('item', sa.Column('category', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True)) + op.add_column('item', sa.Column('tags', sa.ARRAY(sa.String()), nullable=True)) + op.add_column('item', sa.Column('link', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True)) + op.add_column('item', sa.Column('picture', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True)) + op.add_column('item', sa.Column('in_featured', sa.Boolean(), nullable=False)) + op.add_column('item', sa.Column('rating', sa.Float(), nullable=True)) + op.add_column('item', sa.Column('tries', sa.Integer(), nullable=True)) + op.add_column('item', sa.Column('favorites', sa.Integer(), nullable=True)) + op.add_column('item', sa.Column('duration', sa.Interval(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('item', 'favorites') + op.drop_column('item', 'tries') + op.drop_column('item', 'rating') + op.drop_column('item', 'in_featured') + op.drop_column('item', 'picture') + op.drop_column('item', 'link') + op.drop_column('item', 'tags') + op.drop_column('item', 'category') + op.drop_column('item', 'type') + op.drop_column('item', 'difficulty') + op.drop_column('item', 'price') + op.drop_column('item', 'location') + op.drop_column('item', 'duration') + # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/f77762e21457_add_column_to_item_model.py b/backend/app/alembic/versions/f77762e21457_add_column_to_item_model.py new file mode 100644 index 0000000000..ca9d00b01b --- /dev/null +++ b/backend/app/alembic/versions/f77762e21457_add_column_to_item_model.py @@ -0,0 +1,29 @@ +"""Add column to item model + +Revision ID: f77762e21457 +Revises: bc408f47ce8b +Create Date: 2025-06-25 14:30:07.278621 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = 'f77762e21457' +down_revision = 'bc408f47ce8b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('item', sa.Column('duration', sa.Interval(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('item', 'duration') + # ### end Alembic commands ### diff --git a/backend/app/api/routes/items.py b/backend/app/api/routes/items.py index 177dc1e476..17e5c653fb 100644 --- a/backend/app/api/routes/items.py +++ b/backend/app/api/routes/items.py @@ -1,44 +1,115 @@ import uuid +import datetime from typing import Any from fastapi import APIRouter, HTTPException +from pydantic import BaseModel from sqlmodel import func, select from app.api.deps import CurrentUser, SessionDep -from app.models import Item, ItemCreate, ItemPublic, ItemsPublic, ItemUpdate, Message +from app.models import Item, ItemBase, ItemCreate, ItemPublic, ItemsPublic, ItemUpdate, Message +from app.openfga.fgaMiddleware import check_user_has_relation, create_fga_tuple, delete_fga_tuple, initialize_fga_client +from app.openfga.fgaMiddleware import check_user_has_permission +from openfga_sdk.client.models.tuple import ClientTuple router = APIRouter(prefix="/items", tags=["items"]) @router.get("/", response_model=ItemsPublic) def read_items( - session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100 + session: SessionDep, + current_user: CurrentUser, + skip: int = 0, + limit: int = 100, + get_completed: bool = False, + get_owned: bool = False, + title: str | None = None, + description: str | None = None, + location: str | None = None, + price: str | None = None, + difficulty: str | None = None, + type: str | None = None, + category: str | None = None, + tags: list[str] | None = None, + link: str | None = None, + picture: str | None = None, + in_featured: bool | None = None, + rating: float | None = None, + tries: int | None = None, + favorites: int | None = None, + duration: datetime.timedelta | None = None, ) -> Any: """ Retrieve items. """ + fga_client = initialize_fga_client() - if current_user.is_superuser: - count_statement = select(func.count()).select_from(Item) - count = session.exec(count_statement).one() - statement = select(Item).offset(skip).limit(limit) - items = session.exec(statement).all() + # Database filters + if title or description or location or price or difficulty or type or category or tags or link or picture or in_featured or rating or tries or favorites or duration: + query = select(Item) + # instead of appending to filters, we can use a where builder results = session.exec(query).all() + if title: + query = query.where(Item.title == title) + if description: + query = query.where(Item.description == description) + if location: + query = query.where(Item.location == location) + if price: + query = query.where(Item.price == price) + if difficulty: + query = query.where(Item.difficulty == difficulty) + if type: + query = query.where(Item.type == type) + if category: + query = query.where(Item.category == category) + if tags: + query = query.where(Item.tags == tags) + if link: + query = query.where(Item.link == link) + if picture: + query = query.where(Item.picture == picture) + if in_featured: + query = query.where(Item.in_featured == in_featured) + if rating: + query = query.where(Item.rating == rating) + if tries: + query = query.where(Item.tries == tries) + if favorites: + query = query.where(Item.favorites == favorites) + if duration: + query = query.where(Item.duration == duration) + + query = query.offset(skip).limit(limit) + items = session.exec(query).all() + count = len(items) else: - count_statement = ( - select(func.count()) - .select_from(Item) - .where(Item.owner_id == current_user.id) - ) - count = session.exec(count_statement).one() + # get all items + count = session.exec(select(func.count()).select_from(Item)).one() statement = ( select(Item) - .where(Item.owner_id == current_user.id) .offset(skip) .limit(limit) ) items = session.exec(statement).all() - return ItemsPublic(data=items, count=count) + # FGA filters + if get_owned: + item_ids = check_user_has_relation(fga_client, relation="owner", user=f"{current_user.email}") + if len(item_ids) == 0: + return ItemsPublic(data=[], count=0) + else: + # filter items to only those that match the item_ids + items = [item for item in items if str(item.id) in item_ids] + + if get_completed: + item_ids = check_user_has_relation(fga_client, relation="completed", user=f"{current_user.email}") + if len(item_ids) == 0: + return ItemsPublic(data=[], count=0) + else: + # get those items with those ids + items = [item for item in items if str(item.id) in item_ids] + + return ItemsPublic(data=[ItemPublic.model_validate(item) for item in items], count=count) @router.get("/{id}", response_model=ItemPublic) @@ -61,24 +132,40 @@ def create_item( """ Create new item. """ + item = Item.model_validate(item_in, update={"owner_id": current_user.id}) session.add(item) session.commit() session.refresh(item) + + fga_client = initialize_fga_client() + create_fga_tuple(fga_client, [ + ClientTuple(user=f"user:{current_user.email}", relation="owner", object=f"item:{item.id}") + ]) return item @router.put("/{id}", response_model=ItemPublic) -def update_item( +async def update_item( *, session: SessionDep, current_user: CurrentUser, id: uuid.UUID, item_in: ItemUpdate, ) -> Any: + """ Update an item. """ + # check if the user has the permission to update the item + # if not, raise an error + # if the user has the permission, update the item + # return the updated item + + fga_client = initialize_fga_client() + if not check_user_has_permission(fga_client, ClientTuple(user=f"user:{current_user.id}", relation="update", object=f"item:{id}")): + raise HTTPException(status_code=400, detail="Not enough permissions") + item = session.get(Item, id) if not item: raise HTTPException(status_code=404, detail="Item not found") @@ -91,9 +178,20 @@ def update_item( session.refresh(item) return item +@router.get("/{id}/can-update", response_model=bool) +async def can_update_item( + *, + session: SessionDep, + current_user: CurrentUser, + id: uuid.UUID, +): + fga_client = initialize_fga_client() + has_permission = check_user_has_permission(fga_client, ClientTuple(user=f"user:{current_user.id}", relation="update", object=f"item:{id}")) + + return has_permission @router.delete("/{id}") -def delete_item( +async def delete_item( session: SessionDep, current_user: CurrentUser, id: uuid.UUID ) -> Message: """ @@ -106,4 +204,9 @@ def delete_item( raise HTTPException(status_code=400, detail="Not enough permissions") session.delete(item) session.commit() + + fga_client = initialize_fga_client() + delete_fga_tuple(fga_client, [ClientTuple(user=f"user:{current_user.id}", relation="owner", object=f"item:{id}")]) + return Message(message="Item deleted successfully") + diff --git a/backend/app/backend_pre_start.py b/backend/app/backend_pre_start.py index c2f8e29ae1..d45fa4513b 100644 --- a/backend/app/backend_pre_start.py +++ b/backend/app/backend_pre_start.py @@ -25,7 +25,7 @@ def init(db_engine: Engine) -> None: # Try to create session to check if DB is awake session.exec(select(1)) except Exception as e: - logger.error(e) + logger.info(e) raise e diff --git a/backend/app/core/config.py b/backend/app/core/config.py index d58e03c87d..115fb4ebb0 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -57,6 +57,10 @@ def all_cors_origins(self) -> list[str]: POSTGRES_PASSWORD: str = "" POSTGRES_DB: str = "" + OPENFGA_API_URL: str = "" + OPENFGA_STORE_ID: str = "" + OPENFGA_AUTHORIZATION_MODEL_ID: str = "" + @computed_field # type: ignore[prop-decorator] @property def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: @@ -67,7 +71,7 @@ def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: host=self.POSTGRES_SERVER, port=self.POSTGRES_PORT, path=self.POSTGRES_DB, - ) + ) # type: ignore SMTP_TLS: bool = True SMTP_SSL: bool = False @@ -113,6 +117,9 @@ def _enforce_non_default_secrets(self) -> Self: self._check_default_secret( "FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD ) + self._check_default_secret("OPENFGA_API_URL", self.OPENFGA_API_URL) + self._check_default_secret("OPENFGA_STORE_ID", self.OPENFGA_STORE_ID) + self._check_default_secret("OPENFGA_AUTHORIZATION_MODEL_ID", self.OPENFGA_AUTHORIZATION_MODEL_ID) return self diff --git a/backend/app/main.py b/backend/app/main.py index 9a95801e74..a5e94bd329 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,8 +1,11 @@ +from app.openfga.fgaMiddleware import initialize_fga_client import sentry_sdk -from fastapi import FastAPI +from fastapi import FastAPI, logger from fastapi.routing import APIRoute from starlette.middleware.cors import CORSMiddleware +from contextlib import asynccontextmanager + from app.api.main import api_router from app.core.config import settings diff --git a/backend/app/models.py b/backend/app/models.py index 2389b4a532..3d42c643a9 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,7 +1,9 @@ +import datetime import uuid from pydantic import EmailStr from sqlmodel import Field, Relationship, SQLModel +from sqlalchemy import Column, String, ARRAY # Shared properties @@ -60,6 +62,21 @@ class UsersPublic(SQLModel): class ItemBase(SQLModel): title: str = Field(min_length=1, max_length=255) description: str | None = Field(default=None, max_length=255) + location: str | None = Field(default=None, max_length=255) + price: str | None = Field(default=None, max_length=255) + difficulty: str | None = Field(default=None, max_length=255) + type: str | None = Field(default=None, max_length=255) + category: str | None = Field(default=None, max_length=255) + tags: list[str] = Field( + sa_column=Column(ARRAY(String)), default_factory=list + ) + link: str | None = Field(default=None, max_length=255) + picture: str | None = Field(default=None, max_length=255) + in_featured: bool = Field(default=False) + rating: float | None = Field(default=None) + tries: int | None = Field(default=None) + favorites: int | None = Field(default=None) + duration: datetime.timedelta | None = Field(default=None) # Properties to receive on item creation @@ -79,6 +96,7 @@ class Item(ItemBase, table=True): foreign_key="user.id", nullable=False, ondelete="CASCADE" ) owner: User | None = Relationship(back_populates="items") + # created_at: datetime.datetime = Field(default_factory=datetime.datetime.now) # Properties to return via API, id is always required diff --git a/backend/app/openfga/fgaMiddleware.py b/backend/app/openfga/fgaMiddleware.py new file mode 100644 index 0000000000..627c775891 --- /dev/null +++ b/backend/app/openfga/fgaMiddleware.py @@ -0,0 +1,193 @@ +import logging +from typing import Any +from app.core.config import settings +from openfga_sdk import ( + Condition, + ConditionParamTypeRef, + CreateStoreRequest, + Metadata, + ObjectRelation, + OpenFgaClient, + ReadRequestTupleKey, + RelationMetadata, + RelationReference, + RelationshipCondition, + TypeDefinition, + Userset, + Usersets, + UserTypeFilter, + WriteAuthorizationModelRequest, +) +from openfga_sdk.client.models import ( + ClientAssertion, + ClientBatchCheckItem, + ClientBatchCheckRequest, + ClientCheckRequest, + ClientListObjectsRequest, + ClientListRelationsRequest, + ClientReadChangesRequest, + ClientTuple, + ClientWriteRequest, + WriteTransactionOpts, +) +from openfga_sdk.client.models.list_users_request import ClientListUsersRequest +from openfga_sdk.credentials import CredentialConfiguration, Credentials +from openfga_sdk.models.fga_object import FgaObject +from openfga_sdk.client.models.tuple import ClientTuple +from openfga_sdk.client import ClientConfiguration +from openfga_sdk.sync import OpenFgaClient + + +import traceback + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def initialize_fga_client(): + configuration = ClientConfiguration( + api_url=settings.OPENFGA_API_URL, # required + store_id=settings.OPENFGA_STORE_ID, # optional, not needed when calling `CreateStore` or `ListStores` + authorization_model_id=settings.OPENFGA_AUTHORIZATION_MODEL_ID, # optional, can be overridden per request + ) + # Enter a context with an instance of the OpenFgaClient + with OpenFgaClient(configuration) as fga_client: + return fga_client + +def close_fga_client(fga_client: OpenFgaClient): + fga_client.close() + +# Check if a user has a permission on an object +def check_user_has_permission(fga_client: OpenFgaClient, tuple: ClientTuple) -> bool: + if fga_client is None: # type: ignore + fga_client = initialize_fga_client() + + if fga_client is None: + logger.info("FGA client not initialized") # type: ignore + return False + + try: + response = fga_client.check( + ClientCheckRequest( + user=tuple.user, + relation=tuple.relation, + object=tuple.object + ) + ) + return response.allowed # type: ignore + except Exception as e: + logger.info(f"Error checking user permission: {e}") # type: ignore + return False + +# Check if a user has a permission on multiple objects +# Example list of ClientBatchCheckItem is: +# [ +# { +# "user": "user:123", +# "relation": "can_view", +# "object": "document:123" +# }, +# { +# "user": "user:123", +# "relation": "can_view", +# "object": "document:123" +# } +# ] +def check_user_has_permission_batch(fga_client: OpenFgaClient, tuples: list[ClientBatchCheckItem]) -> bool: + if fga_client is None: # type: ignore + fga_client = initialize_fga_client() + + if fga_client is None: + logger.info("FGA client not initialized") # type: ignore + return False + + logger.info(f"Check fga_client: {fga_client}") + logger.info(f"Check fga status: {fga_client.get_store_id()}") + + try: + response = fga_client.batch_check( + ClientBatchCheckRequest( + checks=tuples + ) + ) + return response.allowed # type: ignore + except Exception as e: + logger.info(f"Error checking user permission: {e}") # type: ignore + return False + +# Create a tuple +# Example list of ClientTuple is: +# [ +# { +# "user": "user:123", +# "relation": "can_view", +# "object": "document:123" +# } +# ] +def create_fga_tuple(fga_client: OpenFgaClient, tuples: list[ClientTuple]) -> bool: + if fga_client is None: # type: ignore + fga_client = initialize_fga_client() + + if fga_client is None: + logger.info("FGA client not initialized") # type: ignore + return False + + logger.info(f"Creating FGA tuple: {tuples}") + logger.info(f"So is this even working lol: {fga_client.get_store_id()}") + + try: + response = fga_client.write_tuples(tuples) + logger.info(f"Response vars: {vars(response)}") + return True + except Exception as e: + logger.info(f"Error creating FGA tuple: {e}") # type: ignore + return False + +# Delete a tuple +def delete_fga_tuple(fga_client: OpenFgaClient, tuples: list[ClientTuple]) -> bool: + # This is the opposite of the create_fga_tuple function + if fga_client is None: # type: ignore + fga_client = initialize_fga_client() + + if fga_client is None: + logger.info("FGA client not initialized") # type: ignore + return False + + try: + response = fga_client.write( + ClientWriteRequest( + deletes=tuples + ) + ) + return response.allowed # type: ignore + except Exception as e: + logger.info(f"Error deleting FGA tuple: {e}") # type: ignore + return False + + +def check_user_has_relation(fga_client: OpenFgaClient, relation: str, user: str) -> list[str]: + + if fga_client is None: # type: ignore + fga_client = initialize_fga_client() + + if fga_client is None: + logger.info("FGA client not initialized") # type: ignore + return [] + + body = ClientListObjectsRequest( + user=f"user:{user}", + relation=f"{relation}", + type="item" + ) + + try: + response = fga_client.list_objects(body) + # response.objects = ["document:otherdoc", "document:planning"] + # we need to return a list of objects + if len(response.objects) > 0: + return [item.split(":")[1] for item in response.objects] # type: ignore + else: + return [] + except Exception as e: + logger.info(f"Error checking user relation: {e}") + logger.info(traceback.format_exc()) + return [] \ No newline at end of file diff --git a/backend/app/tests_pre_start.py b/backend/app/tests_pre_start.py index 0ce6045635..d3ddc7dd2f 100644 --- a/backend/app/tests_pre_start.py +++ b/backend/app/tests_pre_start.py @@ -25,7 +25,7 @@ def init(db_engine: Engine) -> None: with Session(db_engine) as session: session.exec(select(1)) except Exception as e: - logger.error(e) + logger.info(e) raise e diff --git a/backend/pyproject.toml b/backend/pyproject.toml index d1fbd0641c..65a31c2c8d 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "pydantic-settings<3.0.0,>=2.2.1", "sentry-sdk[fastapi]<2.0.0,>=1.40.6", "pyjwt<3.0.0,>=2.8.0", + "openfga-sdk<=0.9.4", ] [tool.uv] diff --git a/docker-compose.fga.yml b/docker-compose.fga.yml new file mode 100644 index 0000000000..f0b22f25a7 --- /dev/null +++ b/docker-compose.fga.yml @@ -0,0 +1,36 @@ +services: + openfga: + image: openfga/openfga:latest + container_name: openfga + restart: always + tty: true + command: run --experimentals enable-list-users --http-addr 0.0.0.0:8085 --grpc-addr 0.0.0.0:8089 + networks: + - fga-network + ports: + - "8085:8085" + - "8089:8089" + - "3000:3000" + + openfga-init: + build: + context: ./scripts + dockerfile: Dockerfile.openfga-init + depends_on: + - openfga + volumes: + - my-app_openfga:/home:rw + - ./scripts:/Scripts:rw + networks: + - fga-network + environment: + - OPENFGA_API_URL=${OPENFGA_API_URL} + - OPENFGA_STORE_ID=${OPENFGA_STORE_ID} + - OPENFGA_AUTHORIZATION_MODEL_ID=${OPENFGA_AUTHORIZATION_MODEL_ID} + +volumes: + my-app_openfga: + +networks: + fga-network: + name: fga-network \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index b1aa17ed43..a6e2c8dd5c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,6 +74,9 @@ services: - POSTGRES_USER=${POSTGRES_USER?Variable not set} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set} - SENTRY_DSN=${SENTRY_DSN} + - OPENFGA_API_URL=${OPENFGA_API_URL} + - OPENFGA_STORE_ID=${OPENFGA_STORE_ID} + - OPENFGA_AUTHORIZATION_MODEL_ID=${OPENFGA_AUTHORIZATION_MODEL_ID} backend: image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}' @@ -81,6 +84,7 @@ services: networks: - traefik-public - default + - fga-network depends_on: db: condition: service_healthy @@ -107,6 +111,11 @@ services: - POSTGRES_USER=${POSTGRES_USER?Variable not set} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set} - SENTRY_DSN=${SENTRY_DSN} + - OPENFGA_API_URL=${OPENFGA_API_URL} + - OPENFGA_STORE_ID=${OPENFGA_STORE_ID} + - OPENFGA_AUTHORIZATION_MODEL_ID=${OPENFGA_AUTHORIZATION_MODEL_ID} + ports: + - 8000:8000 healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/utils/health-check/"] @@ -116,23 +125,23 @@ services: build: context: ./backend - labels: - - traefik.enable=true - - traefik.docker.network=traefik-public - - traefik.constraint-label=traefik-public + # labels: + # - traefik.enable=false + # - traefik.docker.network=traefik-public + # - traefik.constraint-label=traefik-public - - traefik.http.services.${STACK_NAME?Variable not set}-backend.loadbalancer.server.port=8000 + # - traefik.http.services.${STACK_NAME?Variable not set}-backend.loadbalancer.server.port=8000 - - traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.rule=Host(`api.${DOMAIN?Variable not set}`) - - traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.entrypoints=http + # - traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.rule=Host(`api.${DOMAIN?Variable not set}`) + # - traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.entrypoints=http - - traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.rule=Host(`api.${DOMAIN?Variable not set}`) - - traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.entrypoints=https - - traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.tls=true - - traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.tls.certresolver=le + # - traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.rule=Host(`api.${DOMAIN?Variable not set}`) + # - traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.entrypoints=https + # - traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.tls=true + # - traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.tls.certresolver=le - # Enable redirection for HTTP and HTTPS - - traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.middlewares=https-redirect + # # Enable redirection for HTTP and HTTPS + # - traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.middlewares=https-redirect frontend: image: '${DOCKER_IMAGE_FRONTEND?Variable not set}:${TAG-latest}' @@ -143,29 +152,32 @@ services: build: context: ./frontend args: - - VITE_API_URL=https://api.${DOMAIN?Variable not set} + - VITE_API_URL=http://localhost:8000 - NODE_ENV=production - labels: - - traefik.enable=true - - traefik.docker.network=traefik-public - - traefik.constraint-label=traefik-public + # labels: + # - traefik.enable=false + # - traefik.docker.network=traefik-public + # - traefik.constraint-label=traefik-public + + # - traefik.http.services.${STACK_NAME?Variable not set}-frontend.loadbalancer.server.port=80 - - traefik.http.services.${STACK_NAME?Variable not set}-frontend.loadbalancer.server.port=80 + # - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.rule=Host(`dashboard.${DOMAIN?Variable not set}`) + # - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.entrypoints=http - - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.rule=Host(`dashboard.${DOMAIN?Variable not set}`) - - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.entrypoints=http + # - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.rule=Host(`dashboard.${DOMAIN?Variable not set}`) + # - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.entrypoints=https + # - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.tls=true + # - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.tls.certresolver=le - - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.rule=Host(`dashboard.${DOMAIN?Variable not set}`) - - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.entrypoints=https - - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.tls=true - - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.tls.certresolver=le + # # Enable redirection for HTTP and HTTPS + # - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.middlewares=https-redirect - # Enable redirection for HTTP and HTTPS - - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.middlewares=https-redirect volumes: app-db-data: + my-app_openfga: networks: - traefik-public: - # Allow setting it to false for testing - external: true + default: + external: false + fga-network: + name: fga-network diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 156003aec9..844f4ac310 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -119,6 +119,29 @@ export class ItemsService { }) } + /** + * Can Update Item + * Check if a user has permission to update an item. + * @param data The data for the request. + * @param data.id + * @returns boolean Successful Response + * @throws ApiError + */ + public static canUpdateItem( + data: ItemsReadItemData, + ): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/api/v1/items/{id}/can-update", + path: { + id: data.id, + }, + errors: { + 422: "Validation Error", + }, + }) + } + /** * Update Item * Update an item. diff --git a/scripts/Dockerfile.openfga-init b/scripts/Dockerfile.openfga-init new file mode 100644 index 0000000000..65b830e8c7 --- /dev/null +++ b/scripts/Dockerfile.openfga-init @@ -0,0 +1,8 @@ +FROM alpine:3.19 + +RUN apk add --no-cache bash curl jq + +COPY openfga-init.sh /scripts/openfga-init.sh +COPY fga/model.json /scripts/fga/model.json + +ENTRYPOINT ["/bin/bash", "/scripts/openfga-init.sh"] \ No newline at end of file diff --git a/scripts/fga/model.json b/scripts/fga/model.json new file mode 100644 index 0000000000..a39bb68d19 --- /dev/null +++ b/scripts/fga/model.json @@ -0,0 +1,120 @@ +{ + "schema_version": "1.1", + "type_definitions": [ + { + "type": "user", + "relations": {}, + "metadata": null + }, + { + "type": "item", + "relations": { + "editor": { + "union": { + "child": [ + { + "this": {} + }, + { + "computedUserset": { + "relation": "owner" + } + } + ] + } + }, + "completed": { + "union": { + "child": [ + { + "this": {} + }, + { + "computedUserset": { + "relation": "owner" + } + } + ] + } + }, + "inprogress": { + "this": {} + }, + "owner": { + "this": {} + }, + "recommened": { + "this": {} + }, + "alternative": { + "this": {} + }, + "can_recommend": { + "union": { + "child": [ + { + "computedUserset": { + "relation": "completed" + } + }, + { + "computedUserset": { + "relation": "owner" + } + } + ] + } + } + }, + "metadata": { + "relations": { + "editor": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + }, + "completed": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + }, + "inprogress": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + }, + "owner": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + }, + "recommened": { + "directly_related_user_types": [ + { + "type": "user" + } + ] + }, + "alternative": { + "directly_related_user_types": [ + { + "type": "item" + } + ] + }, + "can_recommend": { + "directly_related_user_types": [] + } + } + } + } + ] +} \ No newline at end of file diff --git a/scripts/openfga-init.sh b/scripts/openfga-init.sh new file mode 100644 index 0000000000..b24fcfa71c --- /dev/null +++ b/scripts/openfga-init.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +set -e + +# Wait for dependencies to be ready +sleep 20 + +echo "Creating FGA store..." +sleep 1 +echo '.' +sleep 1 +echo '.' +sleep 1 +echo '.' +sleep 1 +echo '.' +STORE_ID=$(curl -v -s -X POST http://openfga:8085/stores -H 'Content-Type: application/json' -d '{"name": "my-app"}' | jq -r '.id') +if [ -z "$STORE_ID" ]; then + echo "Error: Failed to create FGA store. Exiting." + exit 1 +fi +echo "FGA Store ID: $STORE_ID" + +echo "Creating authorization model from /my-app_openfga/model.json..." +if [ ! -f /Scripts/fga/model.json ]; then + echo "Error: Authorization model file not found at /my-app_openfga/model.json. Exiting." + exit 1 +fi +AUTH_MODEL_ID=$(curl -s -X POST http://openfga:8085/stores/$STORE_ID/authorization-models -H 'Content-Type: application/json' -d @/Scripts/fga/model.json | jq -r '.authorization_model_id') +if [ -z "$AUTH_MODEL_ID" ]; then + echo "Error: Failed to create authorization model. Exiting." + exit 1 +fi +echo "Authorization Model ID: $AUTH_MODEL_ID" + +# Save variables to a file +printf "{\n \"storeId\": \"%s\",\n \"authModelId\": \"%s\"\n}" "$STORE_ID" "$AUTH_MODEL_ID" > /home/fga_variables.json + +echo "Initialization complete. Keeping the container running..." +wait \ No newline at end of file