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:
90
src/nsct/admin.py
Normal file
90
src/nsct/admin.py
Normal 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())
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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())
|
||||
|
||||
155
src/nsct/security/api_keys.py
Normal file
155
src/nsct/security/api_keys.py
Normal 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
|
||||
@@ -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