fastapi-gsap/gsap_broker/db.py
Tyler J King 782f5654ac fix: shared bearer auth, delegation depth, SQLite permissions
C-4: MCP endpoint requires verified bearer token. Unauthenticated
     requests rejected. _extract_principal() replaced by verified
     AuthResult from middleware.
C-8: All delegation endpoints require verified bearer token.
     X-Delegator-DID header removed — identity from token only.
     delegator_ac_id validated to belong to authenticated principal.
     Only delegators can revoke. Only delegator/delegate can view.
H-6: SQLite file permissions restricted to 0o600 (owner-only).
     Umask set before creation. WAL/SHM files also restricted.
H-7: Delegation depth tracked and enforced against max_delegation_depth.
     Sub-delegations increment depth. Exceeded depth → 403.

Shared TokenAuthenticator auto-detects identity driver from JWT
issuer claim (Keycloak or Entra). verify_bearer FastAPI dependency
for all protected endpoints. Health endpoint remains public.

ALL 10 critical findings CLOSED. ALL 10 high findings CLOSED.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 17:31:46 -04:00

65 lines
2 KiB
Python

# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
import os
import stat
from sqlmodel import SQLModel
from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
from sqlmodel.ext.asyncio.session import AsyncSession
from gsap_broker.settings import settings
engine: AsyncEngine = create_async_engine(settings.database_url, echo=False)
def _restrict_db_permissions() -> None:
"""Fix H-6: restrict SQLite file permissions to owner-only (0o600).
Also restricts WAL and SHM files if they exist.
"""
db_url = settings.database_url
if "sqlite" not in db_url:
return
# Extract path from sqlite URL
path = db_url.split("///")[-1] if "///" in db_url else ""
if not path or not os.path.exists(path):
return
mode = stat.S_IRUSR | stat.S_IWUSR # 0o600
try:
os.chmod(path, mode)
for suffix in ("-wal", "-shm"):
extra = path + suffix
if os.path.exists(extra):
os.chmod(extra, mode)
except OSError:
pass # may fail on Windows or read-only filesystem
async def init_db():
# Set restrictive umask before creating the database
old_umask = os.umask(0o077)
try:
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
# Schema migrations for existing DBs
for migration in [
"ALTER TABLE authorization_contexts ADD COLUMN session_mode BOOLEAN DEFAULT 0",
"ALTER TABLE delegations ADD COLUMN depth INTEGER DEFAULT 0",
"ALTER TABLE delegations ADD COLUMN parent_delegation_id TEXT DEFAULT NULL",
]:
try:
await conn.execute(__import__("sqlalchemy").text(migration))
except Exception:
pass # Column already exists
finally:
os.umask(old_umask)
# Fix H-6: restrict file permissions after creation
_restrict_db_permissions()
async def get_session():
async with AsyncSession(engine) as session:
yield session