Skip to content

Commit 284a36f

Browse files
committed
Switch to postgresql backend. Add azure entrypoint.sh
1 parent ba4cfd2 commit 284a36f

File tree

7 files changed

+1022
-50
lines changed

7 files changed

+1022
-50
lines changed

entrypoint.sh

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#!/bin/bash
2+
set -e
3+
python3 -m pip install --upgrade pip
4+
python3 -m pip install -r requirements.txt
5+
python3 ssh_keyservice_api/seed_database.py
6+
fastapi run ssh_keyservice_api/main.py

poetry.lock

+812-15
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

+9-3
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,24 @@ python = "^3.11"
1111
fastapi = "^0.115.8"
1212
cryptography = "^44.0.1"
1313
fastapi-azure-auth = "^5.0.1"
14-
valkey = "^6.1.0"
1514
pydantic = "^2.10.6"
1615
pydantic-settings = "^2.7.1"
1716
cachetools = "^5.5.1"
1817
azure-identity = "^1.20.0"
1918
azure-keyvault-secrets = "^4.9.0"
20-
19+
sqlmodel = "^0.0.22"
20+
psycopg2 = "^2.9.10"
21+
azure-monitor-opentelemetry = "^1.6.5"
22+
uvicorn = "^0.34.0"
2123

2224
[tool.poetry.group.dev.dependencies]
23-
uvicorn = "^0.34.0"
2425
python-dotenv = "^1.0.1"
2526

27+
[tool.poetry-auto-export]
28+
output = "requirements.txt"
29+
without_hashes = true
30+
without = ["dev"]
31+
2632
[build-system]
2733
requires = ["poetry-core"]
2834
build-backend = "poetry.core.masonry.api"

requirements.txt

+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# poetry.lock hash: 6aac512a4174ad31a98adbfa760fdd2b2372b242
2+
# This file is generated by poetry-auto-export
3+
# The SHA1 hash of the poetry.lock file is printed above
4+
annotated-types==0.7.0 ; python_version >= "3.11" and python_version < "4.0"
5+
anyio==4.8.0 ; python_version >= "3.11" and python_version < "4.0"
6+
asgiref==3.8.1 ; python_version >= "3.11" and python_version < "4.0"
7+
azure-core-tracing-opentelemetry==1.0.0b11 ; python_version >= "3.11" and python_version < "4.0"
8+
azure-core==1.32.0 ; python_version >= "3.11" and python_version < "4.0"
9+
azure-identity==1.20.0 ; python_version >= "3.11" and python_version < "4.0"
10+
azure-keyvault-secrets==4.9.0 ; python_version >= "3.11" and python_version < "4.0"
11+
azure-monitor-opentelemetry-exporter==1.0.0b33 ; python_version >= "3.11" and python_version < "4.0"
12+
azure-monitor-opentelemetry==1.6.5 ; python_version >= "3.11" and python_version < "4.0"
13+
cachetools==5.5.1 ; python_version >= "3.11" and python_version < "4.0"
14+
certifi==2025.1.31 ; python_version >= "3.11" and python_version < "4.0"
15+
cffi==1.17.1 ; python_version >= "3.11" and python_version < "4.0" and platform_python_implementation != "PyPy"
16+
charset-normalizer==3.4.1 ; python_version >= "3.11" and python_version < "4.0"
17+
click==8.1.8 ; python_version >= "3.11" and python_version < "4.0"
18+
colorama==0.4.6 ; python_version >= "3.11" and python_version < "4.0" and platform_system == "Windows"
19+
cryptography==44.0.1 ; python_version >= "3.11" and python_version < "4.0"
20+
deprecated==1.2.18 ; python_version >= "3.11" and python_version < "4.0"
21+
fastapi-azure-auth==5.0.1 ; python_version >= "3.11" and python_version < "4.0"
22+
fastapi==0.115.8 ; python_version >= "3.11" and python_version < "4.0"
23+
fixedint==0.1.6 ; python_version >= "3.11" and python_version < "4.0"
24+
greenlet==3.1.1 ; python_version < "3.14" and (platform_machine == "aarch64" or platform_machine == "ppc64le" or platform_machine == "x86_64" or platform_machine == "amd64" or platform_machine == "AMD64" or platform_machine == "win32" or platform_machine == "WIN32") and python_version >= "3.11"
25+
h11==0.14.0 ; python_version >= "3.11" and python_version < "4.0"
26+
httpcore==1.0.7 ; python_version >= "3.11" and python_version < "4.0"
27+
httpx==0.28.1 ; python_version >= "3.11" and python_version < "4.0"
28+
idna==3.10 ; python_version >= "3.11" and python_version < "4.0"
29+
importlib-metadata==8.5.0 ; python_version >= "3.11" and python_version < "4.0"
30+
isodate==0.7.2 ; python_version >= "3.11" and python_version < "4.0"
31+
msal-extensions==1.2.0 ; python_version >= "3.11" and python_version < "4.0"
32+
msal==1.31.1 ; python_version >= "3.11" and python_version < "4.0"
33+
msrest==0.7.1 ; python_version >= "3.11" and python_version < "4.0"
34+
oauthlib==3.2.2 ; python_version >= "3.11" and python_version < "4.0"
35+
opentelemetry-api==1.30.0 ; python_version >= "3.11" and python_version < "4.0"
36+
opentelemetry-instrumentation-asgi==0.51b0 ; python_version >= "3.11" and python_version < "4.0"
37+
opentelemetry-instrumentation-dbapi==0.51b0 ; python_version >= "3.11" and python_version < "4.0"
38+
opentelemetry-instrumentation-django==0.51b0 ; python_version >= "3.11" and python_version < "4.0"
39+
opentelemetry-instrumentation-fastapi==0.51b0 ; python_version >= "3.11" and python_version < "4.0"
40+
opentelemetry-instrumentation-flask==0.51b0 ; python_version >= "3.11" and python_version < "4.0"
41+
opentelemetry-instrumentation-psycopg2==0.51b0 ; python_version >= "3.11" and python_version < "4.0"
42+
opentelemetry-instrumentation-requests==0.51b0 ; python_version >= "3.11" and python_version < "4.0"
43+
opentelemetry-instrumentation-urllib3==0.51b0 ; python_version >= "3.11" and python_version < "4.0"
44+
opentelemetry-instrumentation-urllib==0.51b0 ; python_version >= "3.11" and python_version < "4.0"
45+
opentelemetry-instrumentation-wsgi==0.51b0 ; python_version >= "3.11" and python_version < "4.0"
46+
opentelemetry-instrumentation==0.51b0 ; python_version >= "3.11" and python_version < "4.0"
47+
opentelemetry-resource-detector-azure==0.1.5 ; python_version >= "3.11" and python_version < "4.0"
48+
opentelemetry-sdk==1.30.0 ; python_version >= "3.11" and python_version < "4.0"
49+
opentelemetry-semantic-conventions==0.51b0 ; python_version >= "3.11" and python_version < "4.0"
50+
opentelemetry-util-http==0.51b0 ; python_version >= "3.11" and python_version < "4.0"
51+
packaging==24.2 ; python_version >= "3.11" and python_version < "4.0"
52+
portalocker==2.10.1 ; python_version >= "3.11" and python_version < "4.0"
53+
psutil==5.9.8 ; python_version >= "3.11" and python_version < "4.0"
54+
psycopg2==2.9.10 ; python_version >= "3.11" and python_version < "4.0"
55+
pycparser==2.22 ; python_version >= "3.11" and python_version < "4.0" and platform_python_implementation != "PyPy"
56+
pydantic-core==2.27.2 ; python_version >= "3.11" and python_version < "4.0"
57+
pydantic-settings==2.7.1 ; python_version >= "3.11" and python_version < "4.0"
58+
pydantic==2.10.6 ; python_version >= "3.11" and python_version < "4.0"
59+
pyjwt==2.10.1 ; python_version >= "3.11" and python_version < "4.0"
60+
pyjwt[crypto]==2.10.1 ; python_version >= "3.11" and python_version < "4.0"
61+
python-dotenv==1.0.1 ; python_version >= "3.11" and python_version < "4.0"
62+
pywin32==308 ; python_version >= "3.11" and python_version < "4.0" and platform_system == "Windows"
63+
requests-oauthlib==2.0.0 ; python_version >= "3.11" and python_version < "4.0"
64+
requests==2.32.3 ; python_version >= "3.11" and python_version < "4.0"
65+
six==1.17.0 ; python_version >= "3.11" and python_version < "4.0"
66+
sniffio==1.3.1 ; python_version >= "3.11" and python_version < "4.0"
67+
sqlalchemy==2.0.38 ; python_version >= "3.11" and python_version < "4.0"
68+
sqlmodel==0.0.22 ; python_version >= "3.11" and python_version < "4.0"
69+
starlette==0.45.3 ; python_version >= "3.11" and python_version < "4.0"
70+
typing-extensions==4.12.2 ; python_version >= "3.11" and python_version < "4.0"
71+
urllib3==2.3.0 ; python_version >= "3.11" and python_version < "4.0"
72+
uvicorn==0.34.0 ; python_version >= "3.11" and python_version < "4.0"
73+
wrapt==1.17.2 ; python_version >= "3.11" and python_version < "4.0"
74+
zipp==3.21.0 ; python_version >= "3.11" and python_version < "4.0"

ssh_keyservice_api/main.py

+57-31
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66

77
import uvicorn
88

9-
import valkey as redis
10-
119
from fastapi import FastAPI, Security, Depends, HTTPException
1210
from fastapi.responses import PlainTextResponse
1311
from fastapi.middleware.cors import CORSMiddleware
@@ -19,12 +17,22 @@
1917
from typing import AsyncGenerator
2018
from datetime import datetime
2119

20+
from sqlmodel import Session, select
21+
2222
from cachetools import TTLCache
2323

24-
from models import UserModel, SSHKeyPutRequest, SSHKeyDeleteRequest
24+
from models import UserModel, SSHKeyPutRequest, SSHKeyDeleteRequest, engine, SSHKey
2525

2626
from dotenv import load_dotenv
2727

28+
from azure.monitor.opentelemetry import configure_azure_monitor
29+
30+
# Setup logger and Azure Monitor:
31+
logger = logging.getLogger("ssh_keyservice_api")
32+
logger.setLevel(logging.INFO)
33+
if os.getenv("APPLICATIONINSIGHTS_CONNECTION_STRING"):
34+
configure_azure_monitor()
35+
2836
#from azure.identity import DefaultAzureCredential
2937
#from azure.keyvault.secrets import SecretClient
3038
# Configure Azure Key Vault
@@ -56,9 +64,13 @@ def get_secret(secret_name: str) -> str:
5664
DB_HOST = get_secret("DB_HOST")
5765
SCOPE = { f'api://{APP_CLIENT_ID}/user.read.profile' : 'user.read.profile' }
5866

67+
# Dependency to get the database session
68+
def get_db_session():
69+
with Session(engine) as session:
70+
yield session
71+
5972
# Cache API keys for 10 minutes (600 seconds)
6073
api_key_cache = TTLCache(maxsize=1, ttl=600)
61-
6274
api_key_header_auth = APIKeyHeader(name="x-api-key", auto_error=True)
6375

6476
def get_api_keys():
@@ -72,13 +84,6 @@ async def api_key_auth(api_key_header: str = Security(api_key_header_auth)):
7284
if not any(secrets.compare_digest(api_key_header, key.strip()) for key in valid_api_keys):
7385
raise HTTPException(status_code=401, detail="Invalid API Key")
7486

75-
# Logging setup
76-
logging.basicConfig(level=logging.INFO)
77-
logger = logging.getLogger("SSHKeyAPI")
78-
79-
# Redis connection
80-
redis_client = redis.Redis(host=DB_HOST, port=6379, decode_responses=True)
81-
8287
@asynccontextmanager
8388
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
8489
"""
@@ -110,46 +115,67 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
110115
scopes=SCOPE,
111116
)
112117

113-
def generate_user_key(email: str) -> str:
114-
return f"user:{hashlib.sha256(email.encode()).hexdigest()}"
118+
def generate_user_hash(email: str) -> str:
119+
return f"{hashlib.sha256(email.encode()).hexdigest()}"
115120

116121
@app.get("/api/v1/users/me", dependencies=[Security(azure_scheme)])
117-
async def get_user_info(user: User = Depends(azure_scheme)) -> UserModel:
122+
async def get_user_info(user: User = Depends(azure_scheme), session: Session = Depends(get_db_session)) -> UserModel:
118123
"""Retrieve SSH keys for authenticated user."""
119124
email = user.claims.get("preferred_username") or user.claims.get("email")
120-
user_key = generate_user_key(email)
121-
stored_keys = redis_client.hgetall(f"{user_key}:keys")
122-
# Parse stored data into a structured format
123-
ssh_keys = {
124-
key: {"comment": value.split("|")[0], "timestamp": value.split("|")[1]}
125-
for key, value in stored_keys.items()
126-
}
125+
user_hash = generate_user_hash(email)
126+
127+
# Fetch SSH keys from the database
128+
results = session.exec(select(SSHKey).filter(SSHKey.user_hash == user_hash)).all()
129+
130+
ssh_keys = {}
131+
for key in results:
132+
ssh_keys.update({key.ssh_key: {"comment": key.comment, "timestamp": key.timestamp}})
133+
127134
return {"email": email, "ssh_keys": ssh_keys}
128135

129136
@app.put("/api/v1/users/me/keys", dependencies=[Security(azure_scheme)])
130-
async def add_ssh_key(request: SSHKeyPutRequest, user: User = Depends(azure_scheme)) -> dict[str, str]:
137+
async def add_ssh_key( request: SSHKeyPutRequest, user: User = Depends(azure_scheme), session: Session = Depends(get_db_session)) -> dict[str, str]:
131138
"""Add an SSH key for the authenticated user."""
132139
email = user.claims.get("preferred_username") or user.claims.get("email")
133-
user_key = generate_user_key(email)
140+
user_hash = generate_user_hash(email)
134141
timestamp = datetime.utcnow().isoformat()
135-
redis_client.hset(f"{user_key}:keys", request.ssh_key, f"{request.comment}|{timestamp}")
142+
143+
ssh_key = SSHKey()
144+
ssh_key.ssh_key = request.ssh_key
145+
ssh_key.user_hash = user_hash
146+
ssh_key.comment = request.comment
147+
ssh_key.timestamp = timestamp
148+
session.add(ssh_key)
149+
session.commit()
150+
session.refresh(ssh_key)
151+
136152
return {"message": "SSH key added."}
137153

138154
@app.delete("/api/v1/users/me/keys", dependencies=[Security(azure_scheme)])
139-
async def delete_ssh_key(request: SSHKeyDeleteRequest, user: User = Depends(azure_scheme)) -> dict[str, str]:
155+
async def delete_ssh_key(request: SSHKeyDeleteRequest, user: User = Depends(azure_scheme), session: Session = Depends(get_db_session)) -> dict[str, str]:
140156
"""Delete a specific SSH key for the authenticated user."""
141157
email = user.claims.get("preferred_username") or user.claims.get("email")
142-
user_key = generate_user_key(email)
143-
if not redis_client.hexists(f"{user_key}:keys", request.ssh_key):
158+
user_hash = generate_user_hash(email)
159+
160+
ssh_key = session.exec(select(SSHKey).filter(SSHKey.user_hash == user_hash, SSHKey.ssh_key == request.ssh_key)).first()
161+
if not ssh_key:
144162
raise HTTPException(status_code=404, detail="SSH key not found.")
145-
redis_client.hdel(f"{user_key}:keys", request.ssh_key)
163+
session.delete(ssh_key)
164+
session.commit()
165+
146166
return {"message": "SSH key deleted."}
147167

148168
@app.get("/api/v1/users/{email}/keys", response_class=PlainTextResponse, dependencies=[Security(api_key_auth)])
149-
async def get_ssh_keys_by_mail(email: str) -> str:
169+
async def get_ssh_keys_by_mail(email: str, session: Session = Depends(get_db_session)) -> str:
150170
"""Get registered SSH keys for a given email."""
151-
user_key = generate_user_key(email)
152-
ssh_keys = redis_client.hgetall(f"{user_key}:keys")
171+
user_hash = generate_user_hash(email)
172+
173+
results = session.exec(select(SSHKey).filter(SSHKey.user_hash == user_hash)).all()
174+
175+
ssh_keys = {}
176+
for key in results:
177+
ssh_keys.update({key.ssh_key: {"comment": key.comment, "timestamp": key.timestamp}})
178+
153179
return "\n".join(ssh_keys.keys()) if ssh_keys else ""
154180

155181
if __name__ == '__main__':

ssh_keyservice_api/models.py

+53-1
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,64 @@
1+
import logging
2+
import os
3+
import typing
4+
from urllib.parse import quote_plus
5+
6+
from dotenv import load_dotenv
7+
from sqlmodel import Field, SQLModel, create_engine
8+
19
from pydantic import BaseModel
210

11+
logger = logging.getLogger("app")
12+
logger.setLevel(logging.INFO)
13+
14+
sql_url = ""
15+
if os.getenv("WEBSITE_HOSTNAME"):
16+
logger.info("Connecting to Azure PostgreSQL Flexible server based on AZURE_POSTGRESQL_CONNECTIONSTRING...")
17+
env_connection_string = os.getenv("AZURE_POSTGRESQL_CONNECTIONSTRING")
18+
if env_connection_string is None:
19+
logger.info("Missing environment variable AZURE_POSTGRESQL_CONNECTIONSTRING")
20+
else:
21+
# Parse the connection string
22+
details = dict(item.split('=') for item in env_connection_string.split())
23+
24+
# Properly format the URL for SQLAlchemy
25+
sql_url = (
26+
f"postgresql://{quote_plus(details['user'])}:{quote_plus(details['password'])}"
27+
f"@{details['host']}:{details['port']}/{details['dbname']}?sslmode={details['sslmode']}"
28+
)
29+
30+
else:
31+
logger.info("Connecting to local PostgreSQL server based on .env file...")
32+
load_dotenv()
33+
POSTGRES_USERNAME = os.environ.get("DBUSER")
34+
POSTGRES_PASSWORD = os.environ.get("DBPASS")
35+
POSTGRES_HOST = os.environ.get("DBHOST")
36+
POSTGRES_DATABASE = os.environ.get("DBNAME")
37+
POSTGRES_PORT = os.environ.get("DBPORT", 5432)
38+
39+
sql_url = f"postgresql://{POSTGRES_USERNAME}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DATABASE}"
40+
41+
engine = create_engine(sql_url)
42+
43+
def create_db_and_tables():
44+
return SQLModel.metadata.create_all(engine)
45+
346
class UserModel(BaseModel):
447
email: str
5-
ssh_keys: dict[str, dict[str, str]]
48+
ssh_keys: dict[str, dict[str, str]] = {}
649

750
class SSHKeyPutRequest(BaseModel):
851
ssh_key: str
952
comment: str
1053

1154
class SSHKeyDeleteRequest(BaseModel):
1255
ssh_key: str
56+
57+
class SSHKey(SQLModel, table=True):
58+
ssh_key: typing.Optional[str] = Field(default=None, primary_key=True)
59+
user_hash: str = Field(max_length=250)
60+
comment: str = Field(max_length=50)
61+
timestamp: str = Field(max_length=50)
62+
63+
def __str__(self):
64+
return f"{self.ssh_key}"

ssh_keyservice_api/seed_database.py

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from sqlmodel import SQLModel
2+
3+
from models import SSHKey, create_db_and_tables, engine
4+
5+
def drop_all():
6+
# Explicitly remove these tables first to avoid cascade errors
7+
SQLModel.metadata.remove(SSHKey.__table__)
8+
SQLModel.metadata.drop_all(engine)
9+
10+
if __name__ == "__main__":
11+
create_db_and_tables()

0 commit comments

Comments
 (0)