1
0
Fork 0
forked from sr2/cloud-api

Compare commits

..

34 commits

Author SHA1 Message Date
7dad2e920e tests: get_testable_routes finds auth level
Checks all dependencies used on each endpoint and determines the highest level of auth applied to each endpoint.

API Key>SU>Root>User>None
2026-06-22 16:45:50 +01:00
bee0dcd4fe feat: soft deleted users access blocked 2026-06-22 16:12:03 +01:00
a9e059bf0a feat: user soft delete 2026-06-22 15:43:38 +01:00
irl
5b98be9787 ci: define context for docker 2026-06-22 15:30:29 +01:00
be46e43042 fix(db): user active default true 2026-06-22 15:28:46 +01:00
irl
8ab0390977 ci: fix branch name tag again 2026-06-22 15:26:39 +01:00
irl
cc4ae42646 ci: adds frontend ref 2026-06-22 15:24:33 +01:00
irl
44e1d4986f ci: relative repo path 2026-06-22 15:22:42 +01:00
irl
20615f438a ci: fix branch name tag 2026-06-22 15:21:31 +01:00
irl
a481be8352 ci: check out the frontend repo 2026-06-22 15:20:14 +01:00
irl
e7bd455b2d ci: run the build step somewhere 2026-06-22 15:18:28 +01:00
4b3ab92d2a fix: fastapi 0.137 router.route changes 2026-06-22 15:15:42 +01:00
irl
ee47186c5a fix(db): generator types 2026-06-22 15:12:34 +01:00
fab228bf8f minor: ruff format
Tabs -> spaces
2026-06-22 15:04:11 +01:00
b2921b73b8 fix: conftest match db changes 2026-06-22 15:02:39 +01:00
1a851859d0 fix: logging import for email 2026-06-22 15:02:04 +01:00
a343b76f63 fix: invalid toml syntax 2026-06-22 15:01:36 +01:00
irl
84ba3b6bee feat(db): db tuning options and consistency 2026-06-22 14:50:05 +01:00
40918fd8b8 feat: delete org soft deletes 2026-06-22 14:50:05 +01:00
irl
d395b01997 fix: only serve frontend if present in prod 2026-06-22 14:42:13 +01:00
irl
1384ee7bd6 feat: adds empty static directory for frontend 2026-06-22 14:40:02 +01:00
irl
df8ab32cb1 ci: build and publish OCI image 2026-06-22 14:38:23 +01:00
f41f76bcf8 Merge pull request 'feat(utils): use logging around email send' (#31) from irl/cloud-api:maillog into main
Reviewed-on: sr2/cloud-api#31
2026-06-22 13:37:15 +00:00
d07230b3b0 Merge pull request 'fix(user): simplify add_user' (#28) from irl/cloud-api:add_user into main
Reviewed-on: sr2/cloud-api#28
2026-06-22 13:34:36 +00:00
irl
9e1d6026b5 feat: adds Containerfile with frontend serving 2026-06-22 14:24:56 +01:00
c28b4dc37b feat: applied model mixins
IdMixin used on every table with an ID index (no changes needed to db)

Timestamp and Deleted mixins applied to org and user tables.

ActivatedMixin added to users.
2026-06-22 13:46:11 +01:00
7e1ab6c6ee feat: db model mixins 2026-06-22 13:46:11 +01:00
irl
0baa50d10f misc: add frontend dir to .gitignore 2026-06-22 13:30:53 +01:00
irl
53b42b24dd feat(utils): use logging around email send 2026-06-22 13:26:47 +01:00
irl
fe8f627fa5 ci: reduce min age for renovate to 7 days 2026-06-22 12:02:29 +00:00
c2777db2e3 Add renovate.json 2026-06-22 12:02:02 +00:00
irl
a9e539ef74 fix(user): simplify add_user 2026-06-22 12:23:38 +01:00
02ddf9a3ed fix: skip sending email process while running tests
Removes the need for lettermint api key in CI.
2026-06-22 12:06:43 +01:00
63e7d48c07 ci: remove non-ty checks from ty job 2026-06-22 12:04:39 +01:00
65 changed files with 3945 additions and 3726 deletions

View file

@ -34,8 +34,6 @@ jobs:
- run: uv python install # Gets Python version from pyproject.toml - run: uv python install # Gets Python version from pyproject.toml
- run: uv sync --dev - run: uv sync --dev
- run: uv run ty check - run: uv run ty check
- run: uv run ruff format
- run: uv run pytest test
env: env:
ENVIRONMENT: testing ENVIRONMENT: testing
@ -54,3 +52,35 @@ jobs:
- run: uv run pytest test - run: uv run pytest test
env: env:
ENVIRONMENT: testing ENVIRONMENT: testing
build:
needs: [ ruff, ty, tests ]
if: ${{ always() && needs.ruff.result == 'success' && needs.ty.result == 'success' && needs.tests.result == 'success' }}
runs-on: docker
container:
image: ghcr.io/catthehacker/ubuntu:act-latest
options: -v /dind/docker.sock:/var/run/docker.sock
steps:
- name: Checkout the repo
uses: actions/checkout@v4
- name: Checkout the frontend
uses: actions/checkout@v4
with:
repository: sr2/cloud-portal.git
path: frontend
ref: main
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to the registry
uses: docker/login-action@v3
with:
registry: guardianproject.dev
username: irl
password: ${{ secrets.PACKAGE_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
file: /workspace/sr2/cloud-api/Containerfile
context: /workspace/sr2/cloud-api/
push: true
tags: guardianproject.dev/${{ github.repository }}:${{ github.ref_name }}

4
.gitignore vendored
View file

@ -206,5 +206,7 @@ marimo/_static/
marimo/_lsp/ marimo/_lsp/
__marimo__/ __marimo__/
endpoints.txt endpoints.txt
# React Frontend
/frontend/

View file

@ -1 +1 @@
3.14 3.12

42
Containerfile Normal file
View file

@ -0,0 +1,42 @@
FROM node:22-slim AS react-builder
WORKDIR /app
COPY frontend/ /app/
RUN --mount=type=cache,target=/root/.npm npm ci
RUN npm run build # Outputs to /app/dist
FROM ghcr.io/astral-sh/uv:python3.12-trixie-slim AS python-builder
ENV UV_PYTHON_DOWNLOADS=0
WORKDIR /app
# Install dependencies first (layer caching)
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-editable
# Copy project source and install the project itself
COPY ./ /app/
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-editable
FROM python:3.12-slim-trixie
WORKDIR /app
COPY alembic /app/alembic
COPY alembic.ini /app
COPY src /app/src
COPY --from=python-builder /app/.venv /app/.venv
COPY --from=react-builder /app/dist /app/static
# Ensure venv is on PATH
ENV PATH="/app/.venv/bin:$PATH" \
UV_PYTHON_DOWNLOADS=0
EXPOSE 8000
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

View file

@ -0,0 +1,32 @@
"""fix user activated default
Revision ID: ae433e1c3b20
Revises: 661202797ecd
Create Date: 2026-06-22 15:26:57.805129
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ae433e1c3b20'
down_revision: Union[str, Sequence[str], None] = '661202797ecd'
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.alter_column('user', 'active', server_default=sa.true())
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('user', 'active', server_default=sa.false())
# ### end Alembic commands ###

View file

@ -0,0 +1,44 @@
"""model mixins
Revision ID: 661202797ecd
Revises: 869d48618a1c
Create Date: 2026-06-22 13:29:39.689067
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '661202797ecd'
down_revision: Union[str, Sequence[str], None] = '869d48618a1c'
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.add_column('organisation', sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()))
op.add_column('organisation', sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()))
op.add_column('organisation', sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True))
op.add_column('user', sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.false()))
op.add_column('user', sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()))
op.add_column('user', sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()))
op.add_column('user', sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('user', 'deleted_at')
op.drop_column('user', 'updated_at')
op.drop_column('user', 'created_at')
op.drop_column('user', 'active')
op.drop_column('organisation', 'deleted_at')
op.drop_column('organisation', 'updated_at')
op.drop_column('organisation', 'created_at')
# ### end Alembic commands ###

View file

@ -8,7 +8,7 @@ requires-python = ">=3.12"
dependencies = [ dependencies = [
"alembic>=1.18.4", "alembic>=1.18.4",
"email-validator>=2.3.0", "email-validator>=2.3.0",
"fastapi>=0.136.3", "fastapi>=0.138.0",
"httptools>=0.7.1", "httptools>=0.7.1",
"httpx>=0.28.1", "httpx>=0.28.1",
"itsdangerous>=2.2.0", "itsdangerous>=2.2.0",
@ -34,11 +34,11 @@ line-length = 92
[tool.ruff.format] [tool.ruff.format]
quote-style = "double" quote-style = "double"
indent-style = "tab"
[tool.uv] [tool.uv]
add-bounds = "major" add-bounds = "major"
exclude-newer = "P2W" exclude-newer = "P2W"
exclude-newer-package = { "fastapi" = "2026-06-22T00:00:00Z" }
[dependency-groups] [dependency-groups]
dev = [ dev = [

8
renovate.json Normal file
View file

@ -0,0 +1,8 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
],
"minimumReleaseAge": "7 days",
"gitAuthor": "Renovate<noreply@sr2.uk>"
}

View file

@ -22,7 +22,7 @@ from src.organisation.exceptions import AwaitingApprovalException
from src.organisation.models import Organisation as Org from src.organisation.models import Organisation as Org
from src.exceptions import UnauthorizedException, ForbiddenException from src.exceptions import UnauthorizedException, ForbiddenException
from src.auth.config import auth_settings from src.auth.config import auth_settings
from src.user.service import add_user_to_db from src.user.service import add_user
from src.database import DbSession from src.database import DbSession
@ -53,7 +53,7 @@ async def get_current_user(
claims_requests.validate(token.claims) claims_requests.validate(token.claims)
except ExpiredTokenError: except ExpiredTokenError:
raise UnauthorizedException(message="Token is expired") raise UnauthorizedException(message="Token is expired")
db_id = await add_user_to_db(db, token.claims) db_id = await add_user(db, token.claims)
token.claims["db_id"] = db_id token.claims["db_id"] = db_id

View file

@ -6,16 +6,17 @@ Models:
street_address, street_address_line_2, post_office_box_number, address_locality, country_code, address_region, postal_code street_address, street_address_line_2, post_office_box_number, address_locality, country_code, address_region, postal_code
""" """
from src.models import IdMixin
from sqlalchemy import ForeignKey from sqlalchemy import ForeignKey
from sqlalchemy.orm import mapped_column, Mapped from sqlalchemy.orm import mapped_column, Mapped
from src.models import CustomBase from src.models import CustomBase
class Contact(CustomBase): class Contact(CustomBase, IdMixin):
__tablename__ = "contact" __tablename__ = "contact"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(default=None, nullable=True) email: Mapped[str] = mapped_column(default=None, nullable=True)
first_name: Mapped[str] = mapped_column(default=None, nullable=True) first_name: Mapped[str] = mapped_column(default=None, nullable=True)
last_name: Mapped[str] = mapped_column(default=None, nullable=True) last_name: Mapped[str] = mapped_column(default=None, nullable=True)

View file

@ -1,6 +1,7 @@
""" """
Database connection and session utilities Database connection and session utilities
""" """
from contextlib import contextmanager from contextlib import contextmanager
from typing import Annotated, Generator from typing import Annotated, Generator
from sqlalchemy import create_engine, StaticPool, Connection from sqlalchemy import create_engine, StaticPool, Connection
@ -29,6 +30,7 @@ else:
sm = sessionmaker(autocommit=False, expire_on_commit=False, bind=engine) sm = sessionmaker(autocommit=False, expire_on_commit=False, bind=engine)
@contextmanager @contextmanager
def get_db_connection() -> Generator[Connection, None, None]: def get_db_connection() -> Generator[Connection, None, None]:
with engine.connect() as connection: with engine.connect() as connection:
@ -38,12 +40,15 @@ def get_db_connection() -> Generator[Connection, None, None]:
connection.rollback() connection.rollback()
raise raise
def _get_db_connection() -> Generator[Connection, None]:
def _get_db_connection() -> Generator[Connection, None, None]:
with get_db_connection() as connection: with get_db_connection() as connection:
yield connection yield connection
DbConnection = Annotated[Connection, Depends(_get_db_connection)] DbConnection = Annotated[Connection, Depends(_get_db_connection)]
@contextmanager @contextmanager
def get_db_session() -> Generator[Session, None, None]: def get_db_session() -> Generator[Session, None, None]:
session = sm() session = sm()
@ -56,8 +61,9 @@ def get_db_session() -> Generator[Session, None, None]:
session.close() session.close()
def _get_db_session() -> Generator[Session, None]: def _get_db_session() -> Generator[Session, None, None]:
with get_db_session() as session: with get_db_session() as session:
yield session yield session
DbSession = Annotated[Session, Depends(_get_db_session)] DbSession = Annotated[Session, Depends(_get_db_session)]

View file

@ -18,9 +18,7 @@ from src.iam.exceptions import GroupNotFoundException, PermNotFoundException
from src.iam.schemas import GroupIDMixin, PermIDMixin from src.iam.schemas import GroupIDMixin, PermIDMixin
def get_group_model_query( def get_group_model_query(db: DbSession, group_id: Annotated[int, Query(gt=0)]) -> Group:
db: DbSession, group_id: Annotated[int, Query(gt=0)]
) -> Group:
group_model = db.get(Group, group_id) group_model = db.get(Group, group_id)
if group_model is None: if group_model is None:
raise GroupNotFoundException(group_id) raise GroupNotFoundException(group_id)
@ -63,9 +61,7 @@ def get_perm_model_body(
perm_model_body_dependency = Annotated[Permission, Depends(get_perm_model_body)] perm_model_body_dependency = Annotated[Permission, Depends(get_perm_model_body)]
def get_perm_model_query( def get_perm_model_query(db: DbSession, perm_id: Annotated[int, Query(gt=0)]) -> Permission:
db: DbSession, perm_id: Annotated[int, Query(gt=0)]
) -> Permission:
perm_model = db.get(Permission, perm_id) perm_model = db.get(Permission, perm_id)
if perm_model is None: if perm_model is None:
raise PermNotFoundException(perm_id) raise PermNotFoundException(perm_id)

View file

@ -21,13 +21,12 @@ Models:
from sqlalchemy import ForeignKey, UniqueConstraint from sqlalchemy import ForeignKey, UniqueConstraint
from sqlalchemy.orm import relationship, mapped_column, Mapped from sqlalchemy.orm import relationship, mapped_column, Mapped
from src.models import CustomBase from src.models import CustomBase, IdMixin
class Permission(CustomBase): class Permission(CustomBase, IdMixin):
__tablename__ = "permission" __tablename__ = "permission"
id: Mapped[int] = mapped_column(primary_key=True)
resource: Mapped[str] resource: Mapped[str]
action: Mapped[str] action: Mapped[str]
@ -61,9 +60,9 @@ class Permission(CustomBase):
return self.service_rel.name return self.service_rel.name
class Group(CustomBase): class Group(CustomBase, IdMixin):
__tablename__ = "group" __tablename__ = "group"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] name: Mapped[str]
org_id: Mapped[int] = mapped_column(ForeignKey("organisation.id", ondelete="CASCADE")) org_id: Mapped[int] = mapped_column(ForeignKey("organisation.id", ondelete="CASCADE"))

View file

@ -468,9 +468,7 @@ async def remove_group_user(
}, },
}, },
) )
async def get_permissions( async def get_permissions(db: DbSession, org_model: org_model_root_claim_query_dependency):
db: DbSession, org_model: org_model_root_claim_query_dependency
):
""" """
Returns a full list of permissions. Returns a full list of permissions.
""" """

View file

@ -2,6 +2,7 @@
Application root file: Inits the FastAPI application Application root file: Inits the FastAPI application
""" """
import os.path
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import AsyncGenerator from typing import AsyncGenerator
@ -77,3 +78,6 @@ if settings.DISABLE_AUTH and (settings.ENVIRONMENT == Environment.LOCAL):
app.include_router(api_router) app.include_router(api_router)
if os.path.exists("/app/static"):
app.frontend("/ui", directory="/app/static", fallback="index.html")

View file

@ -5,8 +5,8 @@ Global database models
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from sqlalchemy import DateTime, JSON from sqlalchemy import DateTime, JSON, func
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class CustomBase(DeclarativeBase): class CustomBase(DeclarativeBase):
@ -14,3 +14,24 @@ class CustomBase(DeclarativeBase):
datetime: DateTime(timezone=True), datetime: DateTime(timezone=True),
dict[str, Any]: JSON, dict[str, Any]: JSON,
} }
class ActivatedMixin:
active: Mapped[bool] = mapped_column(default=True)
class DeletedTimestampMixin:
deleted_at: Mapped[datetime | None] = mapped_column(nullable=True)
class DescriptionMixin:
description: Mapped[str]
class IdMixin:
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())

View file

@ -14,18 +14,19 @@ Models:
- OrgUsers: org_id[FK][PK], user_id[FK][PK] - OrgUsers: org_id[FK][PK], user_id[FK][PK]
""" """
from src.models import IdMixin, DeletedTimestampMixin
from typing import Any from typing import Any
from sqlalchemy import ForeignKey from sqlalchemy import ForeignKey
from sqlalchemy.orm import relationship, Mapped, mapped_column from sqlalchemy.orm import relationship, Mapped, mapped_column
from src.models import CustomBase from src.models import CustomBase, TimestampMixin
class Organisation(CustomBase): class Organisation(CustomBase, IdMixin, TimestampMixin, DeletedTimestampMixin):
__tablename__ = "organisation" __tablename__ = "organisation"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] name: Mapped[str]
status: Mapped[str] = mapped_column(default="partial") status: Mapped[str] = mapped_column(default="partial")
intake_questionnaire: Mapped[dict[str, Any] | None] intake_questionnaire: Mapped[dict[str, Any] | None]

View file

@ -97,9 +97,7 @@ router = APIRouter(
}, },
}, },
) )
async def get_org_by_id( async def get_org_by_id(db: DbSession, org_model: org_model_root_claim_query_dependency):
db: DbSession, org_model: org_model_root_claim_query_dependency
):
""" """
Returns organisation details including key member email addresses Returns organisation details including key member email addresses
""" """
@ -387,7 +385,8 @@ async def delete_organisation_by_id(
""" """
Removes an organisation from the hub. Removes an organisation from the hub.
""" """
db.delete(org_model) org_model.status = "removed"
org_model.deleted_at = datetime.now(tz=timezone.utc)
db.commit() db.commit()

View file

@ -16,9 +16,7 @@ from src.service.models import Service
from src.service.schemas import ServiceIDMixin from src.service.schemas import ServiceIDMixin
async def get_service_model_query( async def get_service_model_query(db: DbSession, service_id: Annotated[int, Query(gt=0)]):
db: DbSession, service_id: Annotated[int, Query(gt=0)]
):
service_model = db.get(Service, service_id) service_model = db.get(Service, service_id)
if service_model is None: if service_model is None:
raise ServiceNotFoundException(service_id=service_id) raise ServiceNotFoundException(service_id=service_id)

View file

@ -8,13 +8,12 @@ Models:
from sqlalchemy.orm import relationship, mapped_column, Mapped from sqlalchemy.orm import relationship, mapped_column, Mapped
from src.models import CustomBase from src.models import CustomBase, IdMixin
class Service(CustomBase): class Service(CustomBase, IdMixin):
__tablename__ = "service" __tablename__ = "service"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(unique=True) name: Mapped[str] = mapped_column(unique=True)
api_key: Mapped[str] api_key: Mapped[str]

View file

@ -76,9 +76,7 @@ router = APIRouter(
}, },
}, },
) )
async def get_all_services( async def get_all_services(db: DbSession, org_model: org_model_root_claim_query_dependency):
db: DbSession, org_model: org_model_root_claim_query_dependency
):
""" """
Returns the ID and name of all services registered to the hub. Returns the ID and name of all services registered to the hub.
""" """

View file

@ -10,12 +10,13 @@ Exports:
from typing import Annotated from typing import Annotated
from fastapi import Depends, Query from fastapi import Depends, Query
from src.user.exceptions import UserNotFoundException
from src.user.models import User
from src.auth.service import claims_dependency from src.auth.service import claims_dependency
from src.database import DbSession from src.database import DbSession
from src.schemas import UserIDMixin from src.schemas import UserIDMixin
from src.exceptions import ForbiddenException
from src.user.exceptions import UserNotFoundException
from src.user.models import User
async def get_user_model_claims(claims: claims_dependency, db: DbSession): async def get_user_model_claims(claims: claims_dependency, db: DbSession):
@ -27,6 +28,9 @@ async def get_user_model_claims(claims: claims_dependency, db: DbSession):
if user_model is None: if user_model is None:
raise UserNotFoundException(user_id=user_id) raise UserNotFoundException(user_id=user_id)
if not user_model.active:
raise ForbiddenException("User account is not active")
return user_model return user_model

View file

@ -10,6 +10,8 @@ Models:
- groups: Calc property dict of {group_rel.org_rel.name: group_rel.name} - groups: Calc property dict of {group_rel.org_rel.name: group_rel.name}
""" """
from src.models import IdMixin, ActivatedMixin, TimestampMixin, DeletedTimestampMixin
from collections import defaultdict from collections import defaultdict
from sqlalchemy.orm import relationship, mapped_column, Mapped from sqlalchemy.orm import relationship, mapped_column, Mapped
@ -17,10 +19,9 @@ from sqlalchemy.orm import relationship, mapped_column, Mapped
from src.models import CustomBase from src.models import CustomBase
class User(CustomBase): class User(CustomBase, IdMixin, ActivatedMixin, TimestampMixin, DeletedTimestampMixin):
__tablename__ = "user" __tablename__ = "user"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] email: Mapped[str]
first_name: Mapped[str] first_name: Mapped[str]
last_name: Mapped[str] last_name: Mapped[str]

View file

@ -8,6 +8,8 @@ Endpoints:
- [DELETE](/user/): [super admin]: Removes a User(id) from the hub database. - [DELETE](/user/): [super admin]: Removes a User(id) from the hub database.
""" """
from datetime import datetime, timezone
from fastapi import APIRouter, status, BackgroundTasks from fastapi import APIRouter, status, BackgroundTasks
from src.iam.models import Group from src.iam.models import Group
@ -104,7 +106,7 @@ async def get_user_by_id(
status.HTTP_404_NOT_FOUND: {"description": "User not found"}, status.HTTP_404_NOT_FOUND: {"description": "User not found"},
}, },
) )
async def delete_user_by_id( async def soft_delete_user_by_id(
db: DbSession, db: DbSession,
user_model: user_model_query_dependency, user_model: user_model_query_dependency,
su: super_admin_dependency, su: super_admin_dependency,
@ -112,7 +114,8 @@ async def delete_user_by_id(
""" """
Deletes the user with the provided ID from the database. This will not remove them from OIDC, and they will be automatically readded on next login. Deletes the user with the provided ID from the database. This will not remove them from OIDC, and they will be automatically readded on next login.
""" """
db.delete(user_model) user_model.active = False
user_model.deleted_at = datetime.now(tz=timezone.utc)
db.commit() db.commit()

View file

@ -1,13 +1,11 @@
""" """
Module specific business logic for user module Module specific business logic for user module
Exports:
- add_user_to_db: Creates a User record from OIDC claims, or updates user details
""" """
from typing import Any from typing import Any
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import logging
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.exceptions import UnprocessableContentException from src.exceptions import UnprocessableContentException
@ -17,7 +15,7 @@ from src.user.schemas import OIDCUser
from src.user.models import User from src.user.models import User
async def add_user_to_db(db: Session, user_claims: dict[str, Any]) -> int: async def add_user(db: Session, user_claims: dict[str, Any]) -> int:
try: try:
valid_user = OIDCUser( valid_user = OIDCUser(
first_name=user_claims["given_name"], first_name=user_claims["given_name"],
@ -26,7 +24,7 @@ async def add_user_to_db(db: Session, user_claims: dict[str, Any]) -> int:
oidc_id=user_claims["sub"], oidc_id=user_claims["sub"],
) )
except Exception as e: except Exception as e:
print(e) logging.exception(e)
raise UnprocessableContentException("Invalid or missing OIDC data") raise UnprocessableContentException("Invalid or missing OIDC data")
db_user = db.query(User).filter(User.oidc_id == valid_user.oidc_id).first() db_user = db.query(User).filter(User.oidc_id == valid_user.oidc_id).first()
@ -37,17 +35,10 @@ async def add_user_to_db(db: Session, user_claims: dict[str, Any]) -> int:
user_id = user_model.id user_id = user_model.id
db.commit() db.commit()
return user_id return user_id
else:
user_id = db_user.id user_id = db_user.id
change = False
if db_user.first_name != valid_user.first_name:
db_user.first_name = valid_user.first_name db_user.first_name = valid_user.first_name
change = True
if db_user.last_name != valid_user.last_name:
db_user.last_name = valid_user.last_name db_user.last_name = valid_user.last_name
change = True
if change:
db.add(db_user)
db.commit() db.commit()
return user_id return user_id

View file

@ -1,3 +1,5 @@
import logging
from lettermint import Lettermint, ValidationError from lettermint import Lettermint, ValidationError
from datetime import datetime, timezone from datetime import datetime, timezone
from joserfc import jwt, jwk, errors from joserfc import jwt, jwk, errors
@ -39,9 +41,12 @@ async def verify_email_token(user_model, token):
async def send_email(recipient: str, subject: str, body: str): async def send_email(recipient: str, subject: str, body: str):
if settings.ENVIRONMENT.is_testing:
return
lettermint = Lettermint(api_token=settings.LETTERMINT_API_TOKEN.get_secret_value()) lettermint = Lettermint(api_token=settings.LETTERMINT_API_TOKEN.get_secret_value())
if settings.ENVIRONMENT.is_testing or settings.ENVIRONMENT == "local": if settings.ENVIRONMENT == "local":
recipient = "ok@testing.lettermint.co" recipient = "ok@testing.lettermint.co"
try: try:
@ -52,8 +57,10 @@ async def send_email(recipient: str, subject: str, body: str):
.text(body) .text(body)
.send() .send()
) )
logging.info(
print(response.status_code) "Email sent to {} with subject {} (Status: {})".format(
except ValidationError: recipient, subject, response.status_code
# Error thrown if domain not approved for project )
print("Lettermint validation error") )
except ValidationError as e:
logging.exception(e)

View file

@ -1,8 +1,9 @@
from fastapi.dependencies.models import Dependant
import pytest import pytest
from typing import AsyncGenerator from typing import AsyncGenerator
from itertools import combinations from itertools import combinations
from fastapi.routing import APIRoute from fastapi.routing import APIRoute, iter_route_contexts
from httpx import AsyncClient, ASGITransport from httpx import AsyncClient, ASGITransport
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
@ -14,7 +15,7 @@ from src.iam.models import Group, Permission, OrgPermissions
from src.auth.service import get_current_user, get_dev_user from src.auth.service import get_current_user, get_dev_user
from src.auth.dependencies import empty_su_list, get_super_admin_list, testing_su_list from src.auth.dependencies import empty_su_list, get_super_admin_list, testing_su_list
from src.main import app # inited FastAPI app from src.main import app # inited FastAPI app
from src.database import engine, get_db from src.database import engine, get_db_session
from src.models import CustomBase from src.models import CustomBase
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@ -38,7 +39,7 @@ async def default_client(db_session) -> AsyncGenerator[AsyncClient, None]:
def get_db_override(): def get_db_override():
return db_session return db_session
app.dependency_overrides[get_db] = get_db_override app.dependency_overrides[get_db_session] = get_db_override
app.dependency_overrides[get_current_user] = get_dev_user app.dependency_overrides[get_current_user] = get_dev_user
app.dependency_overrides[get_super_admin_list] = testing_su_list app.dependency_overrides[get_super_admin_list] = testing_su_list
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
@ -55,7 +56,7 @@ async def no_user_client(db_session) -> AsyncGenerator[AsyncClient, None]:
def get_db_override(): def get_db_override():
return db_session return db_session
app.dependency_overrides[get_db] = get_db_override app.dependency_overrides[get_db_session] = get_db_override
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient( async with AsyncClient(
transport=transport, base_url="http://localhost:8000/api/v1" transport=transport, base_url="http://localhost:8000/api/v1"
@ -70,7 +71,7 @@ async def no_su_client(db_session) -> AsyncGenerator[AsyncClient, None]:
def get_db_override(): def get_db_override():
return db_session return db_session
app.dependency_overrides[get_db] = get_db_override app.dependency_overrides[get_db_session] = get_db_override
app.dependency_overrides[get_current_user] = get_dev_user app.dependency_overrides[get_current_user] = get_dev_user
app.dependency_overrides[get_super_admin_list] = empty_su_list app.dependency_overrides[get_super_admin_list] = empty_su_list
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
@ -258,9 +259,39 @@ def generate_body_and_status(params: dict[str, str]) -> list[tuple[dict, int]]:
def get_testable_routes(): def get_testable_routes():
routes = [] routes = []
for route in app.routes: contexts = list(iter_route_contexts(app.routes))
if not isinstance(route, APIRoute):
for route in contexts:
if not route.methods:
continue continue
if not isinstance(route.route, APIRoute):
continue
dep_func_names = set()
unchecked = []
unchecked.append(route.route.dependant)
while unchecked:
dependant = unchecked.pop(0)
ck = dependant.cache_key[0]
if hasattr(ck, "__name__"):
dep_func_names.add(ck.__name__)
unchecked += [
dep for dep in dependant.dependencies if isinstance(dep, Dependant)
]
auth_level = None
if "get_current_user" in dep_func_names:
auth_level = "User"
if (
"org_body_root_claims" in dep_func_names
or "org_query_root_claims" in dep_func_names
):
auth_level = "Root User"
if "user_model_super_admin" in dep_func_names:
auth_level = "Super Admin"
if "valid_service_key" in dep_func_names:
auth_level = "API Key"
for method in route.methods: for method in route.methods:
if method in {"HEAD", "OPTIONS"}: if method in {"HEAD", "OPTIONS"}:
@ -269,22 +300,18 @@ def get_testable_routes():
routes.append( routes.append(
( (
method, method,
route.path, route.route.path,
route.status_code, route.route.status_code,
route.response_model, route.route.response_model,
route.summary, route.route.summary,
auth_level,
) )
) )
return routes return routes
# with open("endpoints.txt", "w") as f:
# for ep in get_testable_routes():
# f.write(f"[{ep[0]}]({ep[1]}) -> {ep[2]}: {ep[3]}\n")
#
#
### Docstring formatted output ### ### Docstring formatted output ###
# with open("endpoints.txt", "w") as f: with open("endpoints.txt", "w") as f:
# for ep in get_testable_routes(): for ep in get_testable_routes():
# f.write(f"- [{ep[0]}]({ep[1]}): []: {ep[4]}\n") f.write(f"- [{ep[0]}]({ep[1]}): [{ep[5]}]: {ep[4]}\n")

View file

@ -5,7 +5,7 @@
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from fastapi.routing import APIRoute from fastapi.routing import APIRoute, iter_route_contexts
from .conftest import generate_query_and_status from .conftest import generate_query_and_status
@ -180,10 +180,15 @@ async def test_get_self_orgs_dynamic(default_client: AsyncClient):
resp = await default_client.get(path) resp = await default_client.get(path)
contexts = list(iter_route_contexts(default_client._transport.app.routes)) # ty:ignore[unresolved-attribute]
route = next( route = next(
route route.route
for route in default_client._transport.app.routes # ty:ignore[unresolved-attribute] for route in contexts
if isinstance(route, APIRoute) and path in route.path and method in route.methods if isinstance(route.route, APIRoute)
and path in route.route.path
and isinstance(route.methods, set)
and method in route.methods
) )
assert resp.status_code == route.status_code assert resp.status_code == route.status_code

11
uv.lock generated
View file

@ -6,6 +6,9 @@ requires-python = ">=3.12"
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
exclude-newer-span = "P2W" exclude-newer-span = "P2W"
[options.exclude-newer-package]
fastapi = "2026-06-22T00:00:00Z"
[[package]] [[package]]
name = "alembic" name = "alembic"
version = "1.18.4" version = "1.18.4"
@ -238,7 +241,7 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "alembic", specifier = ">=1.18.4" }, { name = "alembic", specifier = ">=1.18.4" },
{ name = "email-validator", specifier = ">=2.3.0" }, { name = "email-validator", specifier = ">=2.3.0" },
{ name = "fastapi", specifier = ">=0.136.3" }, { name = "fastapi", specifier = ">=0.138.0" },
{ name = "httptools", specifier = ">=0.7.1" }, { name = "httptools", specifier = ">=0.7.1" },
{ name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", specifier = ">=0.28.1" },
{ name = "itsdangerous", specifier = ">=2.2.0" }, { name = "itsdangerous", specifier = ">=2.2.0" },
@ -349,7 +352,7 @@ wheels = [
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.136.3" version = "0.138.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "annotated-doc" }, { name = "annotated-doc" },
@ -358,9 +361,9 @@ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "typing-inspection" }, { name = "typing-inspection" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } sdist = { url = "https://files.pythonhosted.org/packages/5b/58/ff455d9fe47c60abadb34b9e05a304b1f05f5ab8000ac01565156b6f5e43/fastapi-0.138.0.tar.gz", hash = "sha256:d445a4877636ad191e7053e08c9bf98cb921a6756776848400bb773d1740c061", size = 419240, upload-time = "2026-06-20T01:18:05.259Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, { url = "https://files.pythonhosted.org/packages/6c/ff/8496d9847a5fedae775eb49460722d3efaa80487854273e9647ae876218c/fastapi-0.138.0-py3-none-any.whl", hash = "sha256:b6f54fd1bd72c80b0f899f172c61a600f6f7af9b43d4d772a018f35624048cb0", size = 126779, upload-time = "2026-06-20T01:18:03.483Z" },
] ]
[[package]] [[package]]