4 Commits

Author SHA1 Message Date
0bdc432215 Accept Tessera (Keycloak-compatible) OIDC tokens as API bearer
Adds an additive bearer-verification path: verify RS256 access tokens against
Tessera's JWKS (iss/aud/exp), map sub/preferred_username/email + roles
(realm_access.roles, resource_access.<audience>.roles) to the app's identity.
Existing auth (API keys / app JWTs / sessions) is unchanged. Issuer + audience
are env-configurable. Validated end-to-end against the local sim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 15:11:31 +01:00
1a5a3ed1b1 Merge fix/security-audit: RBAC/API-key-hash/cookie hardening 2026-06-01 09:23:35 +01:00
16199c9280 Merge feat/knowledge-base: KnowledgeBase feature 2026-06-01 09:23:35 +01:00
3f5f813c65 fix(security): RBAC on legacy create endpoints, hashed API keys, hardening
Addresses findings from the security audit:
- H1: add check_project_role to the legacy misc.py create endpoints
  (milestones=mgr, tasks/supports/meetings=dev) that previously required
  only authentication — closing a cross-project write bypass available to
  any logged-in user or agent API key.
- M2: comments are always attributed to the authenticated caller; the
  client-supplied author_id is dropped (no author spoofing).
- M3: API keys are stored as SHA-256 hashes (key_hash) plus a short
  key_prefix for display — never plaintext. Lookup hashes the presented
  key; listings never expose the secret. Includes an idempotent migration
  for existing deployments.
- M5: the OIDC session cookie's Secure flag is env-driven via
  SESSION_COOKIE_SECURE (default True; set false for plain-HTTP dev).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:16:11 +01:00
9 changed files with 340 additions and 30 deletions

View File

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

View File

@@ -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)

View File

@@ -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:

View File

@@ -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
View 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)

View File

@@ -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"

View File

@@ -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

View File

@@ -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)

View File

@@ -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):