feat(FN-004): merge fusion/fn-004

This commit is contained in:
Fusion
2026-05-14 06:47:30 +02:00
parent 4cbd30ff42
commit 3a18a1f170
62 changed files with 2567 additions and 11 deletions
+3
View File
@@ -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
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+10
View File
@@ -17,3 +17,13 @@
## Context
<!-- Important background information, dependency constraints, deployment notes -->
**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"])`
Submodule .worktrees/hazy-peach added at 08a21eadb7
Submodule .worktrees/sharp-robin added at f0169ad51d
+19
View File
@@ -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
+149
View File
@@ -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 <script_location>/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
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+76
View File
@@ -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())
+28
View File
@@ -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"}
@@ -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 ###
+3
View File
@@ -0,0 +1,3 @@
from app.auth.dependencies import get_current_active_user, get_current_user
__all__ = ["get_current_user", "get_current_active_user"]
+93
View File
@@ -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
+53
View File
@@ -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}")
+7 -2
View File
@@ -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()
+25
View File
@@ -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()
+30
View File
@@ -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")
+41 -3
View File
@@ -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)
+23
View File
@@ -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",
]
+35
View File
@@ -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"
)
+26
View File
@@ -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(),
)
+22
View File
@@ -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)
+36
View File
@@ -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"
)
+30
View File
@@ -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"
)
+18
View File
@@ -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)
+32
View File
@@ -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"
)
+46
View File
@@ -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"
)
+30
View File
@@ -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"
)
+24
View File
@@ -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"
)
+25
View File
@@ -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"]
+103
View File
@@ -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()
+122
View File
@@ -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()
+80
View File
@@ -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()
+100
View File
@@ -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()
+153
View File
@@ -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()
+88
View File
@@ -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()
+100
View File
@@ -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()
+17
View File
@@ -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]
+100
View File
@@ -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()
+42
View File
@@ -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",
]
+29
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
from pydantic import BaseModel, ConfigDict
class OrmBase(BaseModel):
model_config = ConfigDict(from_attributes=True)
+25
View File
@@ -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
+24
View File
@@ -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
+26
View File
@@ -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
+23
View File
@@ -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
+29
View File
@@ -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
+30
View File
@@ -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
+21
View File
@@ -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
+22
View File
@@ -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
+12
View File
@@ -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"
+84
View File
@@ -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
+66
View File
@@ -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
+6 -6
View File
@@ -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"
@@ -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
@@ -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
@@ -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"
@@ -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
+28
View File
@@ -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