Compare commits
4 Commits
feat/knowl
...
tessera-oi
| Author | SHA1 | Date | |
|---|---|---|---|
| 0bdc432215 | |||
| 1a5a3ed1b1 | |||
| 16199c9280 | |||
| 3f5f813c65 |
@@ -1,4 +1,6 @@
|
||||
"""Shared auth dependencies."""
|
||||
import hashlib
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer, APIKeyHeader
|
||||
@@ -11,6 +13,8 @@ from app.core.config import get_db, settings
|
||||
from app.models import models
|
||||
from app.models.apikey import APIKey
|
||||
|
||||
logger = logging.getLogger("harborforge.deps")
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token", auto_error=False)
|
||||
apikey_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
@@ -59,11 +63,17 @@ async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = De
|
||||
return user
|
||||
|
||||
|
||||
def hash_api_key(raw: str) -> str:
|
||||
"""SHA-256 of a raw API key. Keys are high-entropy random tokens, so a
|
||||
fast hash (not bcrypt) is appropriate and allows O(1) lookup by hash."""
|
||||
return hashlib.sha256(raw.encode()).hexdigest()
|
||||
|
||||
|
||||
def _lookup_api_key(db: Session, key: str) -> models.User | None:
|
||||
"""Resolve an API key string to a User; mark last_used_at on hit."""
|
||||
if not key:
|
||||
return None
|
||||
key_obj = db.query(APIKey).filter(APIKey.key == key, APIKey.is_active == True).first() # noqa: E712
|
||||
key_obj = db.query(APIKey).filter(APIKey.key_hash == hash_api_key(key), APIKey.is_active == True).first() # noqa: E712
|
||||
if not key_obj:
|
||||
return None
|
||||
key_obj.last_used_at = datetime.utcnow()
|
||||
@@ -79,6 +89,10 @@ async def get_current_user_or_apikey(
|
||||
"""Authenticate via JWT token (Authorization: Bearer <jwt>) OR API key
|
||||
(X-API-Key: <key>, OR — as a convenience for CLI clients that only know
|
||||
Bearer — Authorization: Bearer <api-key>; falls back when JWT decode fails).
|
||||
|
||||
Bearer tokens are tried in order: local HS256 JWT → external Tessera
|
||||
(OIDC) RS256 access token → API key. The Tessera path is purely additive
|
||||
and never affects local-JWT/API-key callers.
|
||||
"""
|
||||
# Native X-API-Key header
|
||||
if api_key:
|
||||
@@ -86,15 +100,31 @@ async def get_current_user_or_apikey(
|
||||
if user:
|
||||
return user
|
||||
|
||||
# Bearer header — try JWT first, then API key on decode failure
|
||||
# Bearer header — local JWT first, then Tessera, then API key.
|
||||
if token:
|
||||
try:
|
||||
return await get_current_user(token=token, db=db)
|
||||
except HTTPException:
|
||||
user = _lookup_api_key(db, token)
|
||||
if user:
|
||||
return user
|
||||
raise
|
||||
pass
|
||||
|
||||
# External Tessera (OIDC) RS256 access token.
|
||||
try:
|
||||
from app.api.tessera import authenticate_tessera
|
||||
return authenticate_tessera(db, token)
|
||||
except HTTPException:
|
||||
pass
|
||||
except Exception: # JWKS fetch / unexpected verifier error → don't 500
|
||||
logger.warning("Tessera token verification error", exc_info=True)
|
||||
|
||||
# Bearer-carried API key (CLI convenience).
|
||||
user = _lookup_api_key(db, token)
|
||||
if user:
|
||||
return user
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
|
||||
@@ -33,7 +33,11 @@ def create_comment(comment: schemas.CommentCreate, db: Session = Depends(get_db)
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
check_project_role(db, current_user.id, task.project_id, min_role="viewer")
|
||||
|
||||
db_comment = models.Comment(**comment.model_dump())
|
||||
# Always attribute the comment to the authenticated caller — never trust
|
||||
# a client-supplied author_id (prevents author spoofing).
|
||||
data = comment.model_dump()
|
||||
data.pop("author_id", None)
|
||||
db_comment = models.Comment(**data, author_id=current_user.id)
|
||||
db.add(db_comment)
|
||||
db.commit()
|
||||
db.refresh(db_comment)
|
||||
|
||||
@@ -12,7 +12,7 @@ from sqlalchemy import func as sqlfunc
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.config import get_db
|
||||
from app.api.deps import get_current_user_or_apikey, require_admin
|
||||
from app.api.deps import get_current_user_or_apikey, require_admin, hash_api_key
|
||||
from app.api.rbac import check_project_role, ensure_can_edit_milestone
|
||||
from app.models import models
|
||||
from app.models.apikey import APIKey
|
||||
@@ -49,7 +49,8 @@ class APIKeyCreate(BaseModel):
|
||||
|
||||
class APIKeyResponse(BaseModel):
|
||||
id: int
|
||||
key: str
|
||||
key: str | None = None # full secret — only populated on create/reset
|
||||
key_prefix: str | None = None # masked display for listings
|
||||
name: str
|
||||
user_id: int
|
||||
is_active: bool
|
||||
@@ -66,11 +67,16 @@ def create_api_key(data: APIKeyCreate, db: Session = Depends(get_db),
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
key = secrets.token_hex(32)
|
||||
db_key = APIKey(key=key, name=data.name, user_id=data.user_id)
|
||||
db_key = APIKey(key_hash=hash_api_key(key), key_prefix=key[:8], name=data.name, user_id=data.user_id)
|
||||
db.add(db_key)
|
||||
db.commit()
|
||||
db.refresh(db_key)
|
||||
return db_key
|
||||
# Return the raw key once (it is never stored or shown again).
|
||||
return {
|
||||
"id": db_key.id, "key": key, "key_prefix": db_key.key_prefix,
|
||||
"name": db_key.name, "user_id": db_key.user_id, "is_active": db_key.is_active,
|
||||
"created_at": db_key.created_at, "last_used_at": db_key.last_used_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api-keys", response_model=List[APIKeyResponse], tags=["API Keys"])
|
||||
@@ -80,11 +86,14 @@ def list_api_keys(user_id: int = None, db: Session = Depends(get_db),
|
||||
if user_id:
|
||||
query = query.filter(APIKey.user_id == user_id)
|
||||
keys = query.all()
|
||||
# Never expose the full secret on listing; show only a masked prefix.
|
||||
for k in keys:
|
||||
if k.key and len(k.key) > 8:
|
||||
k.key = k.key[:6] + "…" + k.key[-2:]
|
||||
return keys
|
||||
# Never expose the secret on listing — the raw key isn't stored. Show only
|
||||
# the masked prefix.
|
||||
return [{
|
||||
"id": k.id, "key": None,
|
||||
"key_prefix": (k.key_prefix + "…") if k.key_prefix else None,
|
||||
"name": k.name, "user_id": k.user_id, "is_active": k.is_active,
|
||||
"created_at": k.created_at, "last_used_at": k.last_used_at,
|
||||
} for k in keys]
|
||||
|
||||
|
||||
@router.delete("/api-keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["API Keys"])
|
||||
@@ -132,7 +141,10 @@ def list_activity(entity_type: str = None, entity_id: int = None, user_id: int =
|
||||
def create_milestone(ms: schemas.MilestoneCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
import json
|
||||
project = db.query(models.Project).filter(models.Project.id == ms.project_id).first()
|
||||
project_code = project.project_code if project and project.project_code else f"P{ms.project_id}"
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
check_project_role(db, current_user.id, project.id, min_role="mgr")
|
||||
project_code = project.project_code if project.project_code else f"P{ms.project_id}"
|
||||
|
||||
max_ms = db.query(MilestoneModel).filter(MilestoneModel.project_id == ms.project_id).order_by(MilestoneModel.id.desc()).first()
|
||||
next_num = (max_ms.id + 1) if max_ms else 1
|
||||
@@ -488,6 +500,7 @@ def create_milestone_task(project_code: str, milestone_id: str, task_data: dict,
|
||||
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
check_project_role(db, current_user.id, project.id, min_role="dev")
|
||||
|
||||
ms = db.query(MilestoneModel).filter(MilestoneModel.milestone_code == milestone_id, MilestoneModel.project_id == project.id).first()
|
||||
if not ms:
|
||||
@@ -622,6 +635,7 @@ def create_support(project_code: str, milestone_id: str, support_data: dict, db:
|
||||
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
check_project_role(db, current_user.id, project.id, min_role="dev")
|
||||
|
||||
ms = db.query(MilestoneModel).filter(MilestoneModel.milestone_code == milestone_id, MilestoneModel.project_id == project.id).first()
|
||||
if not ms:
|
||||
@@ -768,6 +782,7 @@ def create_meeting(project_code: str, milestone_id: str, meeting_data: dict, db:
|
||||
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
check_project_role(db, current_user.id, project.id, min_role="dev")
|
||||
|
||||
ms = db.query(MilestoneModel).filter(MilestoneModel.milestone_code == milestone_id, MilestoneModel.project_id == project.id).first()
|
||||
if not ms:
|
||||
|
||||
@@ -7,7 +7,7 @@ from pydantic import BaseModel
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, get_current_user_or_apikey, get_password_hash
|
||||
from app.api.deps import get_current_user, get_current_user_or_apikey, get_password_hash, hash_api_key
|
||||
from app.core.config import get_db, settings
|
||||
from app.init_bootstrap import DELETED_USER_USERNAME
|
||||
from app.models import models
|
||||
@@ -464,9 +464,10 @@ def reset_user_apikey(
|
||||
existing_key.is_active = False
|
||||
db.flush()
|
||||
|
||||
# Create new key
|
||||
# Create new key (store only the hash + a display prefix)
|
||||
new_key = APIKey(
|
||||
key=new_key_value,
|
||||
key_hash=hash_api_key(new_key_value),
|
||||
key_prefix=new_key_value[:8],
|
||||
name=f"{target_user.username}-key",
|
||||
user_id=target_user.id,
|
||||
is_active=True,
|
||||
|
||||
231
app/api/tessera.py
Normal file
231
app/api/tessera.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""Tessera (external OIDC, Keycloak-compatible) access-token verification.
|
||||
|
||||
Accepts RS256 access tokens issued by the configured Tessera realm as API
|
||||
bearer tokens. This is ADDITIVE to the existing local HS256 JWT and API-key
|
||||
auth — see app/api/deps.get_current_user_or_apikey.
|
||||
|
||||
Verification:
|
||||
* fetch + cache the realm JWKS ({issuer}/protocol/openid-connect/certs),
|
||||
* select the JWK by the token header `kid`,
|
||||
* verify the RS256 signature, `iss == TESSERA_ISSUER`,
|
||||
`aud` contains TESSERA_AUDIENCE, and require `exp`/`iat`.
|
||||
|
||||
Verified claims are mapped to / provision an hf models.User, mirroring the
|
||||
OIDC login callback provisioning (app/api/routers/oidc.py).
|
||||
"""
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
import requests
|
||||
from fastapi import HTTPException
|
||||
from jose import jwt
|
||||
from jose.exceptions import JWTError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models import models
|
||||
from app.models.role_permission import Role
|
||||
|
||||
logger = logging.getLogger("harborforge.tessera")
|
||||
|
||||
# JWKS cache: refetched when a token's kid is unknown, and at most once per
|
||||
# _JWKS_TTL otherwise (so rotated/revoked keys are picked up within the TTL).
|
||||
_JWKS_TTL = 3600
|
||||
_jwks_lock = threading.Lock()
|
||||
_jwks: dict | None = None
|
||||
_jwks_fetched_at: float = 0.0
|
||||
|
||||
|
||||
def _jwks_url() -> str:
|
||||
return settings.TESSERA_ISSUER.rstrip("/") + "/protocol/openid-connect/certs"
|
||||
|
||||
|
||||
def _fetch_jwks() -> dict:
|
||||
resp = requests.get(_jwks_url(), timeout=5)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _get_jwks(force: bool = False) -> dict:
|
||||
global _jwks, _jwks_fetched_at
|
||||
with _jwks_lock:
|
||||
now = time.time()
|
||||
if force or _jwks is None or (now - _jwks_fetched_at) > _JWKS_TTL:
|
||||
_jwks = _fetch_jwks()
|
||||
_jwks_fetched_at = now
|
||||
return _jwks
|
||||
|
||||
|
||||
def _key_for_kid(kid: str | None) -> dict | None:
|
||||
keys = (_get_jwks() or {}).get("keys") or []
|
||||
for k in keys:
|
||||
if k.get("kid") == kid:
|
||||
return k
|
||||
# Unknown kid → keys may have rotated; force a refresh once and retry.
|
||||
keys = (_get_jwks(force=True) or {}).get("keys") or []
|
||||
for k in keys:
|
||||
if k.get("kid") == kid:
|
||||
return k
|
||||
return None
|
||||
|
||||
|
||||
def verify_tessera_token(token: str) -> dict:
|
||||
"""Verify a Tessera RS256 access token and return its decoded claims.
|
||||
|
||||
Raises HTTPException(401) on any failure (so callers can fall through to
|
||||
the next auth method without leaking which check failed).
|
||||
"""
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="No token")
|
||||
try:
|
||||
header = jwt.get_unverified_header(token)
|
||||
except JWTError as exc:
|
||||
raise HTTPException(status_code=401, detail="Malformed token") from exc
|
||||
|
||||
if header.get("alg") != "RS256":
|
||||
raise HTTPException(status_code=401, detail="Unexpected token algorithm")
|
||||
|
||||
key = _key_for_kid(header.get("kid"))
|
||||
if key is None:
|
||||
raise HTTPException(status_code=401, detail="Unknown signing key")
|
||||
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
key,
|
||||
algorithms=["RS256"],
|
||||
issuer=settings.TESSERA_ISSUER,
|
||||
audience=settings.TESSERA_AUDIENCE,
|
||||
options={"require_exp": True, "require_iat": True},
|
||||
)
|
||||
except JWTError as exc:
|
||||
raise HTTPException(status_code=401, detail="Invalid Tessera token") from exc
|
||||
|
||||
if not claims.get("sub"):
|
||||
raise HTTPException(status_code=401, detail="Token missing subject")
|
||||
return claims
|
||||
|
||||
|
||||
def _collect_roles(claims: dict) -> set[str]:
|
||||
"""Tessera/Keycloak roles: realm_access.roles + resource_access.<client>.roles.
|
||||
|
||||
Mirrors app/api/routers/oidc._collect_roles (normalised lower-case, no
|
||||
leading slash).
|
||||
"""
|
||||
roles: set[str] = set()
|
||||
ra = claims.get("realm_access")
|
||||
if isinstance(ra, dict):
|
||||
roles.update(ra.get("roles") or [])
|
||||
res = claims.get("resource_access")
|
||||
if isinstance(res, dict):
|
||||
for v in res.values():
|
||||
if isinstance(v, dict):
|
||||
roles.update(v.get("roles") or [])
|
||||
return {str(r).strip().lstrip("/").lower() for r in roles if r}
|
||||
|
||||
|
||||
# Token roles (lower-case) mapped to an hf global role name, highest first.
|
||||
# The first match wins. Token "admin" → hf admin (sets is_admin); others map
|
||||
# onto the existing global role hierarchy.
|
||||
_ROLE_PRIORITY = ["admin", "mgr", "dev", "member", "viewer", "guest"]
|
||||
|
||||
|
||||
def _resolve_global_role(db: Session, token_roles: set[str]) -> Role | None:
|
||||
for name in _ROLE_PRIORITY:
|
||||
if name in token_roles:
|
||||
role = db.query(Role).filter(
|
||||
Role.is_global == True, # noqa: E712
|
||||
Role.name == name,
|
||||
).first()
|
||||
if role:
|
||||
return role
|
||||
# No recognised role → fall back to guest (least privilege).
|
||||
return db.query(Role).filter(
|
||||
Role.is_global == True, # noqa: E712
|
||||
Role.name == "guest",
|
||||
).first()
|
||||
|
||||
|
||||
def _unique_username(db: Session, base: str) -> str:
|
||||
base = (base or "tessera-user").strip() or "tessera-user"
|
||||
candidate = base
|
||||
n = 1
|
||||
while db.query(models.User).filter(models.User.username == candidate).first():
|
||||
n += 1
|
||||
candidate = f"{base}-{n}"
|
||||
return candidate
|
||||
|
||||
|
||||
def resolve_or_provision_user(db: Session, claims: dict) -> models.User:
|
||||
"""Resolve the hf User for a verified Tessera token, auto-provisioning one
|
||||
if no match exists. Mirrors the OIDC callback binding/provisioning."""
|
||||
issuer = claims.get("iss") or settings.TESSERA_ISSUER
|
||||
subject = claims.get("sub")
|
||||
email = (claims.get("email") or "").strip() or None
|
||||
username = (claims.get("preferred_username") or "").strip() or None
|
||||
|
||||
token_roles = _collect_roles(claims)
|
||||
is_admin = "admin" in token_roles
|
||||
|
||||
# 1) by (issuer, subject)
|
||||
user = db.query(models.User).filter(
|
||||
models.User.oidc_issuer == issuer,
|
||||
models.User.oidc_subject == subject,
|
||||
).first()
|
||||
|
||||
# 2) by email — bind this Tessera identity onto the existing account.
|
||||
if user is None and email:
|
||||
user = db.query(models.User).filter(models.User.email == email).first()
|
||||
if user is not None:
|
||||
if user.oidc_subject and user.oidc_subject != subject:
|
||||
# Email belongs to a user already bound to a different identity.
|
||||
raise HTTPException(status_code=401, detail="Account already bound to another identity")
|
||||
user.oidc_issuer = issuer
|
||||
user.oidc_subject = subject
|
||||
db.commit()
|
||||
|
||||
# 3) auto-provision
|
||||
if user is None:
|
||||
role = _resolve_global_role(db, token_roles)
|
||||
if role is None:
|
||||
raise HTTPException(status_code=500, detail="No global role available (DB not seeded)")
|
||||
uname = _unique_username(db, username or (email.split("@")[0] if email else None) or f"tessera-{subject[:8]}")
|
||||
# Email is NOT NULL + unique; synthesise a stable placeholder if absent.
|
||||
eff_email = email or f"{subject}@tessera.local"
|
||||
if db.query(models.User).filter(models.User.email == eff_email).first():
|
||||
eff_email = f"{subject}@tessera.local"
|
||||
user = models.User(
|
||||
username=uname,
|
||||
email=eff_email,
|
||||
full_name=(claims.get("name") or username or uname),
|
||||
hashed_password=None,
|
||||
oidc_issuer=issuer,
|
||||
oidc_subject=subject,
|
||||
is_active=True,
|
||||
is_admin=is_admin,
|
||||
role_id=role.id,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
logger.info("Tessera: provisioned user '%s' (admin=%s) for subject %s", user.username, is_admin, subject)
|
||||
return user
|
||||
|
||||
if not user.is_active or user.username in ("acc-mgr", "deleted-user"):
|
||||
raise HTTPException(status_code=401, detail="User is not permitted to sign in")
|
||||
|
||||
# Keep admin status in sync with the token's realm/client roles on each
|
||||
# request so role changes in Tessera take effect without re-provisioning.
|
||||
if bool(user.is_admin) != is_admin:
|
||||
user.is_admin = is_admin
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def authenticate_tessera(db: Session, token: str) -> models.User:
|
||||
"""Verify a Tessera bearer token and return the resolved hf User."""
|
||||
claims = verify_tessera_token(token)
|
||||
return resolve_or_provision_user(db, claims)
|
||||
@@ -22,6 +22,17 @@ class Settings(BaseSettings):
|
||||
# in via a bound OIDC identity / API keys), frontend hides password UI.
|
||||
HARBORFORGE_OIDC_ONLY: bool = False
|
||||
|
||||
# Mark the OIDC state/session cookie Secure (HTTPS-only). Defaults to True
|
||||
# for production; set SESSION_COOKIE_SECURE=false for plain-HTTP local dev.
|
||||
SESSION_COOKIE_SECURE: bool = True
|
||||
|
||||
# External OIDC provider ("Tessera", Keycloak-compatible) whose RS256
|
||||
# access tokens are accepted as API bearer tokens (additive to local
|
||||
# HS256 JWT + API keys). Tokens are verified against the issuer's JWKS;
|
||||
# `iss` must equal TESSERA_ISSUER and `aud` must contain TESSERA_AUDIENCE.
|
||||
TESSERA_ISSUER: str = "https://login.hangman-lab.top/realms/Hangman-Lab"
|
||||
TESSERA_AUDIENCE: str = "harbor-forge"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
15
app/main.py
15
app/main.py
@@ -27,7 +27,7 @@ app.add_middleware(
|
||||
secret_key=settings.SECRET_KEY,
|
||||
session_cookie="hf_oidc",
|
||||
same_site="lax",
|
||||
https_only=False,
|
||||
https_only=settings.SESSION_COOKIE_SECURE,
|
||||
max_age=600,
|
||||
)
|
||||
|
||||
@@ -451,6 +451,19 @@ def _migrate_schema():
|
||||
"CREATE INDEX idx_time_slots_special_slot_id ON time_slots (special_slot_id)"
|
||||
))
|
||||
|
||||
# --- api_keys: migrate legacy plaintext `key` -> hashed `key_hash` ---
|
||||
# Only runs on deployments that still have the old plaintext column;
|
||||
# fresh installs get key_hash/key_prefix directly from create_all.
|
||||
if _has_table(db, "api_keys") and _has_column(db, "api_keys", "key"):
|
||||
if not _has_column(db, "api_keys", "key_hash"):
|
||||
db.execute(text("ALTER TABLE api_keys ADD COLUMN key_hash VARCHAR(64) NULL"))
|
||||
if not _has_column(db, "api_keys", "key_prefix"):
|
||||
db.execute(text("ALTER TABLE api_keys ADD COLUMN key_prefix VARCHAR(16) NULL"))
|
||||
db.execute(text("ALTER TABLE api_keys MODIFY COLUMN `key` VARCHAR(64) NULL"))
|
||||
db.execute(text("UPDATE api_keys SET key_hash = SHA2(`key`, 256), key_prefix = LEFT(`key`, 8) WHERE key_hash IS NULL AND `key` IS NOT NULL"))
|
||||
db.execute(text("UPDATE api_keys SET `key` = NULL WHERE `key` IS NOT NULL"))
|
||||
_ensure_unique_index(db, "api_keys", "idx_api_keys_key_hash", "key_hash")
|
||||
|
||||
# --- schedule_type_special_slots: create-table is handled by
|
||||
# Base.metadata.create_all on first boot; no migration needed here
|
||||
# because there is no legacy table to evolve. Future schema bumps
|
||||
|
||||
@@ -7,7 +7,10 @@ class APIKey(Base):
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
key = Column(String(64), unique=True, nullable=False, index=True)
|
||||
# The raw key is never stored — only its SHA-256 hash. `key_prefix` holds
|
||||
# the first few chars for human-readable display/masking in listings.
|
||||
key_hash = Column(String(64), unique=True, nullable=False, index=True)
|
||||
key_prefix = Column(String(16), nullable=True)
|
||||
name = Column(String(100), nullable=False) # e.g. "agent-zhi", "agent-lyn"
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
@@ -105,7 +105,9 @@ class CommentBase(BaseModel):
|
||||
|
||||
class CommentCreate(CommentBase):
|
||||
task_id: int
|
||||
author_id: int
|
||||
# author_id is NOT accepted from the client — the comment is always
|
||||
# attributed to the authenticated caller (server-side) to prevent
|
||||
# author spoofing.
|
||||
|
||||
|
||||
class CommentUpdate(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user