Skip to content

Feat/t 100 keycloak authentication #104

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

Open
wants to merge 60 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
8f738f6
feat: Add keycloak image to docker-compose
khalsz Jan 14, 2025
602bcc3
feat: Add keycloak to docker-compose file
khalsz Jan 14, 2025
eaaba96
feat: Add fastapi_keycloak to toml file
khalsz Jan 14, 2025
9f09d9d
feat: Add keycloak config into env file
khalsz Jan 14, 2025
8ae8725
feat: Add keycloak auth to post api end
khalsz Jan 14, 2025
c1c317d
feat: Add role to user
khalsz Jan 14, 2025
130b123
fix: format files
khalsz Jan 14, 2025
23be164
fix: make mast-api depends on keycloak
khalsz Jan 15, 2025
36384a3
Add keycloak config/authentication
khalsz Jan 20, 2025
fdf3775
Add test config to enviroment file
khalsz Jan 23, 2025
499ddb7
Add: test for all post request with auth
khalsz Jan 23, 2025
91e9676
fix: change keycloak port
khalsz Jan 23, 2025
a50c434
--amend
khalsz Jan 24, 2025
1cdc892
Add: Keycloak config file
khalsz Jan 24, 2025
2dd8689
fix: keycloak health
khalsz Jan 24, 2025
c82f208
fix: Change keycloak volume path
khalsz Jan 27, 2025
62c2319
--amend
khalsz Jan 30, 2025
978af35
Merge branch 'main' of github.com:ukaea/fair-mast into feat/T-100-key…
khalsz Feb 5, 2025
c260a17
fix: Switche data insertion method from ORM-based to df.to_sql
khalsz Feb 7, 2025
c6fab43
fix: keycloak container timeout limit
khalsz Feb 7, 2025
cb08c75
add pytest fixture for test HTTP auth
khalsz Feb 17, 2025
ec761d7
test keycloak connection url in git CI
khalsz Feb 18, 2025
dbecc0d
fix: ruff fix
khalsz Feb 18, 2025
ea9b5a6
add realm config db files
khalsz Feb 18, 2025
d7b3720
fix: move secrete info to .env file
khalsz Mar 10, 2025
0b78ee4
feat: add dotenv package
khalsz Mar 10, 2025
7374929
fix: client secret from .env file
khalsz Mar 10, 2025
3d15b39
add: admin user to keycloak
khalsz Mar 10, 2025
80260d5
fix: ruff fix
khalsz Mar 10, 2025
ad22451
fix: change to docker cache to actions/cache@v3
khalsz Mar 10, 2025
52f1976
fix: move keycloak secrete to remote
khalsz Apr 2, 2025
dcd3e16
Add: keycloak secrete to github
khalsz Apr 2, 2025
3bd7db4
Merge branch 'main' into feat/T-100-keycloak-authentication
khalsz Apr 2, 2025
a74bc74
fix: remove keycloak secrete from .env.dev file
khalsz Apr 3, 2025
cbb96ed
move keycloak secrete to .env file, fix env var
khalsz Apr 7, 2025
a6433a6
change mock_data path
khalsz Apr 7, 2025
a070788
move keycloak env var to main
khalsz Apr 7, 2025
b5cb977
merged with main
khalsz Apr 8, 2025
0ea7463
fix: move all secrete from .env.dev to .env
khalsz Apr 9, 2025
b84d6af
feat: create .env in git CI
khalsz Apr 9, 2025
62f3d98
update realm config data
khalsz Apr 9, 2025
4cf9d26
merge with main
khalsz Apr 9, 2025
1cb6898
ruff fix
khalsz Apr 9, 2025
be8e990
fix: reaml config path in CI
khalsz Apr 9, 2025
59f290e
fix: reverse realm config path
khalsz Apr 9, 2025
4f7df40
replace keyckloak admin
khalsz Apr 9, 2025
3008772
fix: update post test to latest schema
khalsz Apr 10, 2025
c26ef98
updated env.example file
khalsz Apr 10, 2025
28a1a75
feat: add keycloak secrete to CI
khalsz Apr 10, 2025
5538ed3
fix: update CI with the right secrete name
khalsz Apr 10, 2025
fe6c3f8
feat: add instruction for setting up keycloak secret
khalsz Apr 10, 2025
a7e3f9c
fix: change keycloak exception error type
khalsz Apr 10, 2025
be79c4a
fix: ruff fix
khalsz Apr 10, 2025
099bd96
Merge branch 'main' into feat/T-100-keycloak-authentication
khalsz Apr 10, 2025
37d6d25
merge with main
khalsz Apr 15, 2025
b364670
remove secret from test in CI
khalsz Apr 15, 2025
5a6770f
fix: ruff errors
khalsz Apr 16, 2025
dadac20
fix: keycloak secret in CI
khalsz Apr 16, 2025
6367551
remove comment
khalsz Apr 30, 2025
be8296d
Merge branch 'main' into feat/T-100-keycloak-authentication
khalsz Apr 30, 2025
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
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
uses: docker/setup-buildx-action@v1

- name: Cache Docker layers
uses: actions/cache@v2
uses: actions/cache@v3
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
Expand All @@ -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
7 changes: 6 additions & 1 deletion dev/docker/.env.dev
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,9 @@ 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
KEYCLOAK_CLIENT_SECRET=w9pOmNyAnO6B3SlPcmYr10Nq7iwIn5ze
TEST_USERNAME=test
TEST_PASSWORD=test
30 changes: 30 additions & 0 deletions dev/docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ services:
- ../../tests/mock_data:/code/data
- ../../data/index:/code/index
- ../../src:/code/src
- ../../dev/docker:/code/dev/docker
depends_on:
keycloak:
condition: service_healthy
ports:
- '8081:5000'
entrypoint:
Expand All @@ -27,6 +31,7 @@ services:
environment:
- DATABASE_HOST=pg_container
- DATABASE_PORT=5432
- HOST=keycloak
networks:
- dbnetwork

Expand Down Expand Up @@ -66,6 +71,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
Binary file added dev/docker/realm_config/h2/keycloakdb.mv.db
Binary file not shown.
1,313 changes: 1,313 additions & 0 deletions dev/docker/realm_config/h2/keycloakdb.trace.db

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ dependencies = [
"tqdm>=4.66.5",
"ujson>=5.10.0",
"uvicorn>=0.32.0",
"python-keycloak>=5.1.1",
"python-dotenv>=1.0.1"
]

[project.urls]
Expand All @@ -53,4 +55,4 @@ dev-dependencies = [
"pytest>=8.3.3",
"xarray>=2024.7.0",
"zarr>=2.18.2",
]
]
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
13 changes: 12 additions & 1 deletion src/api/environment.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
import os

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

keycloak_host = os.environ.get("KEYCLOAK_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_PASSWORD = "test"

UNAUTHORIZED_USER = "test1"
118 changes: 118 additions & 0 deletions src/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,36 @@
import pandas as pd
import sqlmodel
import ujson
from dotenv import load_dotenv
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response, status
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, REALM_NAME, SERVER_URL

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

load_dotenv("dev/docker/.env.dev")
CLIENT_SECRET = os.getenv("KEYCLOAK_CLIENT_SECRET")


class JSONLDGraphQL(GraphQL):
async def process_result(
Expand Down Expand Up @@ -78,6 +89,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 +346,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 +435,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 +508,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 +541,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 +572,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
13 changes: 13 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,13 +13,20 @@

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
host = os.environ.get("DATABASE_HOST", "localhost")
TEST_DB_NAME = "test_db"
SQLALCHEMY_DATABASE_TEST_URL = f"postgresql://root:root@{host}:5432/{TEST_DB_NAME}"

# TEST_USERNAME = "test"
# TEST_PASSWORD = "test"
TEST_USERNAME = os.getenv("TEST_USERNAME")
TEST_PASSWORD = os.getenv("TEST_PASSWORD")


# Fixture to create and drop the database
@pytest.fixture(scope="session")
Expand All @@ -39,6 +47,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