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
62 changes: 62 additions & 0 deletions .github/workflows/build-release-images.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: "Build Release Images"

on:
workflow_dispatch:
push:
branches:
- main
- hotfix/**

jobs:
build-and-push:
runs-on: ubuntu-latest
environment: production
strategy:
fail-fast: false
matrix:
include:
- name: api
context: api
image: securelearning-api
target: prod
- name: web
context: web
image: securelearning-web
target: prod
- name: smtp
context: smtp
image: securelearning-smtp

steps:
- uses: actions/checkout@v4

- uses: docker/setup-buildx-action@v3

- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Compute tags
id: tags
run: |
set -euo pipefail
IMAGE="${{ vars.DOCKERHUB_USERNAME }}/${{ matrix.image }}"
SHA_TAG="$(git rev-parse --short HEAD)"
{
echo "image=$IMAGE"
echo "sha_tag=$SHA_TAG"
} >> "$GITHUB_OUTPUT"

- name: Build and push ${{ matrix.name }}
uses: docker/build-push-action@v6
with:
context: ${{ matrix.context }}
target: ${{ matrix.target || '' }}
push: true
tags: |
${{ steps.tags.outputs.image }}:latest
${{ steps.tags.outputs.image }}:${{ steps.tags.outputs.sha_tag }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: ${{ matrix.name == 'web' && format('VITE_API_URL={0}\nVITE_KEYCLOAK_URL={1}\nVITE_BASE_PATH={2}\nVITE_WEB_URL={3}', vars.API_URL, vars.KEYCLOAK_URL, vars.VITE_BASE_PATH, vars.WEB_URL) || '' }}
109 changes: 106 additions & 3 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ on:

jobs:
deploy:
runs-on: [self-hosted, linux, x64]
runs-on: [self-hosted, Linux, x64]
environment: ${{ inputs.environment || 'production' }}

steps:
Expand Down Expand Up @@ -55,10 +55,113 @@ jobs:
--days 3650


- name: Write deployment .env
env:
POSTGRES_USER: ${{ vars.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_DB: ${{ vars.POSTGRES_DB }}
MONGO_ROOT_USER: ${{ vars.MONGO_ROOT_USER }}
MONGO_ROOT_PASSWORD: ${{ secrets.MONGO_ROOT_PASSWORD }}
MONGO_DB: ${{ vars.MONGO_DB }}
MONGO_USER: ${{ vars.MONGO_USER }}
MONGO_PASSWORD: ${{ secrets.MONGO_PASSWORD }}
GARAGE_RPC_SECRET: ${{ secrets.GARAGE_RPC_SECRET }}
GARAGE_ACCESS_KEY_ID: ${{ secrets.GARAGE_ACCESS_KEY_ID }}
GARAGE_SECRET_ACCESS_KEY: ${{ secrets.GARAGE_SECRET_ACCESS_KEY }}
RABBITMQ_DEFAULT_USER: ${{ vars.RABBITMQ_DEFAULT_USER }}
RABBITMQ_DEFAULT_PASS: ${{ secrets.RABBITMQ_DEFAULT_PASS }}
RABBITMQ_API_USER: ${{ vars.RABBITMQ_API_USER }}
RABBITMQ_API_PASS: ${{ secrets.RABBITMQ_API_PASS }}
RABBITMQ_SMTP_USER: ${{ vars.RABBITMQ_SMTP_USER }}
RABBITMQ_SMTP_PASS: ${{ secrets.RABBITMQ_SMTP_PASS }}
KEYCLOAK_ADMIN: ${{ vars.KEYCLOAK_ADMIN }}
KEYCLOAK_ADMIN_PASSWORD: ${{ secrets.KEYCLOAK_ADMIN_PASSWORD }}
CLIENT_SECRET: ${{ secrets.CLIENT_SECRET }}
GRAFANA_ADMIN_PASSWORD: ${{ secrets.GRAFANA_ADMIN_PASSWORD }}
RABBITMQ_QUEUE: ${{ vars.RABBITMQ_QUEUE }}
FILE_STORAGE_BACKEND: ${{ vars.FILE_STORAGE_BACKEND }}
GARAGE_S3_ENDPOINT: ${{ vars.GARAGE_S3_ENDPOINT }}
GARAGE_S3_PUBLIC_ENDPOINT: ${{ vars.GARAGE_S3_PUBLIC_ENDPOINT }}
GARAGE_S3_REGION: ${{ vars.GARAGE_S3_REGION }}
GARAGE_FORCE_PATH_STYLE: ${{ vars.GARAGE_FORCE_PATH_STYLE }}
GARAGE_BUCKET_CONTENT: ${{ vars.GARAGE_BUCKET_CONTENT }}
GARAGE_BUCKET_LOGOS: ${{ vars.GARAGE_BUCKET_LOGOS }}
GARAGE_CONTENT_PREFIX: ${{ vars.GARAGE_CONTENT_PREFIX }}
GARAGE_LOGOS_PREFIX: ${{ vars.GARAGE_LOGOS_PREFIX }}
PUBLIC_BASE_URL: ${{ vars.PUBLIC_BASE_URL }}
KC_HTTP_RELATIVE_PATH: ${{ vars.KC_HTTP_RELATIVE_PATH }}
VITE_BASE_PATH: ${{ vars.VITE_BASE_PATH }}
KC_HOSTNAME: ${{ vars.KC_HOSTNAME }}
KC_HOSTNAME_URL: ${{ vars.KC_HOSTNAME_URL }}
KEYCLOAK_INTERNAL_URL: ${{ vars.KEYCLOAK_INTERNAL_URL }}
KEYCLOAK_URL: ${{ vars.KEYCLOAK_URL }}
API_INTERNAL_URL: ${{ vars.API_INTERNAL_URL }}
API_URL: ${{ vars.API_URL }}
WEB_URL: ${{ vars.WEB_URL }}
KEYCLOAK_ISSUER_URL: ${{ vars.KEYCLOAK_ISSUER_URL }}
NGINX_PORT: ${{ vars.NGINX_PORT }}
SERVER_PORT: ${{ vars.SERVER_PORT }}
TLS_CERT_FILE: ${{ vars.TLS_CERT_FILE }}
TLS_KEY_FILE: ${{ vars.TLS_KEY_FILE }}
DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME }}
GRAFANA_ADMIN_USER: ${{ vars.GRAFANA_ADMIN_USER }}
run: |
set -euo pipefail
{
printf 'POSTGRES_USER=%s\n' "$POSTGRES_USER"
printf 'POSTGRES_PASSWORD=%s\n' "$POSTGRES_PASSWORD"
printf 'POSTGRES_DB=%s\n' "$POSTGRES_DB"
printf 'MONGO_ROOT_USER=%s\n' "$MONGO_ROOT_USER"
printf 'MONGO_ROOT_PASSWORD=%s\n' "$MONGO_ROOT_PASSWORD"
printf 'MONGO_DB=%s\n' "$MONGO_DB"
printf 'MONGO_USER=%s\n' "$MONGO_USER"
printf 'MONGO_PASSWORD=%s\n' "$MONGO_PASSWORD"
printf 'GARAGE_RPC_SECRET=%s\n' "$GARAGE_RPC_SECRET"
printf 'GARAGE_ACCESS_KEY_ID=%s\n' "$GARAGE_ACCESS_KEY_ID"
printf 'GARAGE_SECRET_ACCESS_KEY=%s\n' "$GARAGE_SECRET_ACCESS_KEY"
printf 'FILE_STORAGE_BACKEND=%s\n' "$FILE_STORAGE_BACKEND"
printf 'GARAGE_S3_ENDPOINT=%s\n' "$GARAGE_S3_ENDPOINT"
printf 'GARAGE_S3_PUBLIC_ENDPOINT=%s\n' "$GARAGE_S3_PUBLIC_ENDPOINT"
printf 'GARAGE_S3_REGION=%s\n' "$GARAGE_S3_REGION"
printf 'GARAGE_FORCE_PATH_STYLE=%s\n' "$GARAGE_FORCE_PATH_STYLE"
printf 'GARAGE_BUCKET_CONTENT=%s\n' "$GARAGE_BUCKET_CONTENT"
printf 'GARAGE_BUCKET_LOGOS=%s\n' "$GARAGE_BUCKET_LOGOS"
printf 'GARAGE_CONTENT_PREFIX=%s\n' "$GARAGE_CONTENT_PREFIX"
printf 'GARAGE_LOGOS_PREFIX=%s\n' "$GARAGE_LOGOS_PREFIX"
printf 'RABBITMQ_DEFAULT_USER=%s\n' "$RABBITMQ_DEFAULT_USER"
printf 'RABBITMQ_DEFAULT_PASS=%s\n' "$RABBITMQ_DEFAULT_PASS"
printf 'RABBITMQ_QUEUE=%s\n' "$RABBITMQ_QUEUE"
printf 'RABBITMQ_API_USER=%s\n' "$RABBITMQ_API_USER"
printf 'RABBITMQ_API_PASS=%s\n' "$RABBITMQ_API_PASS"
printf 'RABBITMQ_SMTP_USER=%s\n' "$RABBITMQ_SMTP_USER"
printf 'RABBITMQ_SMTP_PASS=%s\n' "$RABBITMQ_SMTP_PASS"
printf 'KEYCLOAK_ADMIN=%s\n' "$KEYCLOAK_ADMIN"
printf 'KEYCLOAK_ADMIN_PASSWORD=%s\n' "$KEYCLOAK_ADMIN_PASSWORD"
printf 'CLIENT_SECRET=%s\n' "$CLIENT_SECRET"
printf 'KC_HOSTNAME=%s\n' "$KC_HOSTNAME"
printf 'KC_HOSTNAME_URL=%s\n' "$KC_HOSTNAME_URL"
printf 'KC_HTTP_RELATIVE_PATH=%s\n' "$KC_HTTP_RELATIVE_PATH"
printf 'KEYCLOAK_INTERNAL_URL=%s\n' "$KEYCLOAK_INTERNAL_URL"
printf 'KEYCLOAK_URL=%s\n' "$KEYCLOAK_URL"
printf 'API_INTERNAL_URL=%s\n' "$API_INTERNAL_URL"
printf 'PUBLIC_BASE_URL=%s\n' "$PUBLIC_BASE_URL"
printf 'VITE_BASE_PATH=%s\n' "$VITE_BASE_PATH"
printf 'API_URL=%s\n' "$API_URL"
printf 'WEB_URL=%s\n' "$WEB_URL"
printf 'KEYCLOAK_ISSUER_URL=%s\n' "$KEYCLOAK_ISSUER_URL"
printf 'NGINX_PORT=%s\n' "$NGINX_PORT"
printf 'SERVER_PORT=%s\n' "$SERVER_PORT"
printf 'TLS_CERT_FILE=%s\n' "$TLS_CERT_FILE"
printf 'TLS_KEY_FILE=%s\n' "$TLS_KEY_FILE"
printf 'DOCKERHUB_USERNAME=%s\n' "$DOCKERHUB_USERNAME"
printf 'GRAFANA_ADMIN_USER=%s\n' "$GRAFANA_ADMIN_USER"
printf 'GRAFANA_ADMIN_PASSWORD=%s\n' "$GRAFANA_ADMIN_PASSWORD"
printf 'NGINX_CONF_PATH=%s\n' "${{ inputs.nginx_config || 'nginx.mednat.conf' }}"
} > deployment/.env

- name: Rebuild and redeploy stack
env:
DOCKER_BUILDKIT: 1
NGINX_CONF_PATH: ${{ inputs.nginx_config || 'nginx.mednat.conf' }}
run: |
set -euo pipefail
COMPOSE_FILES="-f deployment/docker-compose.yml"
Expand All @@ -67,4 +170,4 @@ jobs:
fi
docker compose $COMPOSE_FILES pull
docker compose $COMPOSE_FILES down --remove-orphans
docker compose $COMPOSE_FILES up -d --remove-orphans
docker compose $COMPOSE_FILES up -d --remove-orphans
9 changes: 5 additions & 4 deletions .github/workflows/ci-web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ jobs:
build-and-push:
needs: check
runs-on: ubuntu-latest
environment: production
if: github.event_name == 'push'

steps:
Expand All @@ -97,7 +98,7 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
VITE_API_URL=${{ secrets.VITE_API_URL }}
VITE_KEYCLOAK_URL=${{ secrets.VITE_KEYCLOAK_URL }}
VITE_BASE_PATH=${{ secrets.VITE_BASE_PATH }}
VITE_WEB_URL=${{ secrets.VITE_WEB_URL }}
VITE_API_URL=${{ vars.VITE_API_URL }}
VITE_KEYCLOAK_URL=${{ vars.VITE_KEYCLOAK_URL }}
VITE_BASE_PATH=${{ vars.VITE_BASE_PATH }}
VITE_WEB_URL=${{ vars.VITE_WEB_URL }}
2 changes: 1 addition & 1 deletion api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ COPY pyproject.toml uv.lock /app/
WORKDIR /app

# Install dependencies (frozen, no-cache for prod optimization)
RUN uv sync --frozen --no-cache
RUN uv sync --no-cache

# Development target: hot reload with source mount
FROM base AS dev
Expand Down
10 changes: 9 additions & 1 deletion api/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,18 @@
phishing_kit,
courses,
progress,
certificates,
)
from src.core.db import init_db
from src.core.mongo import close_mongo_client
from src.core.object_storage import ensure_bucket, garage_enabled
from src.core.settings import settings
from src.tasks import start_scheduler, shutdown_scheduler
from src.tasks.tracking_consumer import start_tracking_consumer, shutdown_tracking_consumer
from src.tasks.tracking_consumer import (
start_tracking_consumer,
shutdown_tracking_consumer,
)


@asynccontextmanager
async def lifespan(app: FastAPI):
Expand All @@ -45,6 +50,7 @@ async def lifespan(app: FastAPI):
shutdown_tracking_consumer()
await close_mongo_client()


app = FastAPI(
title="Project Template API",
summary="API to serve Project Template.",
Expand All @@ -68,6 +74,7 @@ async def lifespan(app: FastAPI):
allow_headers=["*"],
)


@app.get(
"/health",
tags=["Health"],
Expand All @@ -90,3 +97,4 @@ async def health_check():
app.include_router(phishing_kit.router, prefix="/api", tags=["phishing-kits"])
app.include_router(courses.router, prefix="/api", tags=["courses"])
app.include_router(progress.router, prefix="/api", tags=["progress"])
app.include_router(certificates.router, prefix="/api", tags=["certificates"])
3 changes: 2 additions & 1 deletion api/src/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@
AcceptRequest,
ComplianceStatusResponse,
)
from .user_progress import UserProgress, AssignmentStatus
from .user_progress import UserProgress, AssignmentStatus, CertificateDTO
from .org_manager import (
OrgUserCreate,
OrgGroupCreate,
Expand Down Expand Up @@ -216,4 +216,5 @@
"PaginatedCourses",
"UserProgress",
"AssignmentStatus",
"CertificateDTO",
]
3 changes: 2 additions & 1 deletion api/src/models/user_progress/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .table import UserProgress, AssignmentStatus
from .schemas import CertificateDTO

__all__ = ["UserProgress", "AssignmentStatus"]
__all__ = ["UserProgress", "AssignmentStatus", "CertificateDTO"]
15 changes: 15 additions & 0 deletions api/src/models/user_progress/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from typing import Optional
from sqlmodel import SQLModel


class CertificateDTO(SQLModel):
user_id: str
course_id: str
last_emission_date: str
expiration_date: str
expired: bool = False
course_name: Optional["str"]
course_cover_image_link: Optional["str"]
difficulty: Optional["str"]
category: Optional["str"]
realm: str
55 changes: 55 additions & 0 deletions api/src/routers/certificates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, Query
from src.core.dependencies import SessionDep, OAuth2Scheme, CurrentRealm
from src.core.security import Roles, Resource, Scope
from src.services.compliance.token_helpers import decode_token_verified
from src.services import progress as progress_service

router = APIRouter(prefix="/users", tags=["certificates"])


@router.get(
"/me/certificates",
responses={401: {"description": "Invalid token"}},
)
async def get_my_certificates(
session: SessionDep,
realm: CurrentRealm,
token: OAuth2Scheme,
include_expired: Annotated[bool, Query()] = False,
):
"""Get certificates for the authenticated user."""
claims = decode_token_verified(token)
user_id = claims.get("sub")

if not user_id:
raise HTTPException(status_code=401, detail="Invalid token")

return await progress_service.list_certificates(
user_id=user_id,
session=session,
realm_name=realm,
include_expired=include_expired,
)


@router.get(
"/{user_id}/certificates",
dependencies=[Depends(Roles(Resource.ORG_MANAGER, Scope.VIEW))],
responses={401: {"description": "Invalid token"}},
)
async def get_user_certificates(
user_id: str,
session: SessionDep,
realm: CurrentRealm,
token: OAuth2Scheme,
include_expired: Annotated[bool, Query()] = False,
):
"""Get certificates for a user (org manager only)."""
return await progress_service.list_certificates(
user_id=user_id,
session=session,
realm_name=realm,
include_expired=include_expired,
)
1 change: 1 addition & 0 deletions api/src/routers/org_manager/user_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ def enroll_user_endpoint(
start_date=payload.start_date,
deadline=payload.deadline,
cert_valid_days=payload.cert_valid_days,
realm_name=realm,
)


Expand Down
5 changes: 5 additions & 0 deletions api/src/services/org_manager/user_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ def enroll_user(
start_date=None,
deadline=None,
cert_valid_days=365,
realm_name: str | None = None,
) -> dict:
"""Assign courses to a user."""
from datetime import datetime, timedelta
Expand All @@ -315,6 +316,7 @@ def enroll_user(
deadline=deadline,
cert_valid_days=cert_valid_days,
status=AssignmentStatus.SCHEDULED,
realm_name=realm_name,
overdue=False,
expired=False,
progress_data={},
Expand All @@ -332,7 +334,10 @@ def enroll_user(
existing.deadline = deadline
existing.cert_valid_days = cert_valid_days
existing.overdue = False
existing.is_certified = False
existing.expired = False
existing.cert_expires_at = None
existing.realm_name = realm_name
if existing.status != AssignmentStatus.COMPLETED:
existing.progress_data = {}
existing.completed_sections = []
Expand Down
Loading
Loading