mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 22:20:58 +08:00
Add capability RBAC, Alembic bootstrap, optional worker schedulers, and close public docs by default so lab installs stay usable without shared signing keys. Co-authored-by: Cursor <cursoragent@cursor.com>
510 lines
17 KiB
Python
510 lines
17 KiB
Python
"""Auth domain service: bootstrap admin, users, API tokens, audit writes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from fastapi import HTTPException
|
|
from sqlalchemy import func
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .auth_passwords import hash_password, verify_password
|
|
from .auth_scopes import (
|
|
MCP_DEFAULT_SCOPES,
|
|
effective_user_scopes,
|
|
normalize_scopes,
|
|
)
|
|
from .auth_tokens import hash_api_token, issue_access_token, new_api_token_plaintext
|
|
from .config import settings
|
|
from .models import ApiToken, AppUser, AuditLog
|
|
|
|
_log = logging.getLogger("netx.auth")
|
|
|
|
_USERNAME_RE = re.compile(r"^[A-Za-z0-9._@-]{2,64}$")
|
|
_SECRET_KEYS = frozenset(
|
|
{
|
|
"password",
|
|
"password_hash",
|
|
"hop_password",
|
|
"enable_secret",
|
|
"access_token",
|
|
"token",
|
|
"authorization",
|
|
"secret",
|
|
"credential_secret_key",
|
|
}
|
|
)
|
|
|
|
|
|
def user_public(user: AppUser) -> dict[str, Any]:
|
|
scopes = sorted(
|
|
effective_user_scopes(role=str(user.role or "user"), override=getattr(user, "scopes", None) or [])
|
|
)
|
|
return {
|
|
"id": user.id,
|
|
"username": user.username,
|
|
"role": user.role,
|
|
"scopes": scopes,
|
|
"scopes_override": normalize_scopes(getattr(user, "scopes", None) or []),
|
|
"is_active": bool(user.is_active),
|
|
"must_change_password": bool(getattr(user, "must_change_password", False)),
|
|
"created_by": user.created_by or "",
|
|
"created_at": user.created_at.isoformat() if user.created_at else None,
|
|
"updated_at": user.updated_at.isoformat() if user.updated_at else None,
|
|
}
|
|
|
|
|
|
def sanitize_detail(detail: Any) -> Any:
|
|
"""Recursively drop secret-looking keys from audit detail payloads."""
|
|
if isinstance(detail, dict):
|
|
out: dict[str, Any] = {}
|
|
for k, v in detail.items():
|
|
key = str(k).lower()
|
|
if key in _SECRET_KEYS or key.endswith("_password") or key.endswith("_secret"):
|
|
out[k] = "***"
|
|
else:
|
|
out[k] = sanitize_detail(v)
|
|
return out
|
|
if isinstance(detail, list):
|
|
return [sanitize_detail(x) for x in detail[:50]]
|
|
if isinstance(detail, str) and len(detail) > 2000:
|
|
return detail[:2000] + "…"
|
|
return detail
|
|
|
|
|
|
def write_audit(
|
|
db: Session,
|
|
*,
|
|
action: str,
|
|
actor_user_id: str = "",
|
|
actor_username: str = "",
|
|
method: str = "",
|
|
path: str = "",
|
|
status_code: int = 0,
|
|
client_ip: str = "",
|
|
user_agent: str = "",
|
|
detail: dict[str, Any] | None = None,
|
|
) -> None:
|
|
row = AuditLog(
|
|
actor_user_id=str(actor_user_id or ""),
|
|
actor_username=str(actor_username or ""),
|
|
action=str(action or "")[:128],
|
|
method=str(method or "")[:16],
|
|
path=str(path or "")[:512],
|
|
status_code=int(status_code or 0),
|
|
client_ip=str(client_ip or "")[:128],
|
|
user_agent=str(user_agent or "")[:512],
|
|
detail=sanitize_detail(detail or {}),
|
|
)
|
|
db.add(row)
|
|
try:
|
|
db.commit()
|
|
except Exception:
|
|
db.rollback()
|
|
_log.exception("audit_log write failed action=%s", action)
|
|
|
|
|
|
def flag_default_password_users(db: Session) -> None:
|
|
"""Mark accounts still on the bootstrap default password as must_change_password."""
|
|
default_pwd = str(settings.bootstrap_admin_password or "admin123").strip() or "admin123"
|
|
changed = 0
|
|
for user in db.query(AppUser).filter(AppUser.is_active.is_(True)).all():
|
|
if bool(getattr(user, "must_change_password", False)):
|
|
continue
|
|
if verify_password(default_pwd, user.password_hash):
|
|
user.must_change_password = True
|
|
user.updated_at = datetime.utcnow()
|
|
changed += 1
|
|
if changed:
|
|
db.commit()
|
|
_log.warning("flagged %s user(s) still using default password to must_change_password", changed)
|
|
|
|
|
|
def bootstrap_admin_if_needed(db: Session) -> None:
|
|
"""Create the first admin when app_user is empty."""
|
|
count = int(db.query(func.count(AppUser.id)).scalar() or 0)
|
|
if count > 0:
|
|
flag_default_password_users(db)
|
|
ensure_default_mcp_token(db)
|
|
return
|
|
username = str(settings.bootstrap_admin_username or "admin").strip() or "admin"
|
|
password = str(settings.bootstrap_admin_password or "admin123").strip() or "admin123"
|
|
if password == "admin123":
|
|
_log.warning(
|
|
"bootstrapping admin %r with default password admin123; change after first login",
|
|
username,
|
|
)
|
|
if not _USERNAME_RE.match(username):
|
|
raise RuntimeError(f"invalid_bootstrap_admin_username:{username}")
|
|
user = AppUser(
|
|
username=username,
|
|
password_hash=hash_password(password),
|
|
role="admin",
|
|
is_active=True,
|
|
must_change_password=True,
|
|
created_by="bootstrap",
|
|
)
|
|
db.add(user)
|
|
db.commit()
|
|
write_audit(
|
|
db,
|
|
action="auth.bootstrap_admin",
|
|
actor_user_id=user.id,
|
|
actor_username=user.username,
|
|
detail={"username": username, "must_change_password": True},
|
|
)
|
|
_log.info("bootstrapped admin user %r id=%s", username, user.id)
|
|
ensure_default_mcp_token(db, user=user)
|
|
|
|
|
|
def mcp_token_file_path() -> Path:
|
|
raw = str(settings.auth_mcp_token_file or "data/auth/mcp_token").strip()
|
|
path = Path(raw)
|
|
if not path.is_absolute():
|
|
path = Path.cwd() / path
|
|
return path
|
|
|
|
|
|
def ensure_default_mcp_token(db: Session, user: AppUser | None = None) -> str | None:
|
|
"""Ensure a default API token file exists for MCP (lab convenience).
|
|
|
|
Returns plaintext token when created or when file already present; None on failure.
|
|
"""
|
|
path = mcp_token_file_path()
|
|
try:
|
|
if path.is_file():
|
|
existing = path.read_text(encoding="utf-8").strip()
|
|
if existing.startswith("nxt_"):
|
|
# Keep DB in sync if token was wiped from DB but file remains.
|
|
th = hash_api_token(existing)
|
|
row = (
|
|
db.query(ApiToken)
|
|
.filter(ApiToken.token_hash == th, ApiToken.revoked_at.is_(None))
|
|
.one_or_none()
|
|
)
|
|
if row is not None:
|
|
# Ensure MCP bootstrap token stays within MCP_DEFAULT_SCOPES.
|
|
desired = normalize_scopes(MCP_DEFAULT_SCOPES)
|
|
current = normalize_scopes(getattr(row, "scopes", None) or [])
|
|
if current != desired:
|
|
row.scopes = desired
|
|
try:
|
|
db.commit()
|
|
except Exception:
|
|
db.rollback()
|
|
return existing
|
|
except Exception:
|
|
_log.exception("read mcp token file failed path=%s", path)
|
|
|
|
admin = user
|
|
if admin is None:
|
|
admin = (
|
|
db.query(AppUser)
|
|
.filter(AppUser.role == "admin", AppUser.is_active.is_(True))
|
|
.order_by(AppUser.created_at.asc())
|
|
.first()
|
|
)
|
|
if admin is None:
|
|
return None
|
|
try:
|
|
row, plaintext = create_api_token(
|
|
db,
|
|
user=admin,
|
|
name="mcp-default",
|
|
expires_in_days=0,
|
|
scopes=list(MCP_DEFAULT_SCOPES),
|
|
)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(plaintext + "\n", encoding="utf-8")
|
|
try:
|
|
path.chmod(0o600)
|
|
except Exception:
|
|
pass
|
|
write_audit(
|
|
db,
|
|
action="api_tokens.bootstrap_mcp",
|
|
actor_user_id=admin.id,
|
|
actor_username=admin.username,
|
|
detail={"token_id": row.id, "name": row.name, "file": str(path)},
|
|
)
|
|
_log.info("wrote default MCP API token to %s", path)
|
|
return plaintext
|
|
except Exception:
|
|
_log.exception("ensure_default_mcp_token failed")
|
|
return None
|
|
|
|
|
|
def get_user_by_id(db: Session, user_id: str) -> AppUser | None:
|
|
return db.query(AppUser).filter(AppUser.id == str(user_id or "")).one_or_none()
|
|
|
|
|
|
def get_user_by_username(db: Session, username: str) -> AppUser | None:
|
|
return db.query(AppUser).filter(AppUser.username == str(username or "").strip()).one_or_none()
|
|
|
|
|
|
def authenticate_user(db: Session, username: str, password: str) -> AppUser | None:
|
|
user = get_user_by_username(db, username)
|
|
if user is None or not user.is_active:
|
|
return None
|
|
if not verify_password(password, user.password_hash):
|
|
return None
|
|
return user
|
|
|
|
|
|
def login_issue_token(user: AppUser) -> dict[str, Any]:
|
|
token = issue_access_token(user_id=user.id, username=user.username, role=user.role)
|
|
return {
|
|
"access_token": token,
|
|
"token_type": "bearer",
|
|
"user": user_public(user),
|
|
}
|
|
|
|
|
|
def list_users(db: Session) -> list[dict[str, Any]]:
|
|
rows = db.query(AppUser).order_by(AppUser.created_at.asc()).all()
|
|
return [user_public(u) for u in rows]
|
|
|
|
|
|
def create_user(
|
|
db: Session,
|
|
*,
|
|
username: str,
|
|
password: str,
|
|
role: str,
|
|
actor: AppUser,
|
|
scopes: list[str] | None = None,
|
|
) -> AppUser:
|
|
name = str(username or "").strip()
|
|
if not _USERNAME_RE.match(name):
|
|
raise HTTPException(status_code=400, detail="invalid_username")
|
|
pwd = str(password or "")
|
|
if len(pwd) < 6:
|
|
raise HTTPException(status_code=400, detail="password_too_short")
|
|
role_n = str(role or "user").strip().lower()
|
|
if role_n not in ("admin", "user"):
|
|
raise HTTPException(status_code=400, detail="invalid_role")
|
|
if get_user_by_username(db, name) is not None:
|
|
raise HTTPException(status_code=409, detail="username_exists")
|
|
scope_list = normalize_scopes(scopes) if scopes is not None else []
|
|
user = AppUser(
|
|
username=name,
|
|
password_hash=hash_password(pwd),
|
|
role=role_n,
|
|
scopes=scope_list,
|
|
is_active=True,
|
|
created_by=actor.id,
|
|
)
|
|
db.add(user)
|
|
db.commit()
|
|
db.refresh(user)
|
|
return user
|
|
|
|
|
|
def update_user(
|
|
db: Session,
|
|
*,
|
|
user_id: str,
|
|
actor: AppUser,
|
|
is_active: bool | None = None,
|
|
role: str | None = None,
|
|
password: str | None = None,
|
|
scopes: list[str] | None = None,
|
|
) -> AppUser:
|
|
user = get_user_by_id(db, user_id)
|
|
if user is None:
|
|
raise HTTPException(status_code=404, detail="user_not_found")
|
|
if user.id == actor.id and is_active is False:
|
|
raise HTTPException(status_code=400, detail="cannot_deactivate_self")
|
|
if role is not None:
|
|
role_n = str(role).strip().lower()
|
|
if role_n not in ("admin", "user"):
|
|
raise HTTPException(status_code=400, detail="invalid_role")
|
|
if user.id == actor.id and role_n != "admin":
|
|
raise HTTPException(status_code=400, detail="cannot_demote_self")
|
|
user.role = role_n
|
|
if is_active is not None:
|
|
user.is_active = bool(is_active)
|
|
if password is not None:
|
|
pwd = str(password)
|
|
if len(pwd) < 6:
|
|
raise HTTPException(status_code=400, detail="password_too_short")
|
|
user.password_hash = hash_password(pwd)
|
|
user.must_change_password = True
|
|
if scopes is not None:
|
|
user.scopes = normalize_scopes(scopes)
|
|
user.updated_at = datetime.utcnow()
|
|
db.commit()
|
|
db.refresh(user)
|
|
return user
|
|
|
|
|
|
def change_password(db: Session, *, user: AppUser, old_password: str, new_password: str) -> None:
|
|
row = get_user_by_id(db, str(user.id)) or user
|
|
if not verify_password(old_password, row.password_hash):
|
|
raise HTTPException(status_code=400, detail="old_password_incorrect")
|
|
pwd = str(new_password or "")
|
|
if len(pwd) < 6:
|
|
raise HTTPException(status_code=400, detail="password_too_short")
|
|
default_pwd = str(settings.bootstrap_admin_password or "admin123").strip() or "admin123"
|
|
if pwd == default_pwd or pwd == old_password:
|
|
raise HTTPException(status_code=400, detail="password_must_differ_from_default")
|
|
row.password_hash = hash_password(pwd)
|
|
row.must_change_password = False
|
|
row.updated_at = datetime.utcnow()
|
|
db.commit()
|
|
|
|
|
|
def create_api_token(
|
|
db: Session,
|
|
*,
|
|
user: AppUser,
|
|
name: str,
|
|
expires_in_days: int | None = None,
|
|
scopes: list[str] | None = None,
|
|
) -> tuple[ApiToken, str]:
|
|
label = str(name or "").strip() or "default"
|
|
if len(label) > 128:
|
|
raise HTTPException(status_code=400, detail="token_name_too_long")
|
|
expires_at: datetime | None = None
|
|
if expires_in_days is not None and int(expires_in_days) > 0:
|
|
expires_at = datetime.utcnow() + timedelta(days=int(expires_in_days))
|
|
plaintext = new_api_token_plaintext()
|
|
scope_list = normalize_scopes(scopes) if scopes is not None else []
|
|
# Cap token scopes to owner's effective scopes.
|
|
owner_scopes = effective_user_scopes(role=str(user.role or "user"), override=getattr(user, "scopes", None) or [])
|
|
if scope_list:
|
|
scope_list = sorted(frozenset(scope_list) & owner_scopes)
|
|
row = ApiToken(
|
|
name=label,
|
|
token_hash=hash_api_token(plaintext),
|
|
user_id=user.id,
|
|
scopes=scope_list,
|
|
expires_at=expires_at,
|
|
)
|
|
db.add(row)
|
|
db.commit()
|
|
db.refresh(row)
|
|
return row, plaintext
|
|
|
|
|
|
def _token_public(db: Session, r: ApiToken) -> dict[str, Any]:
|
|
owner = get_user_by_id(db, r.user_id)
|
|
now = datetime.utcnow()
|
|
expired = bool(r.expires_at and r.expires_at <= now)
|
|
return {
|
|
"id": r.id,
|
|
"name": r.name,
|
|
"user_id": r.user_id,
|
|
"username": owner.username if owner else "",
|
|
"scopes": normalize_scopes(getattr(r, "scopes", None) or []),
|
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
|
"expires_at": r.expires_at.isoformat() if r.expires_at else None,
|
|
"last_used_at": r.last_used_at.isoformat() if r.last_used_at else None,
|
|
"revoked_at": r.revoked_at.isoformat() if r.revoked_at else None,
|
|
"revoked": bool(r.revoked_at),
|
|
"expired": expired,
|
|
"active": (not bool(r.revoked_at)) and (not expired),
|
|
}
|
|
|
|
|
|
def list_api_tokens(db: Session, *, user_id: str | None = None) -> list[dict[str, Any]]:
|
|
q = db.query(ApiToken)
|
|
if user_id:
|
|
q = q.filter(ApiToken.user_id == user_id)
|
|
rows = q.order_by(ApiToken.created_at.desc()).all()
|
|
return [_token_public(db, r) for r in rows]
|
|
|
|
|
|
def revoke_api_token(db: Session, *, token_id: str, actor: AppUser) -> ApiToken:
|
|
row = db.query(ApiToken).filter(ApiToken.id == str(token_id)).one_or_none()
|
|
if row is None:
|
|
raise HTTPException(status_code=404, detail="api_token_not_found")
|
|
if actor.role != "admin" and row.user_id != actor.id:
|
|
raise HTTPException(status_code=403, detail="forbidden")
|
|
if row.revoked_at is None:
|
|
row.revoked_at = datetime.utcnow()
|
|
db.commit()
|
|
db.refresh(row)
|
|
return row
|
|
|
|
|
|
def resolve_api_token_row(db: Session, plaintext: str) -> ApiToken | None:
|
|
th = hash_api_token(plaintext)
|
|
row = (
|
|
db.query(ApiToken)
|
|
.filter(ApiToken.token_hash == th, ApiToken.revoked_at.is_(None))
|
|
.one_or_none()
|
|
)
|
|
if row is None:
|
|
return None
|
|
if row.expires_at is not None and row.expires_at <= datetime.utcnow():
|
|
return None
|
|
user = get_user_by_id(db, row.user_id)
|
|
if user is None or not user.is_active:
|
|
return None
|
|
row.last_used_at = datetime.utcnow()
|
|
try:
|
|
db.commit()
|
|
except Exception:
|
|
db.rollback()
|
|
return row
|
|
|
|
|
|
def resolve_api_token_user(db: Session, plaintext: str) -> AppUser | None:
|
|
row = resolve_api_token_row(db, plaintext)
|
|
if row is None:
|
|
return None
|
|
return get_user_by_id(db, row.user_id)
|
|
|
|
|
|
def list_audit_logs(
|
|
db: Session,
|
|
*,
|
|
actor: AppUser,
|
|
page: int = 1,
|
|
page_size: int = 50,
|
|
username: str = "",
|
|
action: str = "",
|
|
) -> dict[str, Any]:
|
|
page = max(1, int(page or 1))
|
|
page_size = max(1, min(200, int(page_size or 50)))
|
|
q = db.query(AuditLog)
|
|
if actor.role != "admin":
|
|
q = q.filter(AuditLog.actor_user_id == actor.id)
|
|
elif username.strip():
|
|
q = q.filter(AuditLog.actor_username == username.strip())
|
|
if action.strip():
|
|
q = q.filter(AuditLog.action.ilike(f"%{action.strip()}%"))
|
|
total = int(q.count())
|
|
rows = (
|
|
q.order_by(AuditLog.ts.desc())
|
|
.offset((page - 1) * page_size)
|
|
.limit(page_size)
|
|
.all()
|
|
)
|
|
items = [
|
|
{
|
|
"id": r.id,
|
|
"ts": r.ts.isoformat() if r.ts else None,
|
|
"actor_user_id": r.actor_user_id,
|
|
"actor_username": r.actor_username,
|
|
"action": r.action,
|
|
"method": r.method,
|
|
"path": r.path,
|
|
"status_code": r.status_code,
|
|
"client_ip": r.client_ip,
|
|
"user_agent": r.user_agent,
|
|
"detail": r.detail or {},
|
|
}
|
|
for r in rows
|
|
]
|
|
return {
|
|
"total": total,
|
|
"page": page,
|
|
"page_size": page_size,
|
|
"items": items,
|
|
}
|