Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/t 100 keycloak authentication #104

Open
wants to merge 24 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
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
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}

- name: Fix realm_config directory permission
run: sudo chown -R 1000:1000 dev/docker/realm_config

- name: Build and run containers
run: |
docker compose \
Expand All @@ -41,7 +44,6 @@ jobs:
pip install --upgrade pip
pip install uv
uv sync

- name: Run tests
run: |
uv run pytest -rs
Expand Down
4 changes: 3 additions & 1 deletion dev/docker/.env.dev
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ MINIO_PASSWORD=minio123
POSTGRES_USER=root
POSTGRES_PASSWORD=root
PGADMIN_USER=admin@admin.com
PGADMIN_PASSWORD=root
PGADMIN_PASSWORD=root
KEYCLOAK_USER=admin
KEYCLOAK_PASSWORD=admin
29 changes: 29 additions & 0 deletions dev/docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ services:
- ../../tests/mock_data:/code/data
- ../../data/index:/code/index
- ../../src:/code/src
depends_on:
keycloak:
condition: service_healthy
ports:
- '8081:5000'
entrypoint:
Expand All @@ -27,6 +30,7 @@ services:
environment:
- DATABASE_HOST=pg_container
- DATABASE_PORT=5432
- KEYCLOA_HOST=keycloak
networks:
- dbnetwork

Expand Down Expand Up @@ -66,6 +70,31 @@ services:
networks:
- dbnetwork

# Service for keycloak
# This container runs keycloak image for authentication of some api ends.
keycloak:
container_name: keycloak
image: quay.io/keycloak/keycloak:24.0
restart: always
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
KC_HOSTNAME: keycloak
KC_HEALTH_ENABLED: true
ports:
- "8080:8080"
volumes:
- ./realm_config:/opt/keycloak/data
command: start-dev
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/keycloak/8080 && echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: keycloak\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '200 OK'"]
interval: 10s
timeout: 30s
retries: 3
start_period: 50s
networks:
- dbnetwork

networks:
dbnetwork:
name: dbnetwork
Expand Down
6 changes: 6 additions & 0 deletions dev/docker/realm_config/h2/keycloakdb.lock.db
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#FileLock
#Tue Feb 18 10:19:22 GMT 2025
server=10.89.0.189\:42933
hostName=8bbed5f946f4
method=file
id=1951891c8d4cfa6dce2fbaec98f78b7a5ba9e45515c
Binary file added dev/docker/realm_config/h2/keycloakdb.mv.db
Binary file not shown.
223 changes: 223 additions & 0 deletions dev/docker/realm_config/h2/keycloakdb.trace.db

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ dependencies = [
"tqdm>=4.66.5",
"ujson>=5.10.0",
"uvicorn>=0.32.0",
"python-keycloak>=5.1.1"
]

[project.urls]
Expand Down
9 changes: 5 additions & 4 deletions src/api/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,10 @@ def create_cpf_summary(self, data_path: Path):
df["context"] = [Json(base_context)] * len(df)
df = df.drop_duplicates(subset=["name"])
df["name"] = df["name"].apply(
lambda x: models.ShotModel.__fields__.get("cpf_" + x.lower()).alias
if models.ShotModel.__fields__.get("cpf_" + x.lower())
else x
)
lambda x: models.ShotModel.__fields__.get("cpf_" + x.lower()).alias
if models.ShotModel.__fields__.get("cpf_" + x.lower())
else x
)
df.to_sql("cpf_summary", self.uri, if_exists="append")

def create_scenarios(self, data_path: Path):
Expand All @@ -147,6 +147,7 @@ def create_scenarios(self, data_path: Path):
data = pd.DataFrame(dict(id=ids, name=scenarios)).set_index("id")
data = data.dropna()
data["context"] = [Json(base_context)] * len(data)

data.to_sql("scenarios", self.uri, if_exists="append")

def create_shots(self, data_path: Path):
Expand Down
16 changes: 15 additions & 1 deletion src/api/environment.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
import os

host = os.environ.get("DATABASE_HOST", "localhost")

keycloak_host = os.environ.get("KEYCLOA_HOST", "localhost")
# Location of the database
DB_NAME = "mast_db"
SQLALCHEMY_DATABASE_URL = f"postgresql://root:root@{host}:5432/{DB_NAME}"
# Echo SQL statements
SQLALCHEMY_DEBUG = os.environ.get("SQLALCHEMY_DEBUG", False)

# Keycloak server url
SERVER_URL = f"http://{keycloak_host}:8080"
# Realm name within keycloak
REALM_NAME = "realm1"
# Client name withing keycloak
CLIENT_NAME = "fair-mast-test"
# Keycloak client secret key
CLIENT_SECRET = "w9pOmNyAnO6B3SlPcmYr10Nq7iwIn5ze"

TEST_USERNAME = "test"
TEST_PASSWORD = "test"

UNAUTHORIZED_USER = "test1"
119 changes: 119 additions & 0 deletions src/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,30 @@
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi_pagination import add_pagination
from fastapi_pagination.cursor import CursorPage
from fastapi_pagination.ext.sqlalchemy import paginate
from keycloak import KeycloakOpenID
from keycloak.exceptions import (
KeycloakAuthenticationError,
KeycloakAuthorizationConfigError,
)
from sqlalchemy.orm import Session
from strawberry.asgi import GraphQL
from strawberry.http import GraphQLHTTPResponse
from strawberry.types import ExecutionResult

from . import crud, graphql, models
from .database import get_db
from .environment import (
CLIENT_NAME,
CLIENT_SECRET,
REALM_NAME,
SERVER_URL,
)

templates = Jinja2Templates(directory="src/api/templates")

Expand Down Expand Up @@ -78,6 +90,38 @@ def fixup_context(d):
app.add_websocket_route("/graphql", graphql_app)
add_pagination(app)

keycloak_id = KeycloakOpenID(
server_url=SERVER_URL,
realm_name=REALM_NAME,
client_id=CLIENT_NAME,
client_secret_key=CLIENT_SECRET,
verify=True,
)
security = HTTPBasic()


def authenticate_user_by_role(credentials: HTTPBasicCredentials = Depends(security)):
try:
token = keycloak_id.token(
username=credentials.username,
password=credentials.password,
grant_type="password",
)

user_info = keycloak_id.userinfo(token=token["access_token"])
user_roles = (
user_info.get("resource_access", {}).get(CLIENT_NAME, {}).get("roles", {})
)
if "fair-mast-admins" not in user_roles:
raise KeycloakAuthorizationConfigError(
error_message="Forbidden user: Access not sufficient", response_code=403
)
return user_info
except KeycloakAuthenticationError as e:
raise KeycloakAuthenticationError(
error_message=f"Invalid username or password: {e}", response_code=401
)


@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
Expand Down Expand Up @@ -303,6 +347,21 @@ def get_shots(db: Session = Depends(get_db), params: QueryParams = Depends()):
return paginate(db, query)


@app.post("/json/shots", description="Post data to shot table")
def post_shots(
shot_data: list[dict],
db: Session = Depends(get_db),
_: HTTPBasicCredentials = Depends(authenticate_user_by_role),
):
try:
engine = db.get_bind()
df = pd.DataFrame(shot_data)
df.to_sql("shots", engine, if_exists="append", index=False)
return shot_data
except Exception as e:
raise HTTPException(status_code=400, detail=f"Error:{str(e)}")


@app.get("/json/shots/aggregate")
def get_shots_aggregate(
request: Request,
Expand Down Expand Up @@ -377,6 +436,21 @@ def get_signals(db: Session = Depends(get_db), params: QueryParams = Depends()):
return paginate(db, query)


@app.post("/json/signals", description="post data to signal table")
def post_signal(
signal_data: list[dict],
db: Session = Depends(get_db),
_: HTTPBasicCredentials = Depends(authenticate_user_by_role),
):
try:
engine = db.get_bind()
df = pd.DataFrame(signal_data)
df.to_sql("signals", engine, if_exists="append", index=False)
return signal_data
except Exception as e:
raise HTTPException(status_code=400, detail=f"Error:{str(e)}")


@app.get("/json/signals/aggregate")
def get_signals_aggregate(
request: Request,
Expand Down Expand Up @@ -435,6 +509,21 @@ def get_cpf_summary(db: Session = Depends(get_db), params: QueryParams = Depends
return paginate(db, query)


@app.post("/json/cpf_summary", description="post data to cpf summary table")
def post_cpf_summary(
cpf_data: list[dict],
db: Session = Depends(get_db),
_: HTTPBasicCredentials = Depends(authenticate_user_by_role),
):
try:
engine = db.get_bind()
df = pd.DataFrame(cpf_data)
df.to_sql("cpf_summary", engine, if_exists="append", index=False)
return cpf_data
except Exception as e:
raise (HTTPException(status_code=400, detail=f"Error:{str(e)}"))


@app.get(
"/json/scenarios",
description="Get information on different scenarios.",
Expand All @@ -453,6 +542,21 @@ def get_scenarios(
return paginate(db, query)


@app.post("/json/scenarios", description="post data to scenario table")
def post_scenarios(
scenario_data: list[dict],
db: Session = Depends(get_db),
_: HTTPBasicCredentials = Depends(authenticate_user_by_role),
):
try:
engine = db.get_bind()
df = pd.DataFrame(scenario_data)
df.to_sql("scenarios", engine, if_exists="append", index=False)
return scenario_data
except Exception as e:
raise (HTTPException(status_code=400, detail=f"Error:{str(e)}"))


@app.get(
"/json/sources",
description="Get information on different sources.",
Expand All @@ -469,6 +573,21 @@ def get_sources(db: Session = Depends(get_db), params: QueryParams = Depends()):
return paginate(db, query)


@app.post("/json/sources", description="Post Shot data into database")
def post_source(
source_data: list[dict],
db: Session = Depends(get_db),
_: HTTPBasicCredentials = Depends(authenticate_user_by_role),
):
try:
engine = db.get_bind()
df = pd.DataFrame(source_data)
df.to_sql("sources", engine, if_exists="append", index=False)
return source_data
except Exception as e:
raise (HTTPException(status_code=400, detail=f"Error:{str(e)}"))


@app.get(
"/json/sources/aggregate",
response_model=models.SourceModel,
Expand Down
7 changes: 7 additions & 0 deletions tests/api/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import pytest
from fastapi.testclient import TestClient
from requests.auth import HTTPBasicAuth
from sqlalchemy.orm import sessionmaker
from sqlalchemy_utils.functions import (
drop_database,
Expand All @@ -12,6 +13,7 @@

from src.api.create import DBCreationClient
from src.api.database import get_db
from src.api.environment import TEST_PASSWORD, TEST_USERNAME
from src.api.main import app, graphql_app

# Set up the database URL
Expand Down Expand Up @@ -39,6 +41,11 @@ def test_db(data_path):
drop_database(SQLALCHEMY_DATABASE_TEST_URL)


@pytest.fixture()
def test_auth():
return HTTPBasicAuth(username=TEST_USERNAME, password=TEST_PASSWORD)


class TestSQLAlchemySession(SchemaExtension):
def on_request_start(self):
engine = create_engine(SQLALCHEMY_DATABASE_TEST_URL)
Expand Down
Loading
Loading