feat: implement auth, projects, and frontend foundation
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Headquarter API package."""
|
||||
@@ -0,0 +1,3 @@
|
||||
from src.api.auth import router as auth_router
|
||||
|
||||
__all__ = ["auth_router"]
|
||||
@@ -0,0 +1,190 @@
|
||||
from secrets import token_urlsafe
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import AsyncGenerator, Literal, cast
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.cookies import build_cookie_options
|
||||
from src.auth.jwt_service import decode_access_token, mint_access_token
|
||||
from src.auth.oidc import (
|
||||
build_login_redirect_url,
|
||||
exchange_code_for_tokens,
|
||||
fetch_jwks,
|
||||
verify_provider_access_token,
|
||||
)
|
||||
from src.auth.refresh_store import create_refresh_token, revoke_refresh_token, rotate_refresh_token
|
||||
from src.config import Settings
|
||||
from src.database import SessionLocal
|
||||
from src.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with SessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def login() -> RedirectResponse:
|
||||
settings = Settings()
|
||||
redirect_uri = "http://localhost:8000/auth/callback"
|
||||
state = token_urlsafe(24)
|
||||
location = build_login_redirect_url(
|
||||
settings=settings,
|
||||
redirect_uri=redirect_uri,
|
||||
state=state,
|
||||
nonce=token_urlsafe(16),
|
||||
)
|
||||
response = RedirectResponse(location)
|
||||
response.set_cookie("auth_state", state, httponly=True, samesite="lax")
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/callback")
|
||||
async def callback(
|
||||
code: str,
|
||||
state: str,
|
||||
response: Response,
|
||||
auth_state: str | None = Cookie(default=None),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict[str, str]:
|
||||
if auth_state is None or auth_state != state:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid state")
|
||||
|
||||
settings = Settings()
|
||||
redirect_uri = "http://localhost:8000/auth/callback"
|
||||
async with httpx.AsyncClient() as client:
|
||||
token_payload = await exchange_code_for_tokens(
|
||||
settings=settings,
|
||||
code=code,
|
||||
redirect_uri=redirect_uri,
|
||||
client=client,
|
||||
)
|
||||
jwks = await fetch_jwks(settings=settings, client=client)
|
||||
|
||||
provider_claims = verify_provider_access_token(
|
||||
settings=settings,
|
||||
token=token_payload["access_token"],
|
||||
jwks=jwks,
|
||||
)
|
||||
|
||||
authentik_id = str(provider_claims["sub"])
|
||||
email = str(provider_claims.get("email", f"{authentik_id}@authentik.local"))
|
||||
name = str(provider_claims.get("name", email))
|
||||
|
||||
user = await session.scalar(select(User).where(User.authentik_id == authentik_id))
|
||||
if user is None:
|
||||
user = User(email=email, name=name, authentik_id=authentik_id, avatar_url=None)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
else:
|
||||
user.email = email
|
||||
user.name = name
|
||||
await session.commit()
|
||||
|
||||
access_token = mint_access_token(
|
||||
settings=settings,
|
||||
subject=str(user.id),
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=settings.access_token_ttl_minutes),
|
||||
)
|
||||
refresh_token, _ = await create_refresh_token(
|
||||
session=session,
|
||||
user_id=user.id,
|
||||
expires_at=datetime.now(UTC) + timedelta(days=settings.refresh_token_ttl_days),
|
||||
user_agent=None,
|
||||
ip_address=None,
|
||||
)
|
||||
|
||||
cookie_options = build_cookie_options(settings)
|
||||
cookie_samesite = cast(Literal["lax", "strict", "none"], cookie_options["samesite"])
|
||||
cookie_secure = bool(cookie_options["secure"])
|
||||
response.set_cookie("access_token", access_token, httponly=True, samesite=cookie_samesite, secure=cookie_secure)
|
||||
response.set_cookie("refresh_token", refresh_token, httponly=True, samesite=cookie_samesite, secure=cookie_secure)
|
||||
response.delete_cookie("auth_state", samesite="lax")
|
||||
|
||||
return {"sub": str(user.id), "email": user.email, "name": user.name}
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
async def refresh(
|
||||
response: Response,
|
||||
refresh_token: str | None = Cookie(default=None),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict[str, str]:
|
||||
if not refresh_token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing refresh token")
|
||||
|
||||
settings = Settings()
|
||||
try:
|
||||
rotated_raw_token, rotated_record = await rotate_refresh_token(
|
||||
session=session,
|
||||
raw_token=refresh_token,
|
||||
user_agent=None,
|
||||
ip_address=None,
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(error)) from error
|
||||
|
||||
user = await session.get(User, rotated_record.user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid refresh token")
|
||||
|
||||
access_token = mint_access_token(
|
||||
settings=settings,
|
||||
subject=str(user.id),
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=settings.access_token_ttl_minutes),
|
||||
)
|
||||
|
||||
cookie_options = build_cookie_options(settings)
|
||||
cookie_samesite = cast(Literal["lax", "strict", "none"], cookie_options["samesite"])
|
||||
cookie_secure = bool(cookie_options["secure"])
|
||||
|
||||
response.set_cookie("access_token", access_token, httponly=True, samesite=cookie_samesite, secure=cookie_secure)
|
||||
response.set_cookie("refresh_token", rotated_raw_token, httponly=True, samesite=cookie_samesite, secure=cookie_secure)
|
||||
|
||||
return {"sub": str(user.id), "email": user.email, "name": user.name}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
response: Response,
|
||||
refresh_token: str | None = Cookie(default=None),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict[str, str]:
|
||||
settings = Settings()
|
||||
cookie_options = build_cookie_options(settings)
|
||||
cookie_samesite = cast(Literal["lax", "strict", "none"], cookie_options["samesite"])
|
||||
cookie_secure = bool(cookie_options["secure"])
|
||||
|
||||
if refresh_token:
|
||||
try:
|
||||
await revoke_refresh_token(session=session, raw_token=refresh_token)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
response.delete_cookie("access_token", samesite=cookie_samesite, secure=cookie_secure)
|
||||
response.delete_cookie("refresh_token", samesite=cookie_samesite, secure=cookie_secure)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(access_token: str | None = Cookie(default=None)) -> dict[str, str]:
|
||||
if not access_token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing access token")
|
||||
|
||||
claims = decode_access_token(settings=Settings(), token=access_token)
|
||||
return {
|
||||
"sub": str(claims["sub"]),
|
||||
"email": str(claims["email"]),
|
||||
"name": str(claims["name"]),
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.jwt_service import decode_access_token
|
||||
from src.config import Settings
|
||||
from src.database import SessionLocal
|
||||
from src.models.project import Project
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
|
||||
|
||||
async def get_db_session():
|
||||
async with SessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def get_current_user_id(
|
||||
access_token: Annotated[str | None, Cookie()] = None,
|
||||
) -> uuid.UUID:
|
||||
if not access_token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing access token")
|
||||
|
||||
try:
|
||||
claims = decode_access_token(settings=Settings(), token=access_token)
|
||||
return uuid.UUID(str(claims["sub"]))
|
||||
except Exception:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid access token")
|
||||
|
||||
|
||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||
return user
|
||||
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class ProjectResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
description: str | None
|
||||
owner_id: uuid.UUID
|
||||
default_ssh_key_id: uuid.UUID | None
|
||||
|
||||
|
||||
class SetDefaultSSHKeyRequest(BaseModel):
|
||||
ssh_key_id: uuid.UUID
|
||||
|
||||
|
||||
@router.post("", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_project(
|
||||
data: ProjectCreate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> Project:
|
||||
user = await _get_user(session, user_id)
|
||||
project = Project(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
owner_id=user.id,
|
||||
default_ssh_key_id=None,
|
||||
)
|
||||
session.add(project)
|
||||
await session.commit()
|
||||
await session.refresh(project)
|
||||
return project
|
||||
|
||||
|
||||
@router.get("", response_model=list[ProjectResponse])
|
||||
async def list_projects(
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[Project]:
|
||||
user = await _get_user(session, user_id)
|
||||
result = await session.execute(select(Project).where(Project.owner_id == user.id))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def _get_owned_project(
|
||||
project_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
session: AsyncSession,
|
||||
) -> Project:
|
||||
project = await session.get(Project, project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
|
||||
if project.owner_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
|
||||
return project
|
||||
|
||||
|
||||
@router.patch("/{project_id}", response_model=ProjectResponse)
|
||||
async def update_project(
|
||||
project_id: uuid.UUID,
|
||||
data: ProjectUpdate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> Project:
|
||||
await _get_user(session, user_id)
|
||||
project = await _get_owned_project(project_id, user_id, session)
|
||||
|
||||
if data.name is not None:
|
||||
project.name = data.name
|
||||
if data.description is not None:
|
||||
project.description = data.description
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(project)
|
||||
return project
|
||||
|
||||
|
||||
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_project(
|
||||
project_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> Response:
|
||||
await _get_user(session, user_id)
|
||||
project = await _get_owned_project(project_id, user_id, session)
|
||||
await session.delete(project)
|
||||
await session.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.patch("/{project_id}/default-ssh-key", response_model=ProjectResponse)
|
||||
async def set_default_ssh_key(
|
||||
project_id: uuid.UUID,
|
||||
data: SetDefaultSSHKeyRequest,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> Project:
|
||||
user = await _get_user(session, user_id)
|
||||
project = await _get_owned_project(project_id, user_id, session)
|
||||
|
||||
ssh_key = await session.get(SSHKey, data.ssh_key_id)
|
||||
if ssh_key is None or ssh_key.user_id != user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="invalid ssh key",
|
||||
)
|
||||
|
||||
project.default_ssh_key_id = data.ssh_key_id
|
||||
await session.commit()
|
||||
await session.refresh(project)
|
||||
return project
|
||||
@@ -0,0 +1,12 @@
|
||||
from src.auth.cookies import build_cookie_options
|
||||
from src.auth.jwt_service import decode_access_token, mint_access_token
|
||||
from src.auth.oidc import build_login_redirect_url
|
||||
from src.auth.refresh_store import hash_refresh_token
|
||||
|
||||
__all__ = [
|
||||
"build_cookie_options",
|
||||
"build_login_redirect_url",
|
||||
"decode_access_token",
|
||||
"hash_refresh_token",
|
||||
"mint_access_token",
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
from src.config import Settings
|
||||
|
||||
|
||||
def build_cookie_options(settings: Settings) -> dict[str, str | bool]:
|
||||
return {
|
||||
"httponly": True,
|
||||
"secure": settings.cookie_secure,
|
||||
"samesite": settings.cookie_samesite,
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
from datetime import datetime
|
||||
|
||||
from jose import jwt # type: ignore[import-untyped]
|
||||
|
||||
from src.config import Settings
|
||||
|
||||
|
||||
def mint_access_token(
|
||||
*,
|
||||
settings: Settings,
|
||||
subject: str,
|
||||
email: str,
|
||||
name: str,
|
||||
expires_at: datetime,
|
||||
) -> str:
|
||||
payload = {
|
||||
"sub": subject,
|
||||
"email": email,
|
||||
"name": name,
|
||||
"exp": expires_at,
|
||||
}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
def decode_access_token(*, settings: Settings, token: str) -> dict[str, str | int]:
|
||||
claims = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
|
||||
return dict(claims)
|
||||
@@ -0,0 +1,77 @@
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from jose import jwt # type: ignore[import-untyped]
|
||||
|
||||
from src.config import Settings
|
||||
|
||||
|
||||
def build_login_redirect_url(
|
||||
*,
|
||||
settings: Settings,
|
||||
redirect_uri: str,
|
||||
state: str,
|
||||
nonce: str,
|
||||
) -> str:
|
||||
query = urlencode(
|
||||
{
|
||||
"response_type": "code",
|
||||
"client_id": settings.authentik_client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": "openid profile email",
|
||||
"state": state,
|
||||
"nonce": nonce,
|
||||
}
|
||||
)
|
||||
return f"{settings.authentik_authorize_url}?{query}"
|
||||
|
||||
|
||||
async def exchange_code_for_tokens(
|
||||
*,
|
||||
settings: Settings,
|
||||
code: str,
|
||||
redirect_uri: str,
|
||||
client: httpx.AsyncClient,
|
||||
) -> dict[str, str]:
|
||||
response = await client.post(
|
||||
settings.authentik_token_url,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": settings.authentik_client_id,
|
||||
"client_secret": settings.authentik_client_secret,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
return {
|
||||
"access_token": payload["access_token"],
|
||||
"refresh_token": payload["refresh_token"],
|
||||
}
|
||||
|
||||
|
||||
async def fetch_jwks(*, settings: Settings, client: httpx.AsyncClient) -> dict[str, list[dict[str, str]]]:
|
||||
response = await client.get(settings.authentik_jwks_url)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
return {"keys": payload["keys"]}
|
||||
|
||||
|
||||
def verify_provider_access_token(
|
||||
*,
|
||||
settings: Settings,
|
||||
token: str,
|
||||
jwks: dict[str, list[dict[str, str]]],
|
||||
) -> dict[str, str | int]:
|
||||
unverified_header = jwt.get_unverified_header(token)
|
||||
key_id = unverified_header["kid"]
|
||||
jwk_key = next(key for key in jwks["keys"] if key.get("kid") == key_id)
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
jwk_key,
|
||||
algorithms=[jwk_key.get("alg", "HS256")],
|
||||
audience=settings.authentik_audience,
|
||||
issuer=settings.authentik_issuer,
|
||||
)
|
||||
return dict(claims)
|
||||
@@ -0,0 +1,79 @@
|
||||
from datetime import UTC, datetime
|
||||
from hashlib import sha256
|
||||
from secrets import token_urlsafe
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models.refresh_token import RefreshToken
|
||||
|
||||
|
||||
def hash_refresh_token(raw_token: str) -> str:
|
||||
return sha256(raw_token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def create_refresh_token(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
user_id: object,
|
||||
expires_at: datetime,
|
||||
user_agent: str | None,
|
||||
ip_address: str | None,
|
||||
) -> tuple[str, RefreshToken]:
|
||||
raw_token = token_urlsafe(48)
|
||||
record = RefreshToken(
|
||||
user_id=user_id,
|
||||
token_hash=hash_refresh_token(raw_token),
|
||||
expires_at=expires_at,
|
||||
created_at=datetime.now(UTC),
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
session.add(record)
|
||||
await session.commit()
|
||||
await session.refresh(record)
|
||||
return raw_token, record
|
||||
|
||||
|
||||
async def rotate_refresh_token(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
raw_token: str,
|
||||
user_agent: str | None,
|
||||
ip_address: str | None,
|
||||
) -> tuple[str, RefreshToken]:
|
||||
existing_hash = hash_refresh_token(raw_token)
|
||||
existing = await session.scalar(
|
||||
select(RefreshToken).where(
|
||||
RefreshToken.token_hash == existing_hash,
|
||||
RefreshToken.revoked_at.is_(None),
|
||||
)
|
||||
)
|
||||
if existing is None:
|
||||
raise ValueError("refresh token not found")
|
||||
if existing.expires_at <= datetime.now(UTC):
|
||||
raise ValueError("refresh token expired")
|
||||
|
||||
existing.revoked_at = datetime.now(UTC)
|
||||
await session.flush()
|
||||
|
||||
return await create_refresh_token(
|
||||
session=session,
|
||||
user_id=existing.user_id,
|
||||
expires_at=existing.expires_at,
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
|
||||
async def revoke_refresh_token(*, session: AsyncSession, raw_token: str) -> bool:
|
||||
token_hash = hash_refresh_token(raw_token)
|
||||
existing = await session.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash))
|
||||
if existing is None:
|
||||
return False
|
||||
if existing.revoked_at is not None:
|
||||
return True
|
||||
|
||||
existing.revoked_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
return True
|
||||
@@ -0,0 +1,62 @@
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
def build_database_url(
|
||||
*,
|
||||
user: str,
|
||||
password: str,
|
||||
host: str,
|
||||
port: int,
|
||||
database: str,
|
||||
) -> str:
|
||||
return f"postgresql+asyncpg://{user}:{password}@{host}:{port}/{database}"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_env: str = "development"
|
||||
database_url_override: str | None = Field(default=None, alias="DATABASE_URL")
|
||||
postgres_user: str = "headquarter"
|
||||
postgres_password: str = "headquarter"
|
||||
postgres_host: str = "postgres"
|
||||
postgres_port: int = 5432
|
||||
postgres_db: str = "headquarter"
|
||||
|
||||
authentik_client_id: str = "headquarter-web"
|
||||
authentik_client_secret: str = "change-me"
|
||||
authentik_authorize_url: str = "https://authentik.local/application/o/authorize/"
|
||||
authentik_token_url: str = "https://authentik.local/application/o/token/"
|
||||
authentik_jwks_url: str = "https://authentik.local/application/o/headquarter-web/jwks/"
|
||||
authentik_issuer: str = "https://authentik.local/application/o/headquarter-web/"
|
||||
authentik_audience: str = "headquarter-web"
|
||||
|
||||
jwt_secret: str = "change-me-jwt-secret"
|
||||
jwt_algorithm: str = "HS256"
|
||||
access_token_ttl_minutes: int = 15
|
||||
refresh_token_ttl_days: int = 7
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore", populate_by_name=True)
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
if self.database_url_override:
|
||||
return self.database_url_override
|
||||
|
||||
return build_database_url(
|
||||
user=self.postgres_user,
|
||||
password=self.postgres_password,
|
||||
host=self.postgres_host,
|
||||
port=self.postgres_port,
|
||||
database=self.postgres_db,
|
||||
)
|
||||
|
||||
@property
|
||||
def cookie_secure(self) -> bool:
|
||||
return self.app_env == "production"
|
||||
|
||||
@property
|
||||
def cookie_samesite(self) -> str:
|
||||
if self.app_env == "production":
|
||||
return "strict"
|
||||
|
||||
return "lax"
|
||||
@@ -0,0 +1,15 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from src.config import Settings, build_database_url
|
||||
|
||||
|
||||
settings = Settings()
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
future=True,
|
||||
poolclass=NullPool,
|
||||
)
|
||||
SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
__all__ = ["SessionLocal", "build_database_url", "engine", "settings"]
|
||||
@@ -0,0 +1,8 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from src.api.auth import router as auth_router
|
||||
from src.api.projects import router as projects_router
|
||||
|
||||
app = FastAPI(title="Headquarter API")
|
||||
app.include_router(auth_router)
|
||||
app.include_router(projects_router)
|
||||
@@ -0,0 +1,9 @@
|
||||
from src.models.base import Base
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.refresh_token import RefreshToken
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.user import User
|
||||
from src.models.user_config import UserConfig
|
||||
|
||||
__all__ = ["Base", "GitRepository", "Project", "RefreshToken", "SSHKey", "User", "UserConfig"]
|
||||
@@ -0,0 +1,24 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class UUIDPrimaryKeyMixin:
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.project import Project
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
class GitRepository(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "git_repositories"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
path: Mapped[str] = mapped_column(String(1024))
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
|
||||
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
last_push: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
project: Mapped["Project"] = relationship(back_populates="repositories")
|
||||
owner: Mapped["User"] = relationship()
|
||||
@@ -0,0 +1,31 @@
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
class Project(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "projects"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
default_ssh_key_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("ssh_keys.id"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
owner: Mapped["User"] = relationship(back_populates="projects")
|
||||
repositories: Mapped[list["GitRepository"]] = relationship(back_populates="project")
|
||||
default_ssh_key: Mapped["SSHKey | None"] = relationship(foreign_keys=[default_ssh_key_id])
|
||||
ssh_keys: Mapped[list["SSHKey"]] = relationship(back_populates="project", foreign_keys="SSHKey.project_id")
|
||||
@@ -0,0 +1,24 @@
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.models.base import Base, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
class RefreshToken(UUIDPrimaryKeyMixin, Base):
|
||||
__tablename__ = "refresh_tokens"
|
||||
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="refresh_tokens")
|
||||
@@ -0,0 +1,25 @@
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.models.base import Base, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.project import Project
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
class SSHKey(UUIDPrimaryKeyMixin, Base):
|
||||
__tablename__ = "ssh_keys"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
public_key: Mapped[str] = mapped_column(Text)
|
||||
private_key_encrypted: Mapped[str] = mapped_column(Text)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
project_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=True)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="ssh_keys")
|
||||
project: Mapped["Project | None"] = relationship(back_populates="ssh_keys", foreign_keys=[project_id])
|
||||
@@ -0,0 +1,26 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.project import Project
|
||||
from src.models.refresh_token import RefreshToken
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.user_config import UserConfig
|
||||
|
||||
|
||||
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
authentik_id: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
avatar_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
|
||||
projects: Mapped[list["Project"]] = relationship(back_populates="owner")
|
||||
refresh_tokens: Mapped[list["RefreshToken"]] = relationship(back_populates="user")
|
||||
ssh_keys: Mapped[list["SSHKey"]] = relationship(back_populates="user")
|
||||
user_config: Mapped["UserConfig | None"] = relationship(back_populates="user", uselist=False)
|
||||
@@ -0,0 +1,20 @@
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
class UserConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "user_configs"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, unique=True)
|
||||
config: Mapped[dict[str, object]] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="user_config")
|
||||
@@ -0,0 +1 @@
|
||||
"""Utility scripts for the API package."""
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.database import SessionLocal
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
def build_seed_user() -> Mapping[str, str | None]:
|
||||
return {
|
||||
"email": "dev@headquarter.local",
|
||||
"name": "Development User",
|
||||
"authentik_id": "dev-authentik-user",
|
||||
"avatar_url": None,
|
||||
}
|
||||
|
||||
|
||||
async def seed_database(session: AsyncSession) -> User:
|
||||
payload = build_seed_user()
|
||||
existing_user = await session.scalar(select(User).where(User.email == payload["email"]))
|
||||
|
||||
if existing_user is not None:
|
||||
return existing_user
|
||||
|
||||
user = User(**payload)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def run() -> None:
|
||||
async with SessionLocal() as session:
|
||||
user = await seed_database(session)
|
||||
print({"user_id": str(user.id), "email": user.email})
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import asyncio
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user