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:
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user