From 049b1bfa126f0e6038cad4914d4537d8df8e1655 Mon Sep 17 00:00:00 2001 From: inimaz <49730431+inimaz@users.noreply.github.com> Date: Fri, 26 Jul 2024 19:27:09 +0200 Subject: [PATCH] feat(api): project token management (#617) * feat(api): project token management * tests + alembic revision Signed-off-by: inimaz <93inigo93@gmail.com> * fix: default access level is WRITE --------- Signed-off-by: inimaz <93inigo93@gmail.com> --- .../carbonserver/api/domain/project_tokens.py | 17 +++ .../api/infra/database/sql_models.py | 22 ++++ .../repository_projects_tokens.py | 76 +++++++++++++ .../api/routers/project_api_tokens.py | 60 +++++++++++ carbonserver/carbonserver/api/schemas.py | 41 +++++++ .../api/services/project_token_service.py | 18 ++++ .../caf929e09f7c_add_project_tokens.py | 47 ++++++++ carbonserver/container.py | 11 ++ carbonserver/main.py | 3 + .../tests/api/routers/test_project_tokens.py | 100 ++++++++++++++++++ .../service/test_project_tokens_service.py | 65 ++++++++++++ 11 files changed, 460 insertions(+) create mode 100644 carbonserver/carbonserver/api/domain/project_tokens.py create mode 100644 carbonserver/carbonserver/api/infra/repositories/repository_projects_tokens.py create mode 100644 carbonserver/carbonserver/api/routers/project_api_tokens.py create mode 100644 carbonserver/carbonserver/api/services/project_token_service.py create mode 100644 carbonserver/carbonserver/database/alembic/versions/caf929e09f7c_add_project_tokens.py create mode 100644 carbonserver/tests/api/routers/test_project_tokens.py create mode 100644 carbonserver/tests/api/service/test_project_tokens_service.py diff --git a/carbonserver/carbonserver/api/domain/project_tokens.py b/carbonserver/carbonserver/api/domain/project_tokens.py new file mode 100644 index 000000000..fb311b5d6 --- /dev/null +++ b/carbonserver/carbonserver/api/domain/project_tokens.py @@ -0,0 +1,17 @@ +import abc + +from carbonserver.api import schemas + + +class ProjectTokens(abc.ABC): + @abc.abstractmethod + def add_project_token(self, project_id: str, project: schemas.ProjectTokenCreate): + raise NotImplementedError + + @abc.abstractmethod + def delete_project_token(self, project_id: str, token_id: str): + raise NotImplementedError + + @abc.abstractmethod + def list_project_tokens(self, project_id: str): + raise NotImplementedError diff --git a/carbonserver/carbonserver/api/infra/database/sql_models.py b/carbonserver/carbonserver/api/infra/database/sql_models.py index c6ea01c8f..4a0479f6d 100644 --- a/carbonserver/carbonserver/api/infra/database/sql_models.py +++ b/carbonserver/carbonserver/api/infra/database/sql_models.py @@ -113,6 +113,7 @@ class Project(Base): organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id")) experiments = relationship("Experiment", back_populates="project") organization = relationship("Organization", back_populates="projects") + project_tokens = relationship("ProjectToken", back_populates="project") def __repr__(self): return ( @@ -156,3 +157,24 @@ def __repr__(self): f'is_active="{self.is_active}", ' f'email="{self.email}")>' ) + + +class ProjectToken(Base): + __tablename__ = "project_tokens" + id = Column(UUID(as_uuid=True), primary_key=True, index=True, default=uuid.uuid4) + project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id")) + name = Column(String) + token = Column(String, unique=True) + project = relationship("Project", back_populates="project_tokens") + last_used = Column(DateTime, nullable=True) + # Permissions + access = Column(Integer) + + def __repr__(self): + return ( + f' Callable[..., AbstractContextManager]: + self.session_factory = session_factory + + def add_project_token(self, project_id: str, project_token: ProjectTokenCreate): + token = f"pt_{generate_api_key()}" # pt stands for project token + with self.session_factory() as session: + db_project_token = SqlModelProjectToken( + project_id=project_id, + token=token, + name=project_token.name, + access=project_token.access, + ) + session.add(db_project_token) + session.commit() + session.refresh(db_project_token) + return self.map_sql_to_schema(db_project_token) + + def delete_project_token(self, project_id: str, token_id: str): + with self.session_factory() as session: + db_project_token = ( + session.query(SqlModelProjectToken) + .filter( + SqlModelProjectToken.id == token_id + and SqlModelProjectToken.project_id == project_id + ) + .first() + ) + if db_project_token is None: + raise HTTPException( + status_code=404, detail=f"Project token {token_id} not found" + ) + session.delete(db_project_token) + session.commit() + + def list_project_tokens(self, project_id: str): + with self.session_factory() as session: + db_project_tokens = ( + session.query(SqlModelProjectToken) + .filter(SqlModelProjectToken.project_id == project_id) + .all() + ) + return [ + self.map_sql_to_schema(project_token) + for project_token in db_project_tokens + ] + + @staticmethod + def map_sql_to_schema(project_token: SqlModelProjectToken) -> ProjectToken: + """Convert a models.ProjectToken to a schemas.ProjectToken + + :project: An ProjectToken in SQLAlchemy format. + :returns: An ProjectToken in pyDantic BaseModel format. + :rtype: schemas.Project + """ + return ProjectToken( + id=str(project_token.id), + name=project_token.name, + project_id=project_token.project_id, + token=project_token.token, + last_used=project_token.last_used, + access=project_token.access, + ) diff --git a/carbonserver/carbonserver/api/routers/project_api_tokens.py b/carbonserver/carbonserver/api/routers/project_api_tokens.py new file mode 100644 index 000000000..d8f522ece --- /dev/null +++ b/carbonserver/carbonserver/api/routers/project_api_tokens.py @@ -0,0 +1,60 @@ +from typing import List + +from container import ServerContainer +from dependency_injector.wiring import Provide, inject +from fastapi import APIRouter, Depends +from starlette import status + +from carbonserver.api.dependencies import get_token_header +from carbonserver.api.schemas import ProjectToken, ProjectTokenCreate + +PROJECTS_TOKENS_ROUTER_TAGS = ["Project tokens"] + +router = APIRouter( + dependencies=[Depends(get_token_header)], +) + + +# Create project token +@router.post( + "/projects/{project_id}/api-tokens", + tags=PROJECTS_TOKENS_ROUTER_TAGS, + status_code=status.HTTP_201_CREATED, + response_model=ProjectToken, +) +@inject +def add_project_token( + project_id: str, + project_token: ProjectTokenCreate, + project_token_service=Depends(Provide[ServerContainer.project_token_service]), +) -> ProjectToken: + return project_token_service.add_project_token(project_id, project_token) + + +# Delete project token +@router.delete( + "/projects/{project_id}/api-tokens/{token_id}", + tags=PROJECTS_TOKENS_ROUTER_TAGS, + status_code=status.HTTP_204_NO_CONTENT, +) +@inject +def delete_project_token( + project_id: str, + token_id: str, + project_token_service=Depends(Provide[ServerContainer.project_token_service]), +) -> None: + return project_token_service.delete_project_token(project_id, token_id) + + +# See all project tokens of the project +@router.get( + "/projects/{project_id}/api-tokens", + tags=PROJECTS_TOKENS_ROUTER_TAGS, + response_model=List[ProjectToken], +) +@inject +def get_all_project_tokens( + project_id: str, + project_token_service=Depends(Provide[ServerContainer.project_token_service]), +) -> List[ProjectToken]: + return project_token_service.list_tokens_from_project(project_id) diff --git a/carbonserver/carbonserver/api/schemas.py b/carbonserver/carbonserver/api/schemas.py index 29d28eb3f..66bdc93c1 100644 --- a/carbonserver/carbonserver/api/schemas.py +++ b/carbonserver/carbonserver/api/schemas.py @@ -9,6 +9,7 @@ """ from datetime import datetime +from enum import Enum from typing import List, Optional from uuid import UUID @@ -263,6 +264,46 @@ class Config: } +class AccessLevel(Enum): + READ = 1 + WRITE = 2 + READ_WRITE = 3 + + +class ProjectToken(BaseModel): + id: UUID + project_id: UUID + name: Optional[str] + token: str + last_used: Optional[datetime] = None + access: int = AccessLevel.WRITE.value + + class Config: + schema_extra = { + "example": { + "id": "8edb03e1-9a28-452a-9c93-a3b6560136d7", + "project_id": "8edb03e1-9a28-452a-9c93-a3b6560136d7", + "name": "my project token", + "token": "8edb03e1-9a28-452a-9c93-a3b6560136d7", + "last_used": "2021-04-04T08:43:00+02:00", + "access": 1, + } + } + + +class ProjectTokenCreate(BaseModel): + name: Optional[str] + access: int = AccessLevel.WRITE.value + + class Config: + schema_extra = { + "example": { + "name": "my project token", + "access": 1, + } + } + + class Project(ProjectBase): id: UUID experiments: Optional[List[Experiment]] = [] diff --git a/carbonserver/carbonserver/api/services/project_token_service.py b/carbonserver/carbonserver/api/services/project_token_service.py new file mode 100644 index 000000000..fb84cd250 --- /dev/null +++ b/carbonserver/carbonserver/api/services/project_token_service.py @@ -0,0 +1,18 @@ +from carbonserver.api.infra.repositories.repository_projects_tokens import ( + SqlAlchemyRepository as ProjectTokensSqlRepository, +) +from carbonserver.api.schemas import ProjectTokenCreate + + +class ProjectTokenService: + def __init__(self, project_token_repository: ProjectTokensSqlRepository): + self._repository = project_token_repository + + def add_project_token(self, project_id, project_token: ProjectTokenCreate): + return self._repository.add_project_token(project_id, project_token) + + def delete_project_token(self, project_id, token_id): + return self._repository.delete_project_token(project_id, token_id) + + def list_tokens_from_project(self, project_id): + return self._repository.list_project_tokens(project_id) diff --git a/carbonserver/carbonserver/database/alembic/versions/caf929e09f7c_add_project_tokens.py b/carbonserver/carbonserver/database/alembic/versions/caf929e09f7c_add_project_tokens.py new file mode 100644 index 000000000..8cca42f00 --- /dev/null +++ b/carbonserver/carbonserver/database/alembic/versions/caf929e09f7c_add_project_tokens.py @@ -0,0 +1,47 @@ +"""add project tokens + +Revision ID: caf929e09f7c +Revises: 7ace119b161f +Create Date: 2024-07-25 19:51:43.046273 + +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import UUID + +# revision identifiers, used by Alembic. +revision = "caf929e09f7c" +down_revision = "7ace119b161f" +branch_labels = None +depends_on = None + + +def upgrade(): + + # Create the project_tokens table + + op.create_table( + "project_tokens", + sa.Column("id", UUID(as_uuid=True), primary_key=True, index=True), + sa.Column("project_id", UUID(as_uuid=True)), + sa.Column("token", sa.String, unique=True), + sa.Column("name", sa.String), + sa.Column("access", sa.Integer), + sa.Column("last_used", sa.DateTime, nullable=True), + ) + # Create the foreign key constraint between the project_tokens and projects tables + op.create_foreign_key( + "fk_project_tokens_projects", + "project_tokens", + "projects", + ["project_id"], + ["id"], + ) + + +def downgrade(): + op.drop_constraint( + "fk_project_tokens_projects", "project_tokens", type_="foreignkey" + ) + op.drop_table("project_tokens") diff --git a/carbonserver/container.py b/carbonserver/container.py index 2359b0539..5a945138b 100644 --- a/carbonserver/container.py +++ b/carbonserver/container.py @@ -6,6 +6,7 @@ repository_experiments, repository_organizations, repository_projects, + repository_projects_tokens, repository_runs, repository_users, ) @@ -13,6 +14,7 @@ from carbonserver.api.services.experiments_service import ExperimentService from carbonserver.api.services.organization_service import OrganizationService from carbonserver.api.services.project_service import ProjectService +from carbonserver.api.services.project_token_service import ProjectTokenService from carbonserver.api.services.run_service import RunService from carbonserver.api.services.signup_service import SignUpService from carbonserver.api.services.user_service import UserService @@ -50,6 +52,10 @@ class ServerContainer(containers.DeclarativeContainer): repository_projects.SqlAlchemyRepository, session_factory=db.provided.session, ) + project_token_repository = providers.Factory( + repository_projects_tokens.SqlAlchemyRepository, + session_factory=db.provided.session, + ) user_repository = providers.Factory( repository_users.SqlAlchemyRepository, @@ -85,6 +91,11 @@ class ServerContainer(containers.DeclarativeContainer): project_repository=project_repository, ) + project_token_service = providers.Factory( + ProjectTokenService, + project_token_repository=project_token_repository, + ) + run_repository = providers.Factory( repository_runs.SqlAlchemyRepository, session_factory=db.provided.session, diff --git a/carbonserver/main.py b/carbonserver/main.py index 43b6c3064..59c9e1e01 100644 --- a/carbonserver/main.py +++ b/carbonserver/main.py @@ -13,6 +13,7 @@ emissions, experiments, organizations, + project_api_tokens, projects, runs, users, @@ -60,6 +61,7 @@ def init_container(): runs, experiments, projects, + project_api_tokens, organizations, users, authenticate, @@ -81,6 +83,7 @@ def init_server(container): server.include_router(authenticate.router) server.include_router(organizations.router) server.include_router(projects.router) + server.include_router(project_api_tokens.router) server.include_router(experiments.router) server.include_router(experiments.router) server.include_router(runs.router) diff --git a/carbonserver/tests/api/routers/test_project_tokens.py b/carbonserver/tests/api/routers/test_project_tokens.py new file mode 100644 index 000000000..2418c1c0a --- /dev/null +++ b/carbonserver/tests/api/routers/test_project_tokens.py @@ -0,0 +1,100 @@ +from unittest import mock + +import pytest +from container import ServerContainer +from fastapi import FastAPI, status +from fastapi.testclient import TestClient + +from carbonserver.api.infra.repositories.repository_projects_tokens import ( + SqlAlchemyRepository, +) +from carbonserver.api.routers import project_api_tokens +from carbonserver.api.schemas import ProjectToken + +PROJECT_ID = "f52fe339-164d-4c2b-a8c0-f562dfce066d" + + +PROJECT_TOKEN_ID = "c13e851f-5c2f-403d-98d0-51fe15df3bc3" + +PROJECT_TOKEN_TO_CREATE = { + "name": "Token API Code Carbon", + "access": 2, +} + +PROJECT_TOKEN = { + "id": PROJECT_TOKEN_ID, + "project_id": PROJECT_ID, + "name": "Token API Code Carbon", + "token": "token", + "access": 2, + "last_used": None, +} + + +@pytest.fixture +def custom_test_server(): + container = ServerContainer() + container.wire(modules=[project_api_tokens]) + app = FastAPI() + app.container = container + app.include_router(project_api_tokens.router) + yield app + + +@pytest.fixture +def client(custom_test_server): + yield TestClient(custom_test_server) + + +def test_add_project_token(client, custom_test_server): + repository_mock = mock.Mock(spec=SqlAlchemyRepository) + expected_project_token = PROJECT_TOKEN + repository_mock.add_project_token.return_value = ProjectToken(**PROJECT_TOKEN) + + with custom_test_server.container.project_token_repository.override( + repository_mock + ): + response = client.post( + "/projects/{PROJECT_ID}/api-tokens", json=PROJECT_TOKEN_TO_CREATE + ) + actual_project_token = response.json() + + assert response.status_code == status.HTTP_201_CREATED + assert actual_project_token == expected_project_token + + +def test_delete_project_token(client, custom_test_server): + repository_mock = mock.Mock(spec=SqlAlchemyRepository) + repository_mock.delete_project_token.return_value = None + + with custom_test_server.container.project_token_repository.override( + repository_mock + ): + response = client.delete( + f"/projects/{PROJECT_ID}/api-tokens/{PROJECT_TOKEN_ID}" + ) + + assert response.status_code == status.HTTP_204_NO_CONTENT + repository_mock.delete_project_token.assert_called_once_with( + PROJECT_ID, PROJECT_TOKEN_ID + ) + + +def test_get_projects_from_organization_returns_correct_project( + client, custom_test_server +): + repository_mock = mock.Mock(spec=SqlAlchemyRepository) + expected_project_token = PROJECT_TOKEN + expected_project_token_list = [expected_project_token] + repository_mock.list_project_tokens.return_value = [ + ProjectToken(**expected_project_token), + ] + + with custom_test_server.container.project_token_repository.override( + repository_mock + ): + response = client.get(f"/projects/{PROJECT_ID}/api-tokens") + actual_project_token_list = response.json() + + assert response.status_code == status.HTTP_200_OK + assert actual_project_token_list == expected_project_token_list diff --git a/carbonserver/tests/api/service/test_project_tokens_service.py b/carbonserver/tests/api/service/test_project_tokens_service.py new file mode 100644 index 000000000..b29e5334a --- /dev/null +++ b/carbonserver/tests/api/service/test_project_tokens_service.py @@ -0,0 +1,65 @@ +from unittest import mock +from uuid import UUID + +from carbonserver.api.infra.repositories.repository_projects_tokens import ( + SqlAlchemyRepository, +) +from carbonserver.api.schemas import AccessLevel, ProjectToken, ProjectTokenCreate +from carbonserver.api.services.project_token_service import ProjectTokenService + +PROJECT_ID = UUID("f52fe339-164d-4c2b-a8c0-f562dfce066d") + +PROJECT_TOKEN_ID = UUID("e60afb92-17b7-4720-91a0-1ae91e409ba7") + +PROJECT_TOKEN = ProjectToken( + id=PROJECT_TOKEN_ID, + project_id=PROJECT_ID, + name="Project", + token="token", + access=AccessLevel.READ.value, +) + + +@mock.patch("uuid.uuid4", return_value=PROJECT_TOKEN_ID) +def test_project_token_service_creates_correct_project_token(_): + repository_mock: SqlAlchemyRepository = mock.Mock(spec=SqlAlchemyRepository) + expected_id = PROJECT_TOKEN_ID + project_token_service: ProjectTokenService = ProjectTokenService(repository_mock) + repository_mock.add_project_token.return_value = PROJECT_TOKEN + + project_token_to_create = ProjectTokenCreate( + name="Project", + access=AccessLevel.READ.value, + ) + + actual_saved_project_token = project_token_service.add_project_token( + str(PROJECT_ID), project_token_to_create + ) + + assert actual_saved_project_token.id == expected_id + assert actual_saved_project_token.project_id == PROJECT_ID + + +def test_project_token_service_deletes_correct_project_token(): + repository_mock: SqlAlchemyRepository = mock.Mock(spec=SqlAlchemyRepository) + project_token_service: ProjectTokenService = ProjectTokenService(repository_mock) + repository_mock.delete_project_token.return_value = None + + project_token_service.delete_project_token(PROJECT_ID, PROJECT_TOKEN_ID) + + # Check that the repository delete_project method was called with the correct project_id + repository_mock.delete_project_token.assert_called_once_with( + PROJECT_ID, PROJECT_TOKEN_ID + ) + + +def test_project_token_service_retrieves_correct_tokens_by_project_id(): + repository_mock: SqlAlchemyRepository = mock.Mock(spec=SqlAlchemyRepository) + expected_project_token = PROJECT_TOKEN + project_token_service: ProjectTokenService = ProjectTokenService(repository_mock) + repository_mock.list_project_tokens.return_value = [PROJECT_TOKEN] + + response = project_token_service.list_tokens_from_project(PROJECT_ID) + + assert len(response) == 1 + assert response[0] == expected_project_token