Skip to content

Commit

Permalink
feat(api): project token management (#617)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
inimaz authored Jul 26, 2024
1 parent a670f64 commit 049b1bf
Show file tree
Hide file tree
Showing 11 changed files with 460 additions and 0 deletions.
17 changes: 17 additions & 0 deletions carbonserver/carbonserver/api/domain/project_tokens.py
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions carbonserver/carbonserver/api/infra/database/sql_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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'<ApiKey(project_id="{self.project_id}", '
f'api_key="{self.token}", '
f'name="{self.name}", '
f'last_used="{self.last_used}", '
f'access="{self.access}", '
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from contextlib import AbstractContextManager

from dependency_injector.providers import Callable
from fastapi import HTTPException

from carbonserver.api.domain.project_tokens import ProjectTokens
from carbonserver.api.infra.api_key_service import generate_api_key
from carbonserver.api.infra.database.sql_models import (
ProjectToken as SqlModelProjectToken,
)
from carbonserver.api.schemas import ProjectToken, ProjectTokenCreate


class SqlAlchemyRepository(ProjectTokens):
def __init__(self, session_factory) -> 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,
)
60 changes: 60 additions & 0 deletions carbonserver/carbonserver/api/routers/project_api_tokens.py
Original file line number Diff line number Diff line change
@@ -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)
41 changes: 41 additions & 0 deletions carbonserver/carbonserver/api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"""

from datetime import datetime
from enum import Enum
from typing import List, Optional
from uuid import UUID

Expand Down Expand Up @@ -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]] = []
Expand Down
18 changes: 18 additions & 0 deletions carbonserver/carbonserver/api/services/project_token_service.py
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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")
11 changes: 11 additions & 0 deletions carbonserver/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
repository_experiments,
repository_organizations,
repository_projects,
repository_projects_tokens,
repository_runs,
repository_users,
)
from carbonserver.api.services.emissions_service import EmissionService
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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions carbonserver/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
emissions,
experiments,
organizations,
project_api_tokens,
projects,
runs,
users,
Expand Down Expand Up @@ -60,6 +61,7 @@ def init_container():
runs,
experiments,
projects,
project_api_tokens,
organizations,
users,
authenticate,
Expand All @@ -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)
Expand Down
Loading

0 comments on commit 049b1bf

Please sign in to comment.