Implement persistent API-key authentication

Protect the research lifecycle with X-API-Key validation backed by persistent user and key records. Store only salted scrypt hashes, support expiry and revocation, and expose a local admin CLI for create/list/revoke workflows.

Initialize only the authentication schema at startup, prevent SQL echo from exposing sensitive bound values, and keep health probes public. Add coverage for valid, missing, invalid, expired, and revoked keys.

Document deployment and key administration, update the local CLI to send NSCT_API_KEY, and record the reset handoff state.
This commit is contained in:
faligam
2026-09-06 17:23:05 +02:00
parent 64eb4e8465
commit 1aacf4aa20
14 changed files with 1065 additions and 60 deletions

90
src/nsct/admin.py Normal file
View File

@@ -0,0 +1,90 @@
"""Administrative API-key lifecycle CLI; this never exposes stored hashes as keys."""
from __future__ import annotations
import argparse
import asyncio
from datetime import datetime
from sqlalchemy import select
from nsct.config import AppSettings
from nsct.security.api_keys import create_api_key, revoke_api_key
from nsct.storage.engine import close_engine, get_engine, get_session_factory
from nsct.storage.models import APIKeyModel, UserModel
def _parse_expiry(value: str | None) -> datetime | None:
if not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00")).replace(tzinfo=None)
except ValueError as exc:
raise argparse.ArgumentTypeError("expiry must be ISO-8601, e.g. 2027-01-31T23:59:59Z") from exc
async def _run(args: argparse.Namespace) -> int:
settings = AppSettings.from_env()
if not settings.postgres.url:
raise RuntimeError("NSCT_DB_URL must be configured for API-key administration.")
await get_engine(settings)
try:
factory = get_session_factory()
async with factory() as session:
if args.command == "create":
issued = await create_api_key(
session,
username=args.username,
email=args.email,
name=args.name,
expires_at=_parse_expiry(args.expires_at),
)
await session.commit()
print("API key (shown once; store it in a secret manager):")
print(issued.key)
print(f"key_id: {issued.key_id}")
return 0
if args.command == "revoke":
if not await revoke_api_key(session, args.key_id):
print("No API key found for that key_id.")
return 1
await session.commit()
print(f"Revoked API key {args.key_id}.")
return 0
rows = await session.execute(
select(APIKeyModel, UserModel)
.join(UserModel, APIKeyModel.user_id == UserModel.id)
.order_by(APIKeyModel.created_at.desc())
)
for key, user in rows:
print(
f"{key.key_id}\t{user.username}\t{key.name or '-'}\t"
f"active={key.is_active}\texpires={key.expires_at or '-'}\tlast_used={key.last_used_at or '-'}"
)
return 0
finally:
await close_engine()
def main() -> int:
parser = argparse.ArgumentParser(prog="nsct-api-key", description="Administer NSCT API keys")
commands = parser.add_subparsers(dest="command", required=True)
create = commands.add_parser("create", help="Create a key and print its plaintext once")
create.add_argument("--username", required=True)
create.add_argument("--email")
create.add_argument("--name")
create.add_argument("--expires-at", help="ISO-8601 timestamp; omitted means no expiry")
revoke = commands.add_parser("revoke", help="Revoke a key by its public key_id")
revoke.add_argument("key_id")
commands.add_parser("list", help="List key metadata (never plaintext or hashes)")
args = parser.parse_args()
try:
return asyncio.run(_run(args))
except (RuntimeError, argparse.ArgumentTypeError) as exc:
parser.error(str(exc))
return 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -16,6 +16,7 @@ from fastapi.responses import Response
from nsct.config import AppSettings
from nsct.logging_config import get_logger, set_request_ctx
from nsct.metrics import G_ACTIVE_RESEARCH_RUNS, metrics
from nsct.storage.engine import close_engine, get_engine
logger = logging.getLogger(__name__)
@@ -30,6 +31,12 @@ async def lifespan(app: FastAPI):
config: AppSettings = app.state.config
logger.info("NSCT API starting up — version %s", app.state.version)
# A database is mandatory for protected research routes. Keep the app
# factory usable for non-database unit tests and let those routes fail
# explicitly through their session dependency instead of failing health
# probes at startup.
if config.postgres.url:
await get_engine(config)
# Pre-flight: validate LLM connectivity
llm_base = config.llm.base_url if config and config.llm and config.llm.base_url else ""
@@ -48,6 +55,7 @@ async def lifespan(app: FastAPI):
logger.info("NSCT API shutting down")
metrics.gauge(G_ACTIVE_RESEARCH_RUNS, 0)
await close_engine()
# ---------------------------------------------------------------------------
@@ -176,4 +184,4 @@ def create_app() -> FastAPI:
return app
app = create_app()
app = create_app()

View File

@@ -8,7 +8,7 @@ import uuid
from datetime import datetime, timezone
from typing import Any
from fastapi import BackgroundTasks, APIRouter, HTTPException
from fastapi import BackgroundTasks, APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from nsct.logging_config import set_request_ctx, clear_request_ctx, set_research_run_id, get_logger
@@ -26,10 +26,13 @@ from nsct.metrics import (
H_RESEARCH_DURATION,
G_ACTIVE_RESEARCH_RUNS,
)
from nsct.security.api_keys import require_api_key
logger = logging.getLogger(__name__)
router = APIRouter()
# Research data and operations are intentionally never exposed without a user
# API key. Health/readiness endpoints remain public for infrastructure probes.
router = APIRouter(dependencies=[Depends(require_api_key)])
# ---------------------------------------------------------------------------
@@ -674,4 +677,4 @@ async def delete_research(research_id: str) -> dict[str, str]:
run.state = ResearchRunState.CANCELLED.value
run.updated_at = _now()
del _research_store[research_id]
return {"status": "deleted", "research_id": research_id}
return {"status": "deleted", "research_id": research_id}

View File

@@ -43,15 +43,19 @@ def bold(text: str) -> str:
# ── API client ───────────────────────────────────────────────────────────
_API_URL = os.environ.get("NSCT_API_URL", "http://localhost:8080")
_API_KEY = os.environ.get("NSCT_API_KEY", "")
_TIMEOUT = 30 # seconds per request
def _client() -> httpx.Client:
"""Return a configured httpx client."""
headers = {"Accept": "application/json"}
if _API_KEY:
headers["X-API-Key"] = _API_KEY
return httpx.Client(
base_url=_API_URL,
timeout=httpx.Timeout(_TIMEOUT, connect=5),
headers={"Accept": "application/json"},
headers=headers,
)
@@ -153,7 +157,7 @@ def cmd_help(**_kwargs: Any) -> int:
print(f" {bold('list [--limit N]')}\tList recent research runs")
print(f" {bold('delete <id>')}\tDelete a research run")
print()
print(f"Environment: NSCT_API_URL={_API_URL}")
print(f"Environment: NSCT_API_URL={_API_URL}; NSCT_API_KEY={'set' if _API_KEY else 'not set'}")
return 0
@@ -432,4 +436,4 @@ def main() -> int:
if __name__ == "__main__":
sys.exit(main())
sys.exit(main())

View File

@@ -0,0 +1,155 @@
"""Persistent API-key creation, verification, and FastAPI dependencies."""
from __future__ import annotations
import base64
import hashlib
import hmac
import secrets
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from fastapi import Depends, HTTPException, Request, Security, status
from fastapi.security import APIKeyHeader
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.concurrency import run_in_threadpool
from nsct.storage.engine import get_session
from nsct.storage.models import APIKeyModel, UserModel
_HEADER = APIKeyHeader(name="X-API-Key", auto_error=False)
_PREFIX = "nsct_"
_SCRYPT_N = 2**15
_SCRYPT_R = 8
_SCRYPT_P = 1
@dataclass(frozen=True)
class IssuedAPIKey:
"""The plaintext value is available only to the administrative caller."""
key: str
key_id: str
user_id: str
name: str | None
def _hash_key(key: str, salt: bytes | None = None) -> str:
salt = salt or secrets.token_bytes(16)
digest = hashlib.scrypt(
key.encode("utf-8"), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, maxmem=64 * 1024 * 1024
)
return "scrypt$32768$8$1${}${}".format(
base64.urlsafe_b64encode(salt).decode("ascii"),
base64.urlsafe_b64encode(digest).decode("ascii"),
)
def _verify_key(key: str, stored_hash: str) -> bool:
try:
algorithm, n, r, p, salt_b64, digest_b64 = stored_hash.split("$")
if algorithm != "scrypt":
return False
salt = base64.urlsafe_b64decode(salt_b64.encode("ascii"))
expected = base64.urlsafe_b64decode(digest_b64.encode("ascii"))
candidate = hashlib.scrypt(
key.encode("utf-8"), salt=salt, n=int(n), r=int(r), p=int(p), maxmem=64 * 1024 * 1024
)
return hmac.compare_digest(candidate, expected)
except (ValueError, TypeError, UnicodeError):
return False
def _key_id_from_value(key: str) -> str | None:
parts = key.split("_", 2)
if len(parts) != 3 or parts[0] != "nsct" or len(parts[1]) != 32:
return None
try:
int(parts[1], 16)
except ValueError:
return None
return parts[1]
async def create_api_key(
session: AsyncSession,
*,
username: str,
email: str | None = None,
name: str | None = None,
expires_at: datetime | None = None,
) -> IssuedAPIKey:
"""Create a user when needed and issue a new random API key."""
user = await session.scalar(select(UserModel).where(UserModel.username == username))
if user is None:
user = UserModel(username=username, email=email)
session.add(user)
await session.flush()
elif email and user.email != email:
user.email = email
key_id = uuid.uuid4().hex
plaintext = f"{_PREFIX}{key_id}_{secrets.token_urlsafe(32)}"
key = APIKeyModel(
user_id=user.id,
key_id=key_id,
key_hash=await run_in_threadpool(_hash_key, plaintext),
name=name,
expires_at=expires_at,
)
session.add(key)
await session.flush()
return IssuedAPIKey(key=plaintext, key_id=key_id, user_id=user.id, name=name)
async def revoke_api_key(session: AsyncSession, key_id: str) -> bool:
key = await session.scalar(select(APIKeyModel).where(APIKeyModel.key_id == key_id))
if key is None:
return False
key.is_active = False
return True
def _unauthorized() -> HTTPException:
return HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing, invalid, expired, or revoked API key.",
headers={"WWW-Authenticate": "APIKey"},
)
async def require_api_key(
request: Request,
api_key: str | None = Security(_HEADER),
session: AsyncSession = Depends(get_session),
) -> APIKeyModel:
"""Authenticate a request using a live, non-expired key and active user."""
if not api_key:
raise _unauthorized()
key_id = _key_id_from_value(api_key)
if key_id is None:
raise _unauthorized()
result = await session.execute(
select(APIKeyModel, UserModel)
.join(UserModel, APIKeyModel.user_id == UserModel.id)
.where(APIKeyModel.key_id == key_id, APIKeyModel.is_active.is_(True), UserModel.is_active.is_(True))
)
row = result.first()
if row is None:
raise _unauthorized()
key, _user = row
now = datetime.now(timezone.utc)
if key.expires_at is not None:
expires_at = key.expires_at.replace(tzinfo=timezone.utc) if key.expires_at.tzinfo is None else key.expires_at
if expires_at <= now:
raise _unauthorized()
if not await run_in_threadpool(_verify_key, api_key, key.key_hash):
raise _unauthorized()
key.last_used_at = now.replace(tzinfo=None)
request.state.api_key_id = key.key_id
request.state.api_user_id = key.user_id
return key

View File

@@ -9,7 +9,7 @@ from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from nsct.config import AppSettings
from nsct.storage.models import Base # noqa: F401 — Base is needed for create_all
from nsct.storage.models import APIKeyModel, UserModel
logger = logging.getLogger(__name__)
@@ -31,19 +31,23 @@ async def get_engine(config: AppSettings) -> None:
db_url = config.postgres.url
_engine = create_async_engine(
db_url,
echo=config.debug,
pool_size=10,
max_overflow=20,
pool_recycle=1800,
pool_pre_ping=True,
)
# SQLAlchemy's echo mode prints bound values, including API-key hashes.
# Never enable it for the persistent authentication database.
engine_options = {"echo": False, "pool_pre_ping": True}
if not db_url.startswith("sqlite"):
engine_options.update(pool_size=10, max_overflow=20, pool_recycle=1800)
_engine = create_async_engine(db_url, **engine_options)
# Ensure tables exist (schema migration not handled here — that is a
# separate migration step; this creates missing tables only).
# Auth is the only persistent HTTP concern currently using this engine.
# Other legacy model metadata contains tables that are not yet deployable
# as a single schema, so creating all of it here can prevent API startup.
async with _engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await conn.run_sync(
lambda sync_conn: UserModel.metadata.create_all(
sync_conn,
tables=[UserModel.__table__, APIKeyModel.__table__],
)
)
_session_factory = async_sessionmaker(
_engine,
@@ -73,6 +77,13 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
await session.close()
def get_session_factory() -> async_sessionmaker[AsyncSession]:
"""Return the initialized factory for trusted administrative commands."""
if _session_factory is None:
raise RuntimeError("Engine not initialised. Call get_engine() first.")
return _session_factory
async def close_engine() -> None:
"""Close the engine and release all pooled connections."""
global _engine, _session_factory
@@ -80,4 +91,4 @@ async def close_engine() -> None:
await _engine.dispose()
logger.info("SQLAlchemy engine disposed.")
_engine = None
_session_factory = None
_session_factory = None

View File

@@ -9,6 +9,7 @@ from uuid import UUID, uuid4
from sqlalchemy import (
BigInteger,
Boolean,
Column,
DateTime,
Enum,
@@ -18,6 +19,7 @@ from sqlalchemy import (
Integer,
JSON,
String,
Table,
Text,
)
from sqlalchemy.orm import DeclarativeBase, relationship
@@ -78,6 +80,52 @@ class Base(DeclarativeBase):
pass
# ---------------------------------------------------------------------------
# API authentication
# ---------------------------------------------------------------------------
class UserModel(Base):
"""A principal that can own one or more API keys."""
__tablename__ = "users"
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
username = Column(String(255), nullable=False, unique=True)
email = Column(String(255), nullable=True, unique=True)
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
api_keys = relationship("APIKeyModel", back_populates="user", cascade="all, delete-orphan")
class APIKeyModel(Base):
"""Persisted API key metadata; the clear-text secret is never stored."""
__tablename__ = "api_keys"
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
# key_id is a public selector embedded in the presented key. It permits a
# single scrypt verification without making the secret itself queryable.
key_id = Column(String(32), nullable=False, unique=True)
key_hash = Column(String(255), nullable=False)
name = Column(String(255), nullable=True)
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
expires_at = Column(DateTime, nullable=True)
last_used_at = Column(DateTime, nullable=True)
user = relationship("UserModel", back_populates="api_keys")
__table_args__ = (
Index("ix_api_keys_key_id", "key_id"),
Index("ix_api_keys_user_id", "user_id"),
Index("ix_api_keys_is_active", "is_active"),
)
# ---------------------------------------------------------------------------
# SearchQuery
# ---------------------------------------------------------------------------
@@ -126,6 +174,7 @@ class SourceModel(Base):
independence = relationship(
"SourceIndependenceModel",
back_populates="source",
foreign_keys="SourceIndependenceModel.source_id",
uselist=False,
cascade="all, delete-orphan",
)
@@ -158,6 +207,20 @@ class ClaimModel(Base):
# Relationships
source = relationship("SourceModel", back_populates="claims")
clusters = relationship("ClaimClusterModel", secondary=lambda: claim_cluster_mapping, back_populates="claims")
claim_cluster_mapping = Table(
"claim_cluster_mapping",
Base.metadata,
Column("id", String(36), primary_key=True, default=lambda: str(uuid4())),
Column("cluster_id", String(36), ForeignKey("claim_clusters.id"), nullable=False),
Column("claim_id", String(36), ForeignKey("claims.id"), nullable=False),
Column("position", Integer, nullable=False, default=0),
Index("ix_claim_cluster_mapping_cluster_id", "cluster_id"),
Index("ix_claim_cluster_mapping_claim_id", "claim_id"),
Index("uq_claim_cluster_mapping_cluster_claim", "cluster_id", "claim_id", unique=True),
)
# ---------------------------------------------------------------------------
@@ -225,7 +288,11 @@ class SourceIndependenceModel(Base):
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow)
# Relationships
source = relationship("SourceModel", back_populates="independence")
source = relationship(
"SourceModel",
back_populates="independence",
foreign_keys=[source_id],
)
# ---------------------------------------------------------------------------
@@ -315,21 +382,6 @@ class ClaimClusterModel(Base):
)
# Association table: claims ↔ clusters (many-to-many)
claim_cluster_mapping = Base()
claim_cluster_mapping.__tablename__ = "claim_cluster_mapping"
claim_cluster_mapping.id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
claim_cluster_mapping.cluster_id = Column(String(36), ForeignKey("claim_clusters.id"), nullable=False)
claim_cluster_mapping.claim_id = Column(String(36), ForeignKey("claims.id"), nullable=False)
claim_cluster_mapping.position = Column(Integer, nullable=False, default=0)
__table_args__ = (
Index("ix_claim_cluster_mapping_cluster_id", "cluster_id"),
Index("ix_claim_cluster_mapping_claim_id", "claim_id"),
Index("uq_claim_cluster_mapping_cluster_claim", "cluster_id", "claim_id", unique=True),
)
class ClaimRelationModel(Base):
"""Pairwise Beziehung zwischen zwei Claims innerhalb eines Clusters."""
@@ -780,4 +832,4 @@ class ResearchRunProvenanceModel(Base):
__table_args__ = (
Index("ix_provenance_run_step", "research_run_id", "step"),
Index("ix_provenance_run_timestamp", "research_run_id", "timestamp"),
)
)