feat(FN-004): merge fusion/fn-004
This commit is contained in:
@@ -26,3 +26,6 @@ TOOL_SUBDOMAIN_PATTERN={tool}-{project}-{user}.tools.${ROOT_DOMAIN}
|
|||||||
|
|
||||||
# Secrets (generate strong random values for production)
|
# Secrets (generate strong random values for production)
|
||||||
SECRET_ENCRYPTION_KEY=change-me-in-production
|
SECRET_ENCRYPTION_KEY=change-me-in-production
|
||||||
|
|
||||||
|
# Auth dev bypass (local development only — NEVER enable in production)
|
||||||
|
AUTH_DEV_BYPASS=false
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -17,3 +17,13 @@
|
|||||||
## Context
|
## Context
|
||||||
|
|
||||||
<!-- Important background information, dependency constraints, deployment notes -->
|
<!-- 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
+1
Submodule .worktrees/hazy-peach added at 08a21eadb7
Submodule
+1
Submodule .worktrees/sharp-robin added at f0169ad51d
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
||||||
@@ -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())
|
||||||
@@ -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 ###
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.auth.dependencies import get_current_active_user, get_current_user
|
||||||
|
|
||||||
|
__all__ = ["get_current_user", "get_current_active_user"]
|
||||||
@@ -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
|
||||||
@@ -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}")
|
||||||
@@ -12,17 +12,22 @@ class Settings(BaseSettings):
|
|||||||
debug: bool = False
|
debug: bool = False
|
||||||
api_v1_prefix: str = "/api/v1"
|
api_v1_prefix: str = "/api/v1"
|
||||||
|
|
||||||
# Authentik OIDC placeholders (to be wired in FN-004)
|
# Authentik OIDC
|
||||||
authentik_issuer_url: str = ""
|
authentik_issuer_url: str = ""
|
||||||
authentik_client_id: str = ""
|
authentik_client_id: str = ""
|
||||||
authentik_client_secret: str = ""
|
authentik_client_secret: str = ""
|
||||||
|
|
||||||
# Database (to be wired in FN-004)
|
# Database
|
||||||
database_url: str = "postgresql://postgres:postgres@localhost:5432/headquarter"
|
database_url: str = "postgresql://postgres:postgres@localhost:5432/headquarter"
|
||||||
|
|
||||||
# Deployment
|
# Deployment
|
||||||
root_domain: str = "localhost"
|
root_domain: str = "localhost"
|
||||||
tool_subdomain_pattern: str = "{tool}-{project}-{user}.tools.{root_domain}"
|
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()
|
settings = Settings()
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
@@ -1,22 +1,60 @@
|
|||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
from app.config import settings
|
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(
|
app = FastAPI(
|
||||||
title=settings.app_name,
|
title=settings.app_name,
|
||||||
debug=settings.debug,
|
debug=settings.debug,
|
||||||
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
allow_origins = ["*"] if settings.debug else []
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["http://localhost:5173"],
|
allow_origins=allow_origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for router in routers:
|
||||||
|
app.include_router(router, prefix=settings.api_v1_prefix)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health() -> dict[str, str]:
|
async def health() -> JSONResponse:
|
||||||
return {"status": "ok", "service": settings.app_name}
|
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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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(),
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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"]
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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]
|
||||||
@@ -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()
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class OrmBase(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -7,6 +7,12 @@ dependencies = [
|
|||||||
"fastapi>=0.115.0",
|
"fastapi>=0.115.0",
|
||||||
"uvicorn[standard]>=0.34.0",
|
"uvicorn[standard]>=0.34.0",
|
||||||
"pydantic-settings>=2.8.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]
|
[project.optional-dependencies]
|
||||||
@@ -15,6 +21,7 @@ dev = [
|
|||||||
"httpx>=0.28.0",
|
"httpx>=0.28.0",
|
||||||
"ruff>=0.11.0",
|
"ruff>=0.11.0",
|
||||||
"mypy>=1.15.0",
|
"mypy>=1.15.0",
|
||||||
|
"sqlalchemy[mypy]",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
@@ -27,6 +34,7 @@ packages = ["app"]
|
|||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
target-version = "py311"
|
target-version = "py311"
|
||||||
|
exclude = ["alembic/versions"]
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E", "F", "I", "N", "W", "UP"]
|
select = ["E", "F", "I", "N", "W", "UP"]
|
||||||
@@ -36,3 +44,7 @@ python_version = "3.11"
|
|||||||
strict = true
|
strict = true
|
||||||
warn_return_any = true
|
warn_return_any = true
|
||||||
warn_unused_configs = true
|
warn_unused_configs = true
|
||||||
|
exclude = ["alembic/versions"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
from fastapi.testclient import TestClient
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.main import app
|
|
||||||
|
|
||||||
client = TestClient(app)
|
|
||||||
|
|
||||||
|
|
||||||
def test_health_returns_ok() -> None:
|
@pytest.mark.asyncio
|
||||||
response = client.get("/health")
|
async def test_health_returns_ok(client: AsyncClient) -> None:
|
||||||
|
response = await client.get("/health")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["status"] == "ok"
|
assert data["status"] == "ok"
|
||||||
assert data["service"] == settings.app_name
|
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
|
||||||
@@ -59,6 +59,34 @@ pnpm dev # Runs frontend and backend in parallel
|
|||||||
docker compose up --build -d
|
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
|
## Testing
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|||||||
Reference in New Issue
Block a user