diff --git a/.env.example b/.env.example index eaee669..b8755dd 100644 --- a/.env.example +++ b/.env.example @@ -26,3 +26,6 @@ TOOL_SUBDOMAIN_PATTERN={tool}-{project}-{user}.tools.${ROOT_DOMAIN} # Secrets (generate strong random values for production) SECRET_ENCRYPTION_KEY=change-me-in-production + +# Auth dev bypass (local development only — NEVER enable in production) +AUTH_DEV_BYPASS=false diff --git a/.fusion/fusion.db b/.fusion/fusion.db index 8f15bff..4918362 100644 Binary files a/.fusion/fusion.db and b/.fusion/fusion.db differ diff --git a/.fusion/fusion.db-shm b/.fusion/fusion.db-shm deleted file mode 100644 index e579f29..0000000 Binary files a/.fusion/fusion.db-shm and /dev/null differ diff --git a/.fusion/fusion.db-wal b/.fusion/fusion.db-wal deleted file mode 100644 index 84dd245..0000000 Binary files a/.fusion/fusion.db-wal and /dev/null differ diff --git a/.fusion/memory/MEMORY.md b/.fusion/memory/MEMORY.md index 3407ce8..3f22275 100644 --- a/.fusion/memory/MEMORY.md +++ b/.fusion/memory/MEMORY.md @@ -17,3 +17,13 @@ ## Context + +**Headquarter Backend Patterns (FN-004)** + +- Use SQLAlchemy 2.0 async style with `mapped_column` + `Mapped[]` syntax +- For Alembic + asyncpg: rewrite `postgresql://` to `postgresql+asyncpg://` in both `app/db.py` and `alembic/env.py` +- For pytest + async SQLAlchemy testing: use `pytest-asyncio==0.21.2` (not 1.3.0) with `asyncio_mode = "auto"`, `NullPool`, and a session-scoped `event_loop` fixture to avoid event loop mismatches with asyncpg +- Test DB isolation: `begin_nested()` on a connection + `async_sessionmaker` bound to that connection; override `get_db_session` dependency +- Secret encryption: derive Fernet key from settings string via `SHA-256 + base64.urlsafe_b64encode` +- Auth dev bypass: only active when `settings.debug and settings.auth_dev_bypass`; creates/returns a fixed `authentik_sub="dev-user"` +- JWT production path: fetch OIDC discovery → get `jwks_uri` → fetch JWKS → match by `kid` → decode with `jwt.decode(..., algorithms=["RS256"])` diff --git a/.worktrees/hazy-peach b/.worktrees/hazy-peach new file mode 160000 index 0000000..08a21ea --- /dev/null +++ b/.worktrees/hazy-peach @@ -0,0 +1 @@ +Subproject commit 08a21eadb78bb1eb29dcadc348b3e727187fb3be diff --git a/.worktrees/sharp-robin b/.worktrees/sharp-robin new file mode 160000 index 0000000..f0169ad --- /dev/null +++ b/.worktrees/sharp-robin @@ -0,0 +1 @@ +Subproject commit f0169ad51d18a07b1092e0c86ac60e33e2a0a1fc diff --git a/apps/api/Makefile b/apps/api/Makefile new file mode 100644 index 0000000..03c6c93 --- /dev/null +++ b/apps/api/Makefile @@ -0,0 +1,19 @@ +.PHONY: revision upgrade downgrade lint test typecheck + +revision: + .venv/bin/alembic revision --autogenerate -m "$(msg)" + +upgrade: + .venv/bin/alembic upgrade head + +downgrade: + .venv/bin/alembic downgrade -1 + +lint: + .venv/bin/ruff check app tests + +test: + .venv/bin/pytest + +typecheck: + .venv/bin/mypy app tests diff --git a/apps/api/alembic.ini b/apps/api/alembic.ini new file mode 100644 index 0000000..e777e87 --- /dev/null +++ b/apps/api/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = postgresql+asyncpg:// + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/apps/api/alembic/README b/apps/api/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/apps/api/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/apps/api/alembic/env.py b/apps/api/alembic/env.py new file mode 100644 index 0000000..ad0cdb9 --- /dev/null +++ b/apps/api/alembic/env.py @@ -0,0 +1,76 @@ +import asyncio +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context +from app.config import settings +from app.models import Base + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +target_metadata = Base.metadata + +# Build async URL from settings +database_url = settings.database_url +if database_url.startswith("postgresql://"): + database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1) + +config.set_main_option("sqlalchemy.url", database_url) + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode.""" + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/apps/api/alembic/script.py.mako b/apps/api/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/apps/api/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/apps/api/alembic/versions/6cfa61694d0a_initial_schema.py b/apps/api/alembic/versions/6cfa61694d0a_initial_schema.py new file mode 100644 index 0000000..7a46ea6 --- /dev/null +++ b/apps/api/alembic/versions/6cfa61694d0a_initial_schema.py @@ -0,0 +1,172 @@ +"""initial schema + +Revision ID: 6cfa61694d0a +Revises: +Create Date: 2026-05-14 06:16:27.700389 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '6cfa61694d0a' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('secret', + sa.Column('scope_type', sa.String(length=50), nullable=False), + sa.Column('scope_id', sa.Uuid(), nullable=False), + sa.Column('key', sa.String(length=255), nullable=False), + sa.Column('encrypted_value', sa.Text(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('scope_type', 'scope_id', 'key') + ) + op.create_index(op.f('ix_secret_scope_id'), 'secret', ['scope_id'], unique=False) + op.create_table('tool_definition', + sa.Column('key', sa.String(length=100), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('version', sa.String(length=50), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('image', sa.Text(), nullable=False), + sa.Column('manifest_data', sa.JSON(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_tool_definition_key'), 'tool_definition', ['key'], unique=True) + op.create_table('user', + sa.Column('authentik_sub', sa.String(length=255), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('display_name', sa.String(length=255), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_user_authentik_sub'), 'user', ['authentik_sub'], unique=True) + op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True) + op.create_table('config', + sa.Column('scope_type', sa.String(length=50), nullable=False), + sa.Column('scope_id', sa.Uuid(), nullable=False), + sa.Column('tool_definition_id', sa.Uuid(), nullable=True), + sa.Column('key', sa.String(length=255), nullable=False), + sa.Column('value', sa.JSON(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['tool_definition_id'], ['tool_definition.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('scope_type', 'scope_id', 'tool_definition_id', 'key') + ) + op.create_index(op.f('ix_config_scope_id'), 'config', ['scope_id'], unique=False) + op.create_index(op.f('ix_config_tool_definition_id'), 'config', ['tool_definition_id'], unique=False) + op.create_table('project', + sa.Column('owner_id', sa.Uuid(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('slug', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('owner_id', 'slug') + ) + op.create_index(op.f('ix_project_owner_id'), 'project', ['owner_id'], unique=False) + op.create_table('repository', + sa.Column('project_id', sa.Uuid(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('git_url', sa.Text(), nullable=False), + sa.Column('provider_type', sa.String(length=50), nullable=False), + sa.Column('default_branch', sa.String(length=100), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['project_id'], ['project.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_repository_project_id'), 'repository', ['project_id'], unique=False) + op.create_table('tool_instance', + sa.Column('project_id', sa.Uuid(), nullable=False), + sa.Column('tool_definition_id', sa.Uuid(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('status', sa.String(length=50), nullable=False), + sa.Column('container_id', sa.String(length=255), nullable=True), + sa.Column('subdomain', sa.String(length=255), nullable=True), + sa.Column('config_override', sa.JSON(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['project_id'], ['project.id'], ), + sa.ForeignKeyConstraint(['tool_definition_id'], ['tool_definition.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('subdomain') + ) + op.create_index(op.f('ix_tool_instance_project_id'), 'tool_instance', ['project_id'], unique=False) + op.create_index(op.f('ix_tool_instance_tool_definition_id'), 'tool_instance', ['tool_definition_id'], unique=False) + op.create_table('workspace', + sa.Column('project_id', sa.Uuid(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('mount_path', sa.Text(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['project_id'], ['project.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_workspace_project_id'), 'workspace', ['project_id'], unique=False) + op.create_table('access_route', + sa.Column('tool_instance_id', sa.Uuid(), nullable=False), + sa.Column('domain', sa.Text(), nullable=False), + sa.Column('path_prefix', sa.String(length=255), nullable=False), + sa.Column('provider_type', sa.String(length=50), nullable=False), + sa.Column('provider_config', sa.JSON(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['tool_instance_id'], ['tool_instance.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_access_route_tool_instance_id'), 'access_route', ['tool_instance_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_access_route_tool_instance_id'), table_name='access_route') + op.drop_table('access_route') + op.drop_index(op.f('ix_workspace_project_id'), table_name='workspace') + op.drop_table('workspace') + op.drop_index(op.f('ix_tool_instance_tool_definition_id'), table_name='tool_instance') + op.drop_index(op.f('ix_tool_instance_project_id'), table_name='tool_instance') + op.drop_table('tool_instance') + op.drop_index(op.f('ix_repository_project_id'), table_name='repository') + op.drop_table('repository') + op.drop_index(op.f('ix_project_owner_id'), table_name='project') + op.drop_table('project') + op.drop_index(op.f('ix_config_tool_definition_id'), table_name='config') + op.drop_index(op.f('ix_config_scope_id'), table_name='config') + op.drop_table('config') + op.drop_index(op.f('ix_user_email'), table_name='user') + op.drop_index(op.f('ix_user_authentik_sub'), table_name='user') + op.drop_table('user') + op.drop_index(op.f('ix_tool_definition_key'), table_name='tool_definition') + op.drop_table('tool_definition') + op.drop_index(op.f('ix_secret_scope_id'), table_name='secret') + op.drop_table('secret') + # ### end Alembic commands ### diff --git a/apps/api/app/auth/__init__.py b/apps/api/app/auth/__init__.py new file mode 100644 index 0000000..d3c7b23 --- /dev/null +++ b/apps/api/app/auth/__init__.py @@ -0,0 +1,3 @@ +from app.auth.dependencies import get_current_active_user, get_current_user + +__all__ = ["get_current_user", "get_current_active_user"] diff --git a/apps/api/app/auth/dependencies.py b/apps/api/app/auth/dependencies.py new file mode 100644 index 0000000..3f5a6bf --- /dev/null +++ b/apps/api/app/auth/dependencies.py @@ -0,0 +1,93 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.jwt import decode_token +from app.config import settings +from app.db import get_db_session +from app.models.user import User + +bearer_scheme = HTTPBearer(auto_error=False) + + +async def _get_or_create_dev_user(session: AsyncSession) -> User: + """Return or create the fixed development user.""" + result = await session.execute( + select(User).where(User.authentik_sub == "dev-user") + ) + user = result.scalar_one_or_none() + if user is None: + user = User( + authentik_sub="dev-user", + email="dev@localhost", + display_name="Dev User", + is_active=True, + ) + session.add(user) + await session.commit() + await session.refresh(user) + return user + + +async def get_current_user( + token: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), + session: AsyncSession = Depends(get_db_session), +) -> User: + if token is None: + if settings.debug and settings.auth_dev_bypass: + return await _get_or_create_dev_user(session) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + claims = decode_token(token.credentials) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"Invalid token: {exc}", + headers={"WWW-Authenticate": "Bearer"}, + ) from exc + + authentik_sub = claims.get("sub") + email = claims.get("email", "") + display_name = claims.get("name") or claims.get("preferred_username") or email + + if not authentik_sub: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token missing 'sub' claim", + headers={"WWW-Authenticate": "Bearer"}, + ) + + result = await session.execute( + select(User).where(User.authentik_sub == authentik_sub) + ) + user = result.scalar_one_or_none() + + if user is None: + user = User( + authentik_sub=authentik_sub, + email=email, + display_name=display_name, + is_active=True, + ) + session.add(user) + await session.commit() + await session.refresh(user) + + return user + + +async def get_current_active_user( + current_user: User = Depends(get_current_user), +) -> User: + if not current_user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Inactive user", + ) + return current_user diff --git a/apps/api/app/auth/jwt.py b/apps/api/app/auth/jwt.py new file mode 100644 index 0000000..476a345 --- /dev/null +++ b/apps/api/app/auth/jwt.py @@ -0,0 +1,53 @@ +from typing import Any + +import jwt + +from app.config import settings + + +def decode_token(token: str) -> dict[str, Any]: + """Decode a JWT token. + + When authentik_issuer_url is configured, validates the token + against the OIDC discovery document JWKS. + Otherwise, decodes without verification (local development only). + """ + if settings.authentik_issuer_url: + import httpx + + issuer = settings.authentik_issuer_url.rstrip("/") + discovery_url = f"{issuer}/.well-known/openid-configuration" + with httpx.Client() as client: + resp = client.get(discovery_url) + resp.raise_for_status() + discovery = resp.json() + jwks_uri = discovery["jwks_uri"] + + jwks_resp = client.get(jwks_uri) + jwks_resp.raise_for_status() + jwks = jwks_resp.json() + + signing_key = jwt.algorithms.RSAAlgorithm.from_jwk( + _find_matching_key(jwks, token) + ) + + return jwt.decode( + token, + signing_key, # type: ignore[arg-type] + algorithms=["RS256"], + audience=settings.authentik_client_id, + issuer=settings.authentik_issuer_url, + ) + + return jwt.decode(token, options={"verify_signature": False}) + + +def _find_matching_key(jwks: dict[str, Any], token: str) -> dict[str, Any]: + """Find the key in JWKS that matches the token's kid header.""" + unverified_header = jwt.get_unverified_header(token) + kid = unverified_header.get("kid") + for key in jwks.get("keys", []): + key_dict: dict[str, Any] = key + if key_dict.get("kid") == kid: + return key_dict + raise RuntimeError(f"No matching JWKS key found for kid={kid}") diff --git a/apps/api/app/config.py b/apps/api/app/config.py index 02afc3c..153ad7a 100644 --- a/apps/api/app/config.py +++ b/apps/api/app/config.py @@ -12,17 +12,22 @@ class Settings(BaseSettings): debug: bool = False api_v1_prefix: str = "/api/v1" - # Authentik OIDC placeholders (to be wired in FN-004) + # Authentik OIDC authentik_issuer_url: str = "" authentik_client_id: str = "" authentik_client_secret: str = "" - # Database (to be wired in FN-004) + # Database database_url: str = "postgresql://postgres:postgres@localhost:5432/headquarter" # Deployment root_domain: str = "localhost" tool_subdomain_pattern: str = "{tool}-{project}-{user}.tools.{root_domain}" + # Auth & encryption + secret_encryption_key: str = "change-me-in-production" + access_token_expire_minutes: int = 60 + auth_dev_bypass: bool = False + settings = Settings() diff --git a/apps/api/app/db.py b/apps/api/app/db.py new file mode 100644 index 0000000..4037d3b --- /dev/null +++ b/apps/api/app/db.py @@ -0,0 +1,25 @@ +from collections.abc import AsyncGenerator + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.config import settings + +# Rewrite sync postgres URL to asyncpg +DATABASE_URL = settings.database_url +if DATABASE_URL.startswith("postgresql://"): + DATABASE_URL = DATABASE_URL.replace("postgresql://", "postgresql+asyncpg://", 1) + +engine = create_async_engine(DATABASE_URL, echo=settings.debug) +AsyncSessionLocal = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +async def get_db_session() -> AsyncGenerator[AsyncSession, None]: + async with AsyncSessionLocal() as session: + try: + yield session + finally: + await session.close() diff --git a/apps/api/app/encryption.py b/apps/api/app/encryption.py new file mode 100644 index 0000000..d36c0e7 --- /dev/null +++ b/apps/api/app/encryption.py @@ -0,0 +1,30 @@ +import base64 +import hashlib + +from cryptography.fernet import Fernet, InvalidToken + +from app.config import settings + + +def _derive_fernet_key(key: str) -> bytes: + """Derive a URL-safe base64-encoded 32-byte Fernet key from any string.""" + digest = hashlib.sha256(key.encode("utf-8")).digest() + return base64.urlsafe_b64encode(digest) + + +_fernet = Fernet(_derive_fernet_key(settings.secret_encryption_key)) + + +def encrypt_value(plain_text: str) -> str: + """Encrypt a plaintext string and return the ciphertext as a string.""" + token = _fernet.encrypt(plain_text.encode("utf-8")) + return token.decode("utf-8") + + +def decrypt_value(cipher_text: str) -> str: + """Decrypt a ciphertext string and return the plaintext.""" + try: + plain = _fernet.decrypt(cipher_text.encode("utf-8")) + except InvalidToken as exc: + raise RuntimeError("Invalid encryption token — secret cannot be decrypted") from exc + return plain.decode("utf-8") diff --git a/apps/api/app/main.py b/apps/api/app/main.py index ae550c4..5efa600 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -1,22 +1,60 @@ +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from sqlalchemy import text from app.config import settings +from app.db import AsyncSessionLocal, engine +from app.routers import routers + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + async with AsyncSessionLocal() as session: + try: + await session.execute(text("SELECT 1")) + except Exception: + import logging + logging.getLogger(__name__).warning("Database connectivity check failed on startup") + yield + await engine.dispose() + app = FastAPI( title=settings.app_name, debug=settings.debug, + lifespan=lifespan, ) +allow_origins = ["*"] if settings.debug else [] app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:5173"], + allow_origins=allow_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) +for router in routers: + app.include_router(router, prefix=settings.api_v1_prefix) + @app.get("/health") -def health() -> dict[str, str]: - return {"status": "ok", "service": settings.app_name} +async def health() -> JSONResponse: + db_status = "connected" + try: + async with AsyncSessionLocal() as session: + await session.execute(text("SELECT 1")) + except Exception: + db_status = "unreachable" + + content = { + "status": "ok" if db_status == "connected" else "degraded", + "service": settings.app_name, + "database": db_status, + } + status_code = 200 if db_status == "connected" else 503 + return JSONResponse(status_code=status_code, content=content) diff --git a/apps/api/app/models/__init__.py b/apps/api/app/models/__init__.py new file mode 100644 index 0000000..18d1964 --- /dev/null +++ b/apps/api/app/models/__init__.py @@ -0,0 +1,23 @@ +from app.models.access_route import AccessRoute +from app.models.base import Base +from app.models.config import Config +from app.models.project import Project +from app.models.repository import Repository +from app.models.secret import Secret +from app.models.tool_definition import ToolDefinition +from app.models.tool_instance import ToolInstance +from app.models.user import User +from app.models.workspace import Workspace + +__all__ = [ + "Base", + "AccessRoute", + "Config", + "Project", + "Repository", + "Secret", + "ToolDefinition", + "ToolInstance", + "User", + "Workspace", +] diff --git a/apps/api/app/models/access_route.py b/apps/api/app/models/access_route.py new file mode 100644 index 0000000..593fd29 --- /dev/null +++ b/apps/api/app/models/access_route.py @@ -0,0 +1,35 @@ +import uuid +from typing import TYPE_CHECKING, Any + +from sqlalchemy import JSON, Boolean, ForeignKey, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base, TimestampMixin, UUIDMixin + +if TYPE_CHECKING: + from app.models.tool_instance import ToolInstance + + +class AccessRoute(Base, UUIDMixin, TimestampMixin): + __tablename__ = "access_route" + + tool_instance_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("tool_instance.id"), index=True + ) + domain: Mapped[str] = mapped_column(Text) + path_prefix: Mapped[str] = mapped_column( + String(255), default="/" + ) + provider_type: Mapped[str] = mapped_column( + String(50), default="traefik" + ) + provider_config: Mapped[dict[str, Any] | None] = mapped_column( + JSON, nullable=True + ) + is_active: Mapped[bool] = mapped_column( + Boolean, default=True + ) + + tool_instance: Mapped["ToolInstance"] = relationship( + back_populates="access_routes" + ) diff --git a/apps/api/app/models/base.py b/apps/api/app/models/base.py new file mode 100644 index 0000000..27f9451 --- /dev/null +++ b/apps/api/app/models/base.py @@ -0,0 +1,26 @@ +import uuid +from datetime import datetime + +from sqlalchemy import func +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + + +class UUIDMixin: + id: Mapped[uuid.UUID] = mapped_column( + primary_key=True, + default=uuid.uuid4, + ) + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column( + server_default=func.now(), + ) + updated_at: Mapped[datetime] = mapped_column( + server_default=func.now(), + onupdate=func.now(), + ) diff --git a/apps/api/app/models/config.py b/apps/api/app/models/config.py new file mode 100644 index 0000000..5486b5e --- /dev/null +++ b/apps/api/app/models/config.py @@ -0,0 +1,22 @@ +import uuid +from typing import Any + +from sqlalchemy import JSON, ForeignKey, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base, TimestampMixin, UUIDMixin + + +class Config(Base, UUIDMixin, TimestampMixin): + __tablename__ = "config" + __table_args__ = ( + UniqueConstraint("scope_type", "scope_id", "tool_definition_id", "key"), + ) + + scope_type: Mapped[str] = mapped_column(String(50)) + scope_id: Mapped[uuid.UUID] = mapped_column(index=True) + tool_definition_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("tool_definition.id"), nullable=True, index=True + ) + key: Mapped[str] = mapped_column(String(255)) + value: Mapped[dict[str, Any]] = mapped_column(JSON) diff --git a/apps/api/app/models/project.py b/apps/api/app/models/project.py new file mode 100644 index 0000000..cc2ffba --- /dev/null +++ b/apps/api/app/models/project.py @@ -0,0 +1,36 @@ +import uuid +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base, TimestampMixin, UUIDMixin + +if TYPE_CHECKING: + from app.models.repository import Repository + from app.models.tool_instance import ToolInstance + from app.models.user import User + from app.models.workspace import Workspace + + +class Project(Base, UUIDMixin, TimestampMixin): + __tablename__ = "project" + __table_args__ = (UniqueConstraint("owner_id", "slug"),) + + owner_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("user.id"), index=True + ) + name: Mapped[str] = mapped_column(String(255)) + slug: Mapped[str] = mapped_column(String(255)) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + + owner: Mapped["User"] = relationship(back_populates="projects") + repositories: Mapped[list["Repository"]] = relationship( + back_populates="project" + ) + workspaces: Mapped[list["Workspace"]] = relationship( + back_populates="project" + ) + tool_instances: Mapped[list["ToolInstance"]] = relationship( + back_populates="project" + ) diff --git a/apps/api/app/models/repository.py b/apps/api/app/models/repository.py new file mode 100644 index 0000000..7aa6775 --- /dev/null +++ b/apps/api/app/models/repository.py @@ -0,0 +1,30 @@ +import uuid +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base, TimestampMixin, UUIDMixin + +if TYPE_CHECKING: + from app.models.project import Project + + +class Repository(Base, UUIDMixin, TimestampMixin): + __tablename__ = "repository" + + project_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("project.id"), index=True + ) + name: Mapped[str] = mapped_column(String(255)) + git_url: Mapped[str] = mapped_column(Text) + provider_type: Mapped[str] = mapped_column( + String(50), default="generic" + ) + default_branch: Mapped[str] = mapped_column( + String(100), default="main" + ) + + project: Mapped["Project"] = relationship( + back_populates="repositories" + ) diff --git a/apps/api/app/models/secret.py b/apps/api/app/models/secret.py new file mode 100644 index 0000000..d0bcd02 --- /dev/null +++ b/apps/api/app/models/secret.py @@ -0,0 +1,18 @@ +import uuid + +from sqlalchemy import String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base, TimestampMixin, UUIDMixin + + +class Secret(Base, UUIDMixin, TimestampMixin): + __tablename__ = "secret" + __table_args__ = ( + UniqueConstraint("scope_type", "scope_id", "key"), + ) + + scope_type: Mapped[str] = mapped_column(String(50)) + scope_id: Mapped[uuid.UUID] = mapped_column(index=True) + key: Mapped[str] = mapped_column(String(255)) + encrypted_value: Mapped[str] = mapped_column(Text) diff --git a/apps/api/app/models/tool_definition.py b/apps/api/app/models/tool_definition.py new file mode 100644 index 0000000..5fcd4a4 --- /dev/null +++ b/apps/api/app/models/tool_definition.py @@ -0,0 +1,32 @@ +from typing import TYPE_CHECKING, Any + +from sqlalchemy import JSON, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base, TimestampMixin, UUIDMixin + +if TYPE_CHECKING: + from app.models.tool_instance import ToolInstance + + +class ToolDefinition(Base, UUIDMixin, TimestampMixin): + __tablename__ = "tool_definition" + + key: Mapped[str] = mapped_column( + String(100), unique=True, index=True + ) + name: Mapped[str] = mapped_column(String(255)) + version: Mapped[str] = mapped_column( + String(50), default="1.0.0" + ) + description: Mapped[str | None] = mapped_column( + Text, nullable=True + ) + image: Mapped[str] = mapped_column(Text) + manifest_data: Mapped[dict[str, Any] | None] = mapped_column( + JSON, nullable=True + ) + + instances: Mapped[list["ToolInstance"]] = relationship( + back_populates="tool_definition" + ) diff --git a/apps/api/app/models/tool_instance.py b/apps/api/app/models/tool_instance.py new file mode 100644 index 0000000..219024a --- /dev/null +++ b/apps/api/app/models/tool_instance.py @@ -0,0 +1,46 @@ +import uuid +from typing import TYPE_CHECKING, Any + +from sqlalchemy import JSON, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base, TimestampMixin, UUIDMixin + +if TYPE_CHECKING: + from app.models.access_route import AccessRoute + from app.models.project import Project + from app.models.tool_definition import ToolDefinition + + +class ToolInstance(Base, UUIDMixin, TimestampMixin): + __tablename__ = "tool_instance" + + project_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("project.id"), index=True + ) + tool_definition_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("tool_definition.id"), index=True + ) + name: Mapped[str] = mapped_column(String(255)) + status: Mapped[str] = mapped_column( + String(50), default="pending" + ) + container_id: Mapped[str | None] = mapped_column( + String(255), nullable=True + ) + subdomain: Mapped[str | None] = mapped_column( + String(255), nullable=True, unique=True + ) + config_override: Mapped[dict[str, Any] | None] = mapped_column( + JSON, nullable=True + ) + + project: Mapped["Project"] = relationship( + back_populates="tool_instances" + ) + tool_definition: Mapped["ToolDefinition"] = relationship( + back_populates="instances" + ) + access_routes: Mapped[list["AccessRoute"]] = relationship( + back_populates="tool_instance" + ) diff --git a/apps/api/app/models/user.py b/apps/api/app/models/user.py new file mode 100644 index 0000000..408e670 --- /dev/null +++ b/apps/api/app/models/user.py @@ -0,0 +1,30 @@ +from typing import TYPE_CHECKING + +from sqlalchemy import Boolean, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base, TimestampMixin, UUIDMixin + +if TYPE_CHECKING: + from app.models.project import Project + + +class User(Base, UUIDMixin, TimestampMixin): + __tablename__ = "user" + + authentik_sub: Mapped[str] = mapped_column( + String(255), unique=True, index=True + ) + email: Mapped[str] = mapped_column( + String(255), unique=True, index=True + ) + display_name: Mapped[str | None] = mapped_column( + String(255), nullable=True + ) + is_active: Mapped[bool] = mapped_column( + Boolean, default=True + ) + + projects: Mapped[list["Project"]] = relationship( + back_populates="owner" + ) diff --git a/apps/api/app/models/workspace.py b/apps/api/app/models/workspace.py new file mode 100644 index 0000000..6773125 --- /dev/null +++ b/apps/api/app/models/workspace.py @@ -0,0 +1,24 @@ +import uuid +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base, TimestampMixin, UUIDMixin + +if TYPE_CHECKING: + from app.models.project import Project + + +class Workspace(Base, UUIDMixin, TimestampMixin): + __tablename__ = "workspace" + + project_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("project.id"), index=True + ) + name: Mapped[str] = mapped_column(String(255)) + mount_path: Mapped[str | None] = mapped_column(Text, nullable=True) + + project: Mapped["Project"] = relationship( + back_populates="workspaces" + ) diff --git a/apps/api/app/routers/__init__.py b/apps/api/app/routers/__init__.py new file mode 100644 index 0000000..a940340 --- /dev/null +++ b/apps/api/app/routers/__init__.py @@ -0,0 +1,25 @@ +from fastapi import APIRouter + +from app.routers.access_routes import router as access_routes_router +from app.routers.configs import router as configs_router +from app.routers.projects import router as projects_router +from app.routers.repositories import router as repositories_router +from app.routers.secrets import router as secrets_router +from app.routers.tool_definitions import router as tool_definitions_router +from app.routers.tool_instances import router as tool_instances_router +from app.routers.users import router as users_router +from app.routers.workspaces import router as workspaces_router + +routers: list[APIRouter] = [ + access_routes_router, + configs_router, + projects_router, + repositories_router, + secrets_router, + tool_definitions_router, + tool_instances_router, + users_router, + workspaces_router, +] + +__all__ = ["routers"] diff --git a/apps/api/app/routers/access_routes.py b/apps/api/app/routers/access_routes.py new file mode 100644 index 0000000..51a16a3 --- /dev/null +++ b/apps/api/app/routers/access_routes.py @@ -0,0 +1,103 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.dependencies import get_current_active_user +from app.db import get_db_session +from app.models.access_route import AccessRoute +from app.models.project import Project +from app.models.tool_instance import ToolInstance +from app.models.user import User +from app.schemas.access_route import AccessRouteCreate, AccessRouteRead, AccessRouteUpdate + +router = APIRouter(tags=["access-routes"]) + + +async def _verify_tool_instance_ownership( + instance_id: UUID, user: User, session: AsyncSession +) -> None: + ti = await session.get(ToolInstance, instance_id) + if not ti: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool instance not found") + project = await session.get(Project, ti.project_id) + if not project or project.owner_id != user.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") + + +@router.post("/tool-instances/{instance_id}/access-routes", response_model=AccessRouteRead, status_code=status.HTTP_201_CREATED) # noqa: E501 +async def create_access_route( + instance_id: UUID, + ar_in: AccessRouteCreate, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> AccessRoute: + await _verify_tool_instance_ownership(instance_id, current_user, session) + ar = AccessRoute(**ar_in.model_dump(), tool_instance_id=instance_id) + session.add(ar) + await session.commit() + await session.refresh(ar) + return ar + + +@router.get("/tool-instances/{instance_id}/access-routes", response_model=list[AccessRouteRead]) +async def list_access_routes( + instance_id: UUID, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> list[AccessRoute]: + await _verify_tool_instance_ownership(instance_id, current_user, session) + result = await session.execute( + select(AccessRoute).where(AccessRoute.tool_instance_id == instance_id) + ) + return list(result.scalars().all()) + + +@router.get("/tool-instances/{instance_id}/access-routes/{route_id}", response_model=AccessRouteRead) # noqa: E501 +async def get_access_route( + instance_id: UUID, + route_id: UUID, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> AccessRoute: + await _verify_tool_instance_ownership(instance_id, current_user, session) + ar = await session.get(AccessRoute, route_id) + if not ar or ar.tool_instance_id != instance_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Access route not found") + return ar + + +@router.put("/tool-instances/{instance_id}/access-routes/{route_id}", response_model=AccessRouteRead) # noqa: E501 +async def update_access_route( + instance_id: UUID, + route_id: UUID, + ar_in: AccessRouteUpdate, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> AccessRoute: + await _verify_tool_instance_ownership(instance_id, current_user, session) + ar = await session.get(AccessRoute, route_id) + if not ar or ar.tool_instance_id != instance_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Access route not found") + update_data = ar_in.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(ar, field, value) + await session.commit() + await session.refresh(ar) + return ar + + +@router.delete("/tool-instances/{instance_id}/access-routes/{route_id}", status_code=status.HTTP_204_NO_CONTENT) # noqa: E501 +async def delete_access_route( + instance_id: UUID, + route_id: UUID, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> None: + await _verify_tool_instance_ownership(instance_id, current_user, session) + ar = await session.get(AccessRoute, route_id) + if not ar or ar.tool_instance_id != instance_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Access route not found") + await session.delete(ar) + await session.commit() diff --git a/apps/api/app/routers/configs.py b/apps/api/app/routers/configs.py new file mode 100644 index 0000000..996dfd4 --- /dev/null +++ b/apps/api/app/routers/configs.py @@ -0,0 +1,122 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.dependencies import get_current_active_user +from app.db import get_db_session +from app.models.config import Config +from app.models.project import Project +from app.models.tool_instance import ToolInstance +from app.models.user import User +from app.schemas.config import ConfigCreate, ConfigRead, ConfigUpdate + +router = APIRouter(tags=["configs"]) + + +async def _verify_config_ownership( + config_obj: Config, user: User, session: AsyncSession +) -> None: + if config_obj.scope_type == "user": + if config_obj.scope_id != user.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") + elif config_obj.scope_type == "project": + project = await session.get(Project, config_obj.scope_id) + if not project or project.owner_id != user.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") + elif config_obj.scope_type == "tool_instance": + ti = await session.get(ToolInstance, config_obj.scope_id) + if not ti: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") + project = await session.get(Project, ti.project_id) + if not project or project.owner_id != user.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") + elif config_obj.scope_type == "global": + pass + else: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid scope_type") + + +@router.post("/configs", response_model=ConfigRead, status_code=status.HTTP_201_CREATED) +async def create_config( + config_in: ConfigCreate, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> Config: + cfg = Config(**config_in.model_dump()) + await _verify_config_ownership(cfg, current_user, session) + session.add(cfg) + await session.commit() + await session.refresh(cfg) + return cfg + + +@router.get("/configs", response_model=list[ConfigRead]) +async def list_configs( + scope_type: str | None = None, + scope_id: UUID | None = None, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> list[Config]: + stmt = select(Config) + if scope_type: + stmt = stmt.where(Config.scope_type == scope_type) + if scope_id: + stmt = stmt.where(Config.scope_id == scope_id) + result = await session.execute(stmt) + configs = list(result.scalars().all()) + allowed = [] + for cfg in configs: + try: + await _verify_config_ownership(cfg, current_user, session) + allowed.append(cfg) + except HTTPException: + pass + return allowed + + +@router.get("/configs/{config_id}", response_model=ConfigRead) +async def get_config( + config_id: UUID, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> Config: + cfg = await session.get(Config, config_id) + if not cfg: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Config not found") + await _verify_config_ownership(cfg, current_user, session) + return cfg + + +@router.put("/configs/{config_id}", response_model=ConfigRead) +async def update_config( + config_id: UUID, + config_in: ConfigUpdate, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> Config: + cfg = await session.get(Config, config_id) + if not cfg: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Config not found") + await _verify_config_ownership(cfg, current_user, session) + update_data = config_in.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(cfg, field, value) + await session.commit() + await session.refresh(cfg) + return cfg + + +@router.delete("/configs/{config_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_config( + config_id: UUID, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> None: + cfg = await session.get(Config, config_id) + if not cfg: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Config not found") + await _verify_config_ownership(cfg, current_user, session) + await session.delete(cfg) + await session.commit() diff --git a/apps/api/app/routers/projects.py b/apps/api/app/routers/projects.py new file mode 100644 index 0000000..b29637e --- /dev/null +++ b/apps/api/app/routers/projects.py @@ -0,0 +1,80 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.dependencies import get_current_active_user +from app.db import get_db_session +from app.models.project import Project +from app.models.user import User +from app.schemas.project import ProjectCreate, ProjectRead, ProjectUpdate + +router = APIRouter(tags=["projects"]) + + +@router.post("/projects", response_model=ProjectRead, status_code=status.HTTP_201_CREATED) +async def create_project( + project_in: ProjectCreate, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> Project: + project = Project(**project_in.model_dump(), owner_id=current_user.id) + session.add(project) + await session.commit() + await session.refresh(project) + return project + + +@router.get("/projects", response_model=list[ProjectRead]) +async def list_projects( + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> list[Project]: + result = await session.execute( + select(Project).where(Project.owner_id == current_user.id) + ) + return list(result.scalars().all()) + + +@router.get("/projects/{project_id}", response_model=ProjectRead) +async def get_project( + project_id: UUID, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> Project: + project = await session.get(Project, project_id) + if not project or project.owner_id != current_user.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + return project + + +@router.put("/projects/{project_id}", response_model=ProjectRead) +async def update_project( + project_id: UUID, + project_in: ProjectUpdate, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> Project: + project = await session.get(Project, project_id) + if not project or project.owner_id != current_user.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + update_data = project_in.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(project, field, value) + await session.commit() + await session.refresh(project) + return project + + +@router.delete("/projects/{project_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_project( + project_id: UUID, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> None: + project = await session.get(Project, project_id) + if not project or project.owner_id != current_user.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + await session.delete(project) + await session.commit() diff --git a/apps/api/app/routers/repositories.py b/apps/api/app/routers/repositories.py new file mode 100644 index 0000000..9f1baf3 --- /dev/null +++ b/apps/api/app/routers/repositories.py @@ -0,0 +1,100 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.dependencies import get_current_active_user +from app.db import get_db_session +from app.models.project import Project +from app.models.repository import Repository +from app.models.user import User +from app.schemas.repository import RepositoryCreate, RepositoryRead, RepositoryUpdate + +router = APIRouter(tags=["repositories"]) + + +async def _get_project_for_user( + project_id: UUID, user: User, session: AsyncSession +) -> Project: + project = await session.get(Project, project_id) + if not project or project.owner_id != user.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + return project + + +@router.post("/projects/{project_id}/repositories", response_model=RepositoryRead, status_code=status.HTTP_201_CREATED) # noqa: E501 +async def create_repository( + project_id: UUID, + repo_in: RepositoryCreate, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> Repository: + await _get_project_for_user(project_id, current_user, session) + repo = Repository(**repo_in.model_dump(), project_id=project_id) + session.add(repo) + await session.commit() + await session.refresh(repo) + return repo + + +@router.get("/projects/{project_id}/repositories", response_model=list[RepositoryRead]) +async def list_repositories( + project_id: UUID, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> list[Repository]: + await _get_project_for_user(project_id, current_user, session) + result = await session.execute( + select(Repository).where(Repository.project_id == project_id) + ) + return list(result.scalars().all()) + + +@router.get("/projects/{project_id}/repositories/{repo_id}", response_model=RepositoryRead) +async def get_repository( + project_id: UUID, + repo_id: UUID, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> Repository: + await _get_project_for_user(project_id, current_user, session) + repo = await session.get(Repository, repo_id) + if not repo or repo.project_id != project_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repository not found") + return repo + + +@router.put("/projects/{project_id}/repositories/{repo_id}", response_model=RepositoryRead) +async def update_repository( + project_id: UUID, + repo_id: UUID, + repo_in: RepositoryUpdate, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> Repository: + await _get_project_for_user(project_id, current_user, session) + repo = await session.get(Repository, repo_id) + if not repo or repo.project_id != project_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repository not found") + update_data = repo_in.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(repo, field, value) + await session.commit() + await session.refresh(repo) + return repo + + +@router.delete("/projects/{project_id}/repositories/{repo_id}", status_code=status.HTTP_204_NO_CONTENT) # noqa: E501 +async def delete_repository( + project_id: UUID, + repo_id: UUID, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> None: + await _get_project_for_user(project_id, current_user, session) + repo = await session.get(Repository, repo_id) + if not repo or repo.project_id != project_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repository not found") + await session.delete(repo) + await session.commit() diff --git a/apps/api/app/routers/secrets.py b/apps/api/app/routers/secrets.py new file mode 100644 index 0000000..1e44672 --- /dev/null +++ b/apps/api/app/routers/secrets.py @@ -0,0 +1,153 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.dependencies import get_current_active_user +from app.db import get_db_session +from app.encryption import decrypt_value, encrypt_value +from app.models.project import Project +from app.models.secret import Secret +from app.models.tool_instance import ToolInstance +from app.models.user import User +from app.schemas.secret import SecretCreate, SecretRead, SecretUpdate + +router = APIRouter(tags=["secrets"]) + + +async def _verify_secret_ownership( + secret_obj: Secret, user: User, session: AsyncSession +) -> None: + if secret_obj.scope_type == "user": + if secret_obj.scope_id != user.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") + elif secret_obj.scope_type == "project": + project = await session.get(Project, secret_obj.scope_id) + if not project or project.owner_id != user.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") + elif secret_obj.scope_type == "tool_instance": + ti = await session.get(ToolInstance, secret_obj.scope_id) + if not ti: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") + project = await session.get(Project, ti.project_id) + if not project or project.owner_id != user.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") + elif secret_obj.scope_type == "global": + pass + else: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid scope_type") + + +@router.post("/secrets", response_model=SecretRead, status_code=status.HTTP_201_CREATED) +async def create_secret( + secret_in: SecretCreate, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> SecretRead: + secret = Secret( + scope_type=secret_in.scope_type, + scope_id=secret_in.scope_id, + key=secret_in.key, + encrypted_value=encrypt_value(secret_in.value), + ) + await _verify_secret_ownership(secret, current_user, session) + session.add(secret) + await session.commit() + await session.refresh(secret) + return SecretRead( + id=secret.id, + scope_type=secret.scope_type, + scope_id=secret.scope_id, + key=secret.key, + value=decrypt_value(secret.encrypted_value), + ) + + +@router.get("/secrets", response_model=list[SecretRead]) +async def list_secrets( + scope_type: str | None = None, + scope_id: UUID | None = None, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> list[SecretRead]: + stmt = select(Secret) + if scope_type: + stmt = stmt.where(Secret.scope_type == scope_type) + if scope_id: + stmt = stmt.where(Secret.scope_id == scope_id) + result = await session.execute(stmt) + secrets = list(result.scalars().all()) + allowed = [] + for s in secrets: + try: + await _verify_secret_ownership(s, current_user, session) + allowed.append(SecretRead( + id=s.id, + scope_type=s.scope_type, + scope_id=s.scope_id, + key=s.key, + value=decrypt_value(s.encrypted_value), + )) + except HTTPException: + pass + return allowed + + +@router.get("/secrets/{secret_id}", response_model=SecretRead) +async def get_secret( + secret_id: UUID, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> SecretRead: + s = await session.get(Secret, secret_id) + if not s: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found") + await _verify_secret_ownership(s, current_user, session) + return SecretRead( + id=s.id, + scope_type=s.scope_type, + scope_id=s.scope_id, + key=s.key, + value=decrypt_value(s.encrypted_value), + ) + + +@router.put("/secrets/{secret_id}", response_model=SecretRead) +async def update_secret( + secret_id: UUID, + secret_in: SecretUpdate, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> SecretRead: + s = await session.get(Secret, secret_id) + if not s: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found") + await _verify_secret_ownership(s, current_user, session) + if secret_in.key is not None: + s.key = secret_in.key + if secret_in.value is not None: + s.encrypted_value = encrypt_value(secret_in.value) + await session.commit() + await session.refresh(s) + return SecretRead( + id=s.id, + scope_type=s.scope_type, + scope_id=s.scope_id, + key=s.key, + value=decrypt_value(s.encrypted_value), + ) + + +@router.delete("/secrets/{secret_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_secret( + secret_id: UUID, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> None: + s = await session.get(Secret, secret_id) + if not s: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found") + await _verify_secret_ownership(s, current_user, session) + await session.delete(s) + await session.commit() diff --git a/apps/api/app/routers/tool_definitions.py b/apps/api/app/routers/tool_definitions.py new file mode 100644 index 0000000..68267ba --- /dev/null +++ b/apps/api/app/routers/tool_definitions.py @@ -0,0 +1,88 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.dependencies import get_current_active_user +from app.db import get_db_session +from app.models.tool_definition import ToolDefinition +from app.models.user import User +from app.schemas.tool_definition import ( + ToolDefinitionCreate, + ToolDefinitionRead, + ToolDefinitionUpdate, +) + +router = APIRouter(tags=["tool-definitions"]) + + +@router.post("/tool-definitions", response_model=ToolDefinitionRead, status_code=status.HTTP_201_CREATED) # noqa: E501 +async def create_tool_definition( + td_in: ToolDefinitionCreate, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> ToolDefinition: + td = ToolDefinition(**td_in.model_dump()) + session.add(td) + await session.commit() + await session.refresh(td) + return td + + +@router.get("/tool-definitions", response_model=list[ToolDefinitionRead]) +async def list_tool_definitions( + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> list[ToolDefinition]: + result = await session.execute(select(ToolDefinition)) + return list(result.scalars().all()) + + +@router.get("/tool-definitions/{tool_def_id}", response_model=ToolDefinitionRead) +async def get_tool_definition( + tool_def_id: UUID, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> ToolDefinition: + td = await session.get(ToolDefinition, tool_def_id) + if not td: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Tool definition not found" + ) + return td + + +@router.put("/tool-definitions/{tool_def_id}", response_model=ToolDefinitionRead) +async def update_tool_definition( + tool_def_id: UUID, + td_in: ToolDefinitionUpdate, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> ToolDefinition: + td = await session.get(ToolDefinition, tool_def_id) + if not td: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Tool definition not found" + ) + update_data = td_in.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(td, field, value) + await session.commit() + await session.refresh(td) + return td + + +@router.delete("/tool-definitions/{tool_def_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_tool_definition( + tool_def_id: UUID, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> None: + td = await session.get(ToolDefinition, tool_def_id) + if not td: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Tool definition not found" + ) + await session.delete(td) + await session.commit() diff --git a/apps/api/app/routers/tool_instances.py b/apps/api/app/routers/tool_instances.py new file mode 100644 index 0000000..cbbd7ec --- /dev/null +++ b/apps/api/app/routers/tool_instances.py @@ -0,0 +1,100 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.dependencies import get_current_active_user +from app.db import get_db_session +from app.models.project import Project +from app.models.tool_instance import ToolInstance +from app.models.user import User +from app.schemas.tool_instance import ToolInstanceCreate, ToolInstanceRead, ToolInstanceUpdate + +router = APIRouter(tags=["tool-instances"]) + + +async def _get_project_for_user( + project_id: UUID, user: User, session: AsyncSession +) -> Project: + project = await session.get(Project, project_id) + if not project or project.owner_id != user.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + return project + + +@router.post("/projects/{project_id}/tool-instances", response_model=ToolInstanceRead, status_code=status.HTTP_201_CREATED) # noqa: E501 +async def create_tool_instance( + project_id: UUID, + ti_in: ToolInstanceCreate, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> ToolInstance: + await _get_project_for_user(project_id, current_user, session) + ti = ToolInstance(**ti_in.model_dump(), project_id=project_id) + session.add(ti) + await session.commit() + await session.refresh(ti) + return ti + + +@router.get("/projects/{project_id}/tool-instances", response_model=list[ToolInstanceRead]) +async def list_tool_instances( + project_id: UUID, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> list[ToolInstance]: + await _get_project_for_user(project_id, current_user, session) + result = await session.execute( + select(ToolInstance).where(ToolInstance.project_id == project_id) + ) + return list(result.scalars().all()) + + +@router.get("/projects/{project_id}/tool-instances/{instance_id}", response_model=ToolInstanceRead) +async def get_tool_instance( + project_id: UUID, + instance_id: UUID, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> ToolInstance: + await _get_project_for_user(project_id, current_user, session) + ti = await session.get(ToolInstance, instance_id) + if not ti or ti.project_id != project_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool instance not found") + return ti + + +@router.put("/projects/{project_id}/tool-instances/{instance_id}", response_model=ToolInstanceRead) +async def update_tool_instance( + project_id: UUID, + instance_id: UUID, + ti_in: ToolInstanceUpdate, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> ToolInstance: + await _get_project_for_user(project_id, current_user, session) + ti = await session.get(ToolInstance, instance_id) + if not ti or ti.project_id != project_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool instance not found") + update_data = ti_in.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(ti, field, value) + await session.commit() + await session.refresh(ti) + return ti + + +@router.delete("/projects/{project_id}/tool-instances/{instance_id}", status_code=status.HTTP_204_NO_CONTENT) # noqa: E501 +async def delete_tool_instance( + project_id: UUID, + instance_id: UUID, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> None: + await _get_project_for_user(project_id, current_user, session) + ti = await session.get(ToolInstance, instance_id) + if not ti or ti.project_id != project_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool instance not found") + await session.delete(ti) + await session.commit() diff --git a/apps/api/app/routers/users.py b/apps/api/app/routers/users.py new file mode 100644 index 0000000..b89325d --- /dev/null +++ b/apps/api/app/routers/users.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter, Depends + +from app.auth.dependencies import get_current_active_user +from app.models.user import User +from app.schemas.user import UserRead + +router = APIRouter(tags=["users"]) + + +@router.get("/users/me", response_model=UserRead) +async def read_current_user(current_user: User = Depends(get_current_active_user)) -> User: + return current_user + + +@router.get("/users", response_model=list[UserRead]) +async def list_users(current_user: User = Depends(get_current_active_user)) -> list[User]: + return [current_user] diff --git a/apps/api/app/routers/workspaces.py b/apps/api/app/routers/workspaces.py new file mode 100644 index 0000000..86e7397 --- /dev/null +++ b/apps/api/app/routers/workspaces.py @@ -0,0 +1,100 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.dependencies import get_current_active_user +from app.db import get_db_session +from app.models.project import Project +from app.models.user import User +from app.models.workspace import Workspace +from app.schemas.workspace import WorkspaceCreate, WorkspaceRead, WorkspaceUpdate + +router = APIRouter(tags=["workspaces"]) + + +async def _get_project_for_user( + project_id: UUID, user: User, session: AsyncSession +) -> Project: + project = await session.get(Project, project_id) + if not project or project.owner_id != user.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + return project + + +@router.post("/projects/{project_id}/workspaces", response_model=WorkspaceRead, status_code=status.HTTP_201_CREATED) # noqa: E501 +async def create_workspace( + project_id: UUID, + ws_in: WorkspaceCreate, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> Workspace: + await _get_project_for_user(project_id, current_user, session) + ws = Workspace(**ws_in.model_dump(), project_id=project_id) + session.add(ws) + await session.commit() + await session.refresh(ws) + return ws + + +@router.get("/projects/{project_id}/workspaces", response_model=list[WorkspaceRead]) +async def list_workspaces( + project_id: UUID, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> list[Workspace]: + await _get_project_for_user(project_id, current_user, session) + result = await session.execute( + select(Workspace).where(Workspace.project_id == project_id) + ) + return list(result.scalars().all()) + + +@router.get("/projects/{project_id}/workspaces/{ws_id}", response_model=WorkspaceRead) +async def get_workspace( + project_id: UUID, + ws_id: UUID, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> Workspace: + await _get_project_for_user(project_id, current_user, session) + ws = await session.get(Workspace, ws_id) + if not ws or ws.project_id != project_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found") + return ws + + +@router.put("/projects/{project_id}/workspaces/{ws_id}", response_model=WorkspaceRead) +async def update_workspace( + project_id: UUID, + ws_id: UUID, + ws_in: WorkspaceUpdate, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> Workspace: + await _get_project_for_user(project_id, current_user, session) + ws = await session.get(Workspace, ws_id) + if not ws or ws.project_id != project_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found") + update_data = ws_in.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(ws, field, value) + await session.commit() + await session.refresh(ws) + return ws + + +@router.delete("/projects/{project_id}/workspaces/{ws_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_workspace( + project_id: UUID, + ws_id: UUID, + current_user: User = Depends(get_current_active_user), + session: AsyncSession = Depends(get_db_session), +) -> None: + await _get_project_for_user(project_id, current_user, session) + ws = await session.get(Workspace, ws_id) + if not ws or ws.project_id != project_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found") + await session.delete(ws) + await session.commit() diff --git a/apps/api/app/schemas/__init__.py b/apps/api/app/schemas/__init__.py new file mode 100644 index 0000000..ec04055 --- /dev/null +++ b/apps/api/app/schemas/__init__.py @@ -0,0 +1,42 @@ +from app.schemas.access_route import AccessRouteCreate, AccessRouteRead, AccessRouteUpdate +from app.schemas.config import ConfigCreate, ConfigRead, ConfigUpdate +from app.schemas.project import ProjectCreate, ProjectRead, ProjectUpdate +from app.schemas.repository import RepositoryCreate, RepositoryRead, RepositoryUpdate +from app.schemas.secret import SecretCreate, SecretRead, SecretUpdate +from app.schemas.tool_definition import ( + ToolDefinitionCreate, + ToolDefinitionRead, + ToolDefinitionUpdate, +) +from app.schemas.tool_instance import ToolInstanceCreate, ToolInstanceRead, ToolInstanceUpdate +from app.schemas.user import UserCreate, UserRead +from app.schemas.workspace import WorkspaceCreate, WorkspaceRead, WorkspaceUpdate + +__all__ = [ + "AccessRouteCreate", + "AccessRouteRead", + "AccessRouteUpdate", + "ConfigCreate", + "ConfigRead", + "ConfigUpdate", + "ProjectCreate", + "ProjectRead", + "ProjectUpdate", + "RepositoryCreate", + "RepositoryRead", + "RepositoryUpdate", + "SecretCreate", + "SecretRead", + "SecretUpdate", + "ToolDefinitionCreate", + "ToolDefinitionRead", + "ToolDefinitionUpdate", + "ToolInstanceCreate", + "ToolInstanceRead", + "ToolInstanceUpdate", + "UserCreate", + "UserRead", + "WorkspaceCreate", + "WorkspaceRead", + "WorkspaceUpdate", +] diff --git a/apps/api/app/schemas/access_route.py b/apps/api/app/schemas/access_route.py new file mode 100644 index 0000000..02c10c6 --- /dev/null +++ b/apps/api/app/schemas/access_route.py @@ -0,0 +1,29 @@ +from typing import Any +from uuid import UUID + +from app.schemas.base import OrmBase + + +class AccessRouteBase(OrmBase): + domain: str + path_prefix: str = "/" + provider_type: str = "traefik" + provider_config: dict[str, Any] | None = None + is_active: bool = True + + +class AccessRouteCreate(AccessRouteBase): + pass + + +class AccessRouteRead(AccessRouteBase): + id: UUID + tool_instance_id: UUID + + +class AccessRouteUpdate(OrmBase): + domain: str | None = None + path_prefix: str | None = None + provider_type: str | None = None + provider_config: dict[str, Any] | None = None + is_active: bool | None = None diff --git a/apps/api/app/schemas/base.py b/apps/api/app/schemas/base.py new file mode 100644 index 0000000..dbc5635 --- /dev/null +++ b/apps/api/app/schemas/base.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel, ConfigDict + + +class OrmBase(BaseModel): + model_config = ConfigDict(from_attributes=True) diff --git a/apps/api/app/schemas/config.py b/apps/api/app/schemas/config.py new file mode 100644 index 0000000..504841e --- /dev/null +++ b/apps/api/app/schemas/config.py @@ -0,0 +1,25 @@ +from typing import Any +from uuid import UUID + +from app.schemas.base import OrmBase + + +class ConfigBase(OrmBase): + scope_type: str + scope_id: UUID + tool_definition_id: UUID | None = None + key: str + value: dict[str, Any] + + +class ConfigCreate(ConfigBase): + pass + + +class ConfigRead(ConfigBase): + id: UUID + + +class ConfigUpdate(OrmBase): + key: str | None = None + value: dict[str, Any] | None = None diff --git a/apps/api/app/schemas/project.py b/apps/api/app/schemas/project.py new file mode 100644 index 0000000..81bb38a --- /dev/null +++ b/apps/api/app/schemas/project.py @@ -0,0 +1,24 @@ +from uuid import UUID + +from app.schemas.base import OrmBase + + +class ProjectBase(OrmBase): + name: str + slug: str + description: str | None = None + + +class ProjectCreate(ProjectBase): + pass + + +class ProjectRead(ProjectBase): + id: UUID + owner_id: UUID + + +class ProjectUpdate(OrmBase): + name: str | None = None + slug: str | None = None + description: str | None = None diff --git a/apps/api/app/schemas/repository.py b/apps/api/app/schemas/repository.py new file mode 100644 index 0000000..972c764 --- /dev/null +++ b/apps/api/app/schemas/repository.py @@ -0,0 +1,26 @@ +from uuid import UUID + +from app.schemas.base import OrmBase + + +class RepositoryBase(OrmBase): + name: str + git_url: str + provider_type: str = "generic" + default_branch: str = "main" + + +class RepositoryCreate(RepositoryBase): + pass + + +class RepositoryRead(RepositoryBase): + id: UUID + project_id: UUID + + +class RepositoryUpdate(OrmBase): + name: str | None = None + git_url: str | None = None + provider_type: str | None = None + default_branch: str | None = None diff --git a/apps/api/app/schemas/secret.py b/apps/api/app/schemas/secret.py new file mode 100644 index 0000000..fcea0e6 --- /dev/null +++ b/apps/api/app/schemas/secret.py @@ -0,0 +1,23 @@ +from uuid import UUID + +from app.schemas.base import OrmBase + + +class SecretBase(OrmBase): + scope_type: str + scope_id: UUID + key: str + + +class SecretCreate(SecretBase): + value: str + + +class SecretRead(SecretBase): + id: UUID + value: str + + +class SecretUpdate(OrmBase): + key: str | None = None + value: str | None = None diff --git a/apps/api/app/schemas/tool_definition.py b/apps/api/app/schemas/tool_definition.py new file mode 100644 index 0000000..fcbbd83 --- /dev/null +++ b/apps/api/app/schemas/tool_definition.py @@ -0,0 +1,29 @@ +from typing import Any +from uuid import UUID + +from app.schemas.base import OrmBase + + +class ToolDefinitionBase(OrmBase): + key: str + name: str + version: str = "1.0.0" + description: str | None = None + image: str + manifest_data: dict[str, Any] | None = None + + +class ToolDefinitionCreate(ToolDefinitionBase): + pass + + +class ToolDefinitionRead(ToolDefinitionBase): + id: UUID + + +class ToolDefinitionUpdate(OrmBase): + name: str | None = None + version: str | None = None + description: str | None = None + image: str | None = None + manifest_data: dict[str, Any] | None = None diff --git a/apps/api/app/schemas/tool_instance.py b/apps/api/app/schemas/tool_instance.py new file mode 100644 index 0000000..347ae4c --- /dev/null +++ b/apps/api/app/schemas/tool_instance.py @@ -0,0 +1,30 @@ +from typing import Any +from uuid import UUID + +from app.schemas.base import OrmBase + + +class ToolInstanceBase(OrmBase): + name: str + status: str = "pending" + container_id: str | None = None + subdomain: str | None = None + config_override: dict[str, Any] | None = None + + +class ToolInstanceCreate(ToolInstanceBase): + tool_definition_id: UUID + + +class ToolInstanceRead(ToolInstanceBase): + id: UUID + project_id: UUID + tool_definition_id: UUID + + +class ToolInstanceUpdate(OrmBase): + name: str | None = None + status: str | None = None + container_id: str | None = None + subdomain: str | None = None + config_override: dict[str, Any] | None = None diff --git a/apps/api/app/schemas/user.py b/apps/api/app/schemas/user.py new file mode 100644 index 0000000..f30af97 --- /dev/null +++ b/apps/api/app/schemas/user.py @@ -0,0 +1,21 @@ +from datetime import datetime +from uuid import UUID + +from app.schemas.base import OrmBase + + +class UserBase(OrmBase): + authentik_sub: str + email: str + display_name: str | None = None + is_active: bool = True + + +class UserCreate(UserBase): + pass + + +class UserRead(UserBase): + id: UUID + created_at: datetime + updated_at: datetime diff --git a/apps/api/app/schemas/workspace.py b/apps/api/app/schemas/workspace.py new file mode 100644 index 0000000..bd44bf4 --- /dev/null +++ b/apps/api/app/schemas/workspace.py @@ -0,0 +1,22 @@ +from uuid import UUID + +from app.schemas.base import OrmBase + + +class WorkspaceBase(OrmBase): + name: str + mount_path: str | None = None + + +class WorkspaceCreate(WorkspaceBase): + pass + + +class WorkspaceRead(WorkspaceBase): + id: UUID + project_id: UUID + + +class WorkspaceUpdate(OrmBase): + name: str | None = None + mount_path: str | None = None diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index a19d610..1dc9631 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -7,6 +7,12 @@ dependencies = [ "fastapi>=0.115.0", "uvicorn[standard]>=0.34.0", "pydantic-settings>=2.8.0", + "sqlalchemy[asyncio]>=2.0.0", + "asyncpg>=0.30.0", + "alembic>=1.15.0", + "pyjwt>=2.8.0", + "cryptography>=44.0.0", + "httpx>=0.28.0", ] [project.optional-dependencies] @@ -15,6 +21,7 @@ dev = [ "httpx>=0.28.0", "ruff>=0.11.0", "mypy>=1.15.0", + "sqlalchemy[mypy]", ] [build-system] @@ -27,6 +34,7 @@ packages = ["app"] [tool.ruff] line-length = 100 target-version = "py311" +exclude = ["alembic/versions"] [tool.ruff.lint] select = ["E", "F", "I", "N", "W", "UP"] @@ -36,3 +44,7 @@ python_version = "3.11" strict = true warn_return_any = true warn_unused_configs = true +exclude = ["alembic/versions"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/apps/api/tests/conftest.py b/apps/api/tests/conftest.py new file mode 100644 index 0000000..9c2d53a --- /dev/null +++ b/apps/api/tests/conftest.py @@ -0,0 +1,84 @@ +from collections.abc import AsyncGenerator, Generator +from typing import Any + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.pool import NullPool + +from app.config import settings +from app.db import get_db_session +from app.main import app +from app.models import Base + +TEST_DATABASE_URL = settings.database_url.replace("/headquarter", "/headquarter_test") +if TEST_DATABASE_URL.startswith("postgresql://"): + TEST_DATABASE_URL = TEST_DATABASE_URL.replace("postgresql://", "postgresql+asyncpg://", 1) + + +@pytest.fixture(scope="session") +def event_loop() -> Generator[Any, None, None]: + import asyncio + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + + +@pytest.fixture(scope="session") +async def db_engine() -> AsyncGenerator[AsyncEngine, None]: + engine = create_async_engine(TEST_DATABASE_URL, echo=False, poolclass=NullPool) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield engine + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await engine.dispose() + + +@pytest.fixture +async def db_session( + db_engine: AsyncEngine, +) -> AsyncGenerator[async_sessionmaker[AsyncSession], None]: + async with db_engine.connect() as connection: + trans = await connection.begin_nested() + testing_session_local = async_sessionmaker( + connection, class_=AsyncSession, expire_on_commit=False + ) + + async def override_get_db() -> AsyncGenerator[AsyncSession, None]: + async with testing_session_local() as session: + yield session + + app.dependency_overrides[get_db_session] = override_get_db + original_db_url = settings.database_url + settings.database_url = TEST_DATABASE_URL + yield testing_session_local + settings.database_url = original_db_url + app.dependency_overrides.pop(get_db_session, None) + await trans.rollback() + + +@pytest.fixture +async def client( + db_session: async_sessionmaker[AsyncSession], +) -> AsyncGenerator[AsyncClient, None]: + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: + yield ac + + +@pytest.fixture +async def auth_client( + client: AsyncClient, +) -> AsyncGenerator[AsyncClient, None]: + original_debug = settings.debug + original_bypass = settings.auth_dev_bypass + settings.debug = True + settings.auth_dev_bypass = True + yield client + settings.debug = original_debug + settings.auth_dev_bypass = original_bypass diff --git a/apps/api/tests/test_auth.py b/apps/api/tests/test_auth.py new file mode 100644 index 0000000..4a6a600 --- /dev/null +++ b/apps/api/tests/test_auth.py @@ -0,0 +1,66 @@ +from typing import Any + +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker + +from app.config import settings +from app.models.user import User + + +@pytest.mark.asyncio +async def test_dev_bypass_creates_user(auth_client: AsyncClient) -> None: + response = await auth_client.get("/api/v1/users/me") + assert response.status_code == 200 + data = response.json() + assert data["authentik_sub"] == "dev-user" + + +@pytest.mark.asyncio +async def test_missing_token_raises_401_when_bypass_disabled(client: AsyncClient) -> None: + original_debug = settings.debug + original_bypass = settings.auth_dev_bypass + settings.debug = False + settings.auth_dev_bypass = False + + response = await client.get("/api/v1/users/me") + + settings.debug = original_debug + settings.auth_dev_bypass = original_bypass + + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_inactive_user_raises_403( + auth_client: AsyncClient, db_session: async_sessionmaker[Any] +) -> None: + async with db_session() as session: + result = await session.execute(select(User).where(User.authentik_sub == "dev-user")) + user = result.scalar_one_or_none() + if user is None: + user = User( + authentik_sub="dev-user", + email="dev@localhost", + display_name="Dev User", + is_active=True, + ) + session.add(user) + await session.commit() + + async with db_session() as session: + result = await session.execute(select(User).where(User.authentik_sub == "dev-user")) + user = result.scalar_one() + user.is_active = False + await session.commit() + + response = await auth_client.get("/api/v1/users/me") + + async with db_session() as session: + result = await session.execute(select(User).where(User.authentik_sub == "dev-user")) + user = result.scalar_one() + user.is_active = True + await session.commit() + + assert response.status_code == 403 diff --git a/apps/api/tests/test_health.py b/apps/api/tests/test_health.py index 966e62c..15f84e7 100644 --- a/apps/api/tests/test_health.py +++ b/apps/api/tests/test_health.py @@ -1,14 +1,14 @@ -from fastapi.testclient import TestClient +import pytest +from httpx import AsyncClient from app.config import settings -from app.main import app - -client = TestClient(app) -def test_health_returns_ok() -> None: - response = client.get("/health") +@pytest.mark.asyncio +async def test_health_returns_ok(client: AsyncClient) -> None: + response = await client.get("/health") assert response.status_code == 200 data = response.json() assert data["status"] == "ok" assert data["service"] == settings.app_name + assert data["database"] == "connected" diff --git a/apps/api/tests/test_routers/__init__.py b/apps/api/tests/test_routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/tests/test_routers/test_projects.py b/apps/api/tests/test_routers/test_projects.py new file mode 100644 index 0000000..0fe5d38 --- /dev/null +++ b/apps/api/tests/test_routers/test_projects.py @@ -0,0 +1,35 @@ +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +async def test_project_crud(auth_client: AsyncClient) -> None: + # Create + resp = await auth_client.post("/api/v1/projects", json={"name": "Test", "slug": "test"}) + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "Test" + project_id = data["id"] + + # List + resp = await auth_client.get("/api/v1/projects") + assert resp.status_code == 200 + assert len(resp.json()) == 1 + + # Get + resp = await auth_client.get(f"/api/v1/projects/{project_id}") + assert resp.status_code == 200 + assert resp.json()["slug"] == "test" + + # Update + resp = await auth_client.put(f"/api/v1/projects/{project_id}", json={"name": "Updated"}) + assert resp.status_code == 200 + assert resp.json()["name"] == "Updated" + + # Delete + resp = await auth_client.delete(f"/api/v1/projects/{project_id}") + assert resp.status_code == 204 + + # Verify deletion + resp = await auth_client.get(f"/api/v1/projects/{project_id}") + assert resp.status_code == 404 diff --git a/apps/api/tests/test_routers/test_repositories.py b/apps/api/tests/test_routers/test_repositories.py new file mode 100644 index 0000000..2530e7d --- /dev/null +++ b/apps/api/tests/test_routers/test_repositories.py @@ -0,0 +1,40 @@ +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +async def test_repository_crud(auth_client: AsyncClient) -> None: + # Create project first + resp = await auth_client.post( + "/api/v1/projects", json={"name": "RepoTest", "slug": "repo-test"} + ) + project_id = resp.json()["id"] + + # Create repo + resp = await auth_client.post( + f"/api/v1/projects/{project_id}/repositories", + json={"name": "repo1", "git_url": "https://git.example.com/repo1.git"}, + ) + assert resp.status_code == 201 + repo_id = resp.json()["id"] + + # List + resp = await auth_client.get(f"/api/v1/projects/{project_id}/repositories") + assert resp.status_code == 200 + assert len(resp.json()) == 1 + + # Get + resp = await auth_client.get(f"/api/v1/projects/{project_id}/repositories/{repo_id}") + assert resp.status_code == 200 + + # Update + resp = await auth_client.put( + f"/api/v1/projects/{project_id}/repositories/{repo_id}", + json={"name": "repo1-updated"}, + ) + assert resp.status_code == 200 + assert resp.json()["name"] == "repo1-updated" + + # Delete + resp = await auth_client.delete(f"/api/v1/projects/{project_id}/repositories/{repo_id}") + assert resp.status_code == 204 diff --git a/apps/api/tests/test_routers/test_secrets.py b/apps/api/tests/test_routers/test_secrets.py new file mode 100644 index 0000000..7f2341d --- /dev/null +++ b/apps/api/tests/test_routers/test_secrets.py @@ -0,0 +1,72 @@ +from typing import Any + +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker + +from app.models.secret import Secret + + +@pytest.mark.asyncio +async def test_secret_encrypt_decrypt( + auth_client: AsyncClient, db_session: async_sessionmaker[Any] +) -> None: + resp = await auth_client.post( + "/api/v1/projects", + json={"name": "SecretTest", "slug": "secret-test"}, + ) + project_id = resp.json()["id"] + + resp = await auth_client.post( + "/api/v1/secrets", + json={ + "scope_type": "project", + "scope_id": str(project_id), + "key": "api_key", + "value": "super-secret", + }, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["value"] == "super-secret" + secret_id = data["id"] + + resp = await auth_client.get(f"/api/v1/secrets/{secret_id}") + assert resp.status_code == 200 + assert resp.json()["value"] == "super-secret" + + async with db_session() as session: + result = await session.execute(select(Secret).where(Secret.id == secret_id)) + secret = result.scalar_one() + assert secret.encrypted_value != "super-secret" + + resp = await auth_client.put( + f"/api/v1/secrets/{secret_id}", + json={"value": "new-secret"}, + ) + assert resp.status_code == 200 + assert resp.json()["value"] == "new-secret" + + resp = await auth_client.delete(f"/api/v1/secrets/{secret_id}") + assert resp.status_code == 204 + + +@pytest.mark.asyncio +async def test_secret_ownership_enforced(auth_client: AsyncClient) -> None: + resp = await auth_client.post("/api/v1/projects", json={"name": "P1", "slug": "p1"}) + p1 = resp.json()["id"] + + resp = await auth_client.post("/api/v1/projects", json={"name": "P2", "slug": "p2"}) + p2 = resp.json()["id"] + + resp = await auth_client.post( + "/api/v1/secrets", + json={"scope_type": "project", "scope_id": str(p1), "key": "k1", "value": "v1"}, + ) + + resp = await auth_client.get("/api/v1/secrets", params={"scope_id": str(p2)}) + assert resp.status_code == 200 + secrets = resp.json() + for s in secrets: + assert s["scope_id"] != str(p1) or s["scope_type"] != "project" diff --git a/apps/api/tests/test_routers/test_tool_definitions.py b/apps/api/tests/test_routers/test_tool_definitions.py new file mode 100644 index 0000000..9d1a622 --- /dev/null +++ b/apps/api/tests/test_routers/test_tool_definitions.py @@ -0,0 +1,26 @@ +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +async def test_tool_definition_crud(auth_client: AsyncClient) -> None: + resp = await auth_client.post( + "/api/v1/tool-definitions", + json={"key": "runfusion", "name": "RunFusion", "image": "runfusion:latest"}, + ) + assert resp.status_code == 201 + td_id = resp.json()["id"] + + resp = await auth_client.get("/api/v1/tool-definitions") + assert resp.status_code == 200 + assert len(resp.json()) >= 1 + + resp = await auth_client.get(f"/api/v1/tool-definitions/{td_id}") + assert resp.status_code == 200 + + resp = await auth_client.put(f"/api/v1/tool-definitions/{td_id}", json={"name": "RunFusionV2"}) + assert resp.status_code == 200 + assert resp.json()["name"] == "RunFusionV2" + + resp = await auth_client.delete(f"/api/v1/tool-definitions/{td_id}") + assert resp.status_code == 204 diff --git a/docs/development.md b/docs/development.md index 010964d..bb4ecc8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -59,6 +59,34 @@ pnpm dev # Runs frontend and backend in parallel docker compose up --build -d ``` +### Alembic Migrations + +Generate a new migration after modifying models: +```bash +cd apps/api +.venv/bin/alembic revision --autogenerate -m "description" +``` + +Apply migrations: +```bash +cd apps/api +.venv/bin/alembic upgrade head +``` + +Downgrade one revision: +```bash +cd apps/api +.venv/bin/alembic downgrade -1 +``` + +Or use the Makefile targets: +```bash +cd apps/api +make revision msg="description" +make upgrade +make downgrade +``` + ## Testing ### Frontend