Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/workflows/build-docker-proxy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Docker Build Proxy

on:
push:
branches:
- 'main'
pull_request:

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}-proxy

jobs:
build:
name: Build docker image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=pr
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}

- name: Build and push docker image
uses: docker/build-push-action@v6
with:
context: nginx
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
8 changes: 6 additions & 2 deletions backend/api/context.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
from typing import Any
from typing import TYPE_CHECKING, Any

import strawberry
from sqlalchemy.ext.asyncio import AsyncSession
from strawberry.fastapi import BaseContext

if TYPE_CHECKING:
from database.models.user import User


class Context(BaseContext):
def __init__(self, db: AsyncSession):
def __init__(self, db: AsyncSession, user: "User | None" = None):
super().__init__()
self.db = db
self.user = user


async def get_context(db: AsyncSession) -> Context:
Expand Down
102 changes: 102 additions & 0 deletions backend/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
from typing import Any

import requests
from config import CLIENT_ID, ISSUER_URI
from fastapi import HTTPException, status
from jose import jwk, jwt

jwks_cache: dict[str, Any] = {}
openid_config_cache: dict[str, Any] = {}


def get_openid_config() -> dict[str, Any]:
global openid_config_cache
if openid_config_cache:
return openid_config_cache

config_url = f"{ISSUER_URI.rstrip('/')}/.well-known/openid-configuration"
try:
response = requests.get(config_url, timeout=5)
response.raise_for_status()
config = response.json()
openid_config_cache = config
return config
except Exception as e:
print(f"OIDC Config Error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Could not fetch OpenID Configuration",
)


def get_public_key(token: str) -> Any:
global jwks_cache

try:
header = jwt.get_unverified_header(token)
kid = header.get("kid")

if not kid:
raise Exception("Token header missing kid")

if kid in jwks_cache:
return jwks_cache[kid]

# TODO use openid config endpoint to obtain well-known endpoints in the future
jwks_uri = f"{ISSUER_URI}/protocol/openid-connect/certs"

response = requests.get(jwks_uri, timeout=5)
response.raise_for_status()
jwks = response.json()

for key_data in jwks.get("keys", []):
if key_data.get("kid") == kid:
key = jwk.construct(key_data)
jwks_cache[kid] = key
return key

raise Exception("Public key not found in JWKS")

except Exception as e:
print(f"Auth Error: {e}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)


def verify_token(token: str) -> dict:
try:
public_key = get_public_key(token)

payload = jwt.decode(
token,
public_key,
algorithms=["RS256"],
options={"verify_aud": False},
)

azp = payload.get("azp")
aud = payload.get("aud")

if isinstance(aud, str):
aud = [aud]
elif aud is None:
aud = []

if azp and azp == CLIENT_ID:
return payload

if CLIENT_ID in aud:
return payload

raise Exception(
f"Invalid audience/azp. Expected {CLIENT_ID}, got azp={azp}, aud={aud}",
)
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token has expired")
except jwt.JWTError as e:
raise HTTPException(status_code=401, detail=f"Invalid token: {e!s}")
except Exception:
raise HTTPException(status_code=401, detail="Authentication failed")
8 changes: 8 additions & 0 deletions backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,11 @@
IS_DEV = ENV == "development"

LOGGER = os.getenv("LOGGER", "uvicorn")

ISSUER_URI = os.getenv("ISSUER_URI", "http://localhost:8080/realms/tasks")
PUBLIC_ISSUER_URI = os.getenv(
"PUBLIC_ISSUER_URI",
ISSUER_URI,
)
CLIENT_ID = os.getenv("CLIENT_ID", "tasks")
CLIENT_SECRET = os.getenv("CLIENT_SECRET", "tasks-secret")
2 changes: 1 addition & 1 deletion backend/database/migrations/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
# Importing 'Base' from 'models' triggers the import of all
# sub-models (User, Patient, etc.) via models/__init__.py,
# ensuring they are registered in Base.metadata for autogeneration.
from database.base import Base
from database.models.base import Base
from database.session import DATABASE_URL
from sqlalchemy import pool
from sqlalchemy.engine import Connection
Expand Down

This file was deleted.

111 changes: 111 additions & 0 deletions backend/database/migrations/versions/92aad7cc3186_initial_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""Initial models

Revision ID: 92aad7cc3186
Revises:
Create Date: 2025-12-01 02:36:26.250686

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = '92aad7cc3186'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('location_nodes',
sa.Column('id', sa.String(), nullable=False),
sa.Column('title', sa.String(), nullable=False),
sa.Column('kind', sa.String(), nullable=False),
sa.Column('parent_id', sa.String(), nullable=True),
sa.ForeignKeyConstraint(['parent_id'], ['location_nodes.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('property_definitions',
sa.Column('id', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('field_type', sa.String(), nullable=False),
sa.Column('options', sa.String(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('allowed_entities', sa.String(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('users',
sa.Column('id', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('firstname', sa.String(), nullable=True),
sa.Column('lastname', sa.String(), nullable=True),
sa.Column('title', sa.String(), nullable=True),
sa.Column('avatar_url', sa.String(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('patients',
sa.Column('id', sa.String(), nullable=False),
sa.Column('firstname', sa.String(), nullable=False),
sa.Column('lastname', sa.String(), nullable=False),
sa.Column('birthdate', sa.Date(), nullable=False),
sa.Column('gender', sa.String(), nullable=False),
sa.Column('assigned_location_id', sa.String(), nullable=True),
sa.ForeignKeyConstraint(['assigned_location_id'], ['location_nodes.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('tasks',
sa.Column('id', sa.String(), nullable=False),
sa.Column('title', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('done', sa.Boolean(), nullable=False),
sa.Column('creation_date', sa.DateTime(), nullable=False),
sa.Column('update_date', sa.DateTime(), nullable=True),
sa.Column('assignee_id', sa.String(), nullable=True),
sa.Column('patient_id', sa.String(), nullable=False),
sa.ForeignKeyConstraint(['assignee_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['patient_id'], ['patients.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('property_values',
sa.Column('id', sa.String(), nullable=False),
sa.Column('definition_id', sa.String(), nullable=False),
sa.Column('patient_id', sa.String(), nullable=True),
sa.Column('task_id', sa.String(), nullable=True),
sa.Column('text_value', sa.String(), nullable=True),
sa.Column('number_value', sa.Float(), nullable=True),
sa.Column('boolean_value', sa.Boolean(), nullable=True),
sa.Column('date_value', sa.Date(), nullable=True),
sa.Column('date_time_value', sa.DateTime(), nullable=True),
sa.Column('select_value', sa.String(), nullable=True),
sa.Column('multi_select_values', sa.String(), nullable=True),
sa.ForeignKeyConstraint(['definition_id'], ['property_definitions.id'], ),
sa.ForeignKeyConstraint(['patient_id'], ['patients.id'], ),
sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('task_dependencies',
sa.Column('previous_task_id', sa.String(), nullable=False),
sa.Column('next_task_id', sa.String(), nullable=False),
sa.ForeignKeyConstraint(['next_task_id'], ['tasks.id'], ),
sa.ForeignKeyConstraint(['previous_task_id'], ['tasks.id'], ),
sa.PrimaryKeyConstraint('previous_task_id', 'next_task_id')
)
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('task_dependencies')
op.drop_table('property_values')
op.drop_table('tasks')
op.drop_table('patients')
op.drop_table('users')
op.drop_table('property_definitions')
op.drop_table('location_nodes')
# ### end Alembic commands ###
File renamed without changes.
10 changes: 8 additions & 2 deletions backend/database/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,18 @@ class User(Base):
__tablename__ = "users"

id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: str(uuid.uuid4())
String,
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
name: Mapped[str] = mapped_column(String)
firstname: Mapped[str | None] = mapped_column(String, nullable=True)
lastname: Mapped[str | None] = mapped_column(String, nullable=True)
title: Mapped[str | None] = mapped_column(String, nullable=True)
avatar_url: Mapped[str | None] = mapped_column(String, nullable=True)
avatar_url: Mapped[str | None] = mapped_column(
String,
nullable=True,
default="https://cdn.helpwave.de/boringavatar.svg",
)

tasks: Mapped[list[Task]] = relationship("Task", back_populates="assignee")
2 changes: 1 addition & 1 deletion backend/database/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import redis.asyncio as redis
from config import DATABASE_URL, REDIS_URL
from database.base import Base
from database.models.base import Base
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
Expand Down
Loading
Loading