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

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"),
)
)