feat: implement auth, projects, and frontend foundation

This commit is contained in:
2026-05-17 20:21:55 +00:00
parent e7819bfc82
commit 71d9fe6406
88 changed files with 10936 additions and 47 deletions
+1
View File
@@ -0,0 +1 @@
"""Headquarter API package."""
+3
View File
@@ -0,0 +1,3 @@
from src.api.auth import router as auth_router
__all__ = ["auth_router"]
+190
View File
@@ -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"]),
}
+163
View File
@@ -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
+12
View File
@@ -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",
]
+9
View File
@@ -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,
}
+27
View File
@@ -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)
+77
View File
@@ -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)
+79
View File
@@ -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
+62
View File
@@ -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"
+15
View File
@@ -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"]
+8
View File
@@ -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)
+9
View File
@@ -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"]
+24
View File
@@ -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,
)
+28
View File
@@ -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()
+31
View File
@@ -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")
+24
View File
@@ -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")
+25
View File
@@ -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])
+26
View File
@@ -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)
+20
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
"""Utility scripts for the API package."""
+48
View File
@@ -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()