51d93d9dc6
- Add RuntimeInjectionService for scope-based config/secret resolution - Mount configs as JSON files at /app/config/ with 0400 permissions - Inject secrets as environment variables with uppercase keys - Implement scope hierarchy: instance > project > user > global - Create ConfigListPage and SecretListPage frontend components - Mask secret values in API responses (never expose decrypted) - Validate secrets exist before spawning containers - Add comprehensive tests for runtime injection service - Update documentation with config/secrets workflow
130 lines
4.8 KiB
Python
130 lines
4.8 KiB
Python
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 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.from_secret(secret)
|
|
|
|
|
|
@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.from_secret(s))
|
|
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.from_secret(s)
|
|
|
|
|
|
@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.from_secret(s)
|
|
|
|
|
|
@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()
|