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
@@ -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