feat: add scoped read-only public API
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
"""Persistent scoped API token helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Iterable, List, Optional, Set
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.api_token import APIToken
|
||||
|
||||
READ_REPORTS_SCOPE = "reports:read"
|
||||
READ_POSTURE_SCOPE = "posture:read"
|
||||
READ_TLS_SCOPE = "tls-reports:read"
|
||||
|
||||
PUBLIC_READ_SCOPES = {
|
||||
READ_REPORTS_SCOPE,
|
||||
READ_POSTURE_SCOPE,
|
||||
READ_TLS_SCOPE,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CreatedAPIToken:
|
||||
"""Return value for newly created API tokens."""
|
||||
|
||||
token: APIToken
|
||||
secret: str
|
||||
|
||||
|
||||
def normalize_scopes(scopes: Iterable[str]) -> List[str]:
|
||||
"""Normalize and validate requested API token scopes."""
|
||||
normalized = sorted({scope.strip().lower() for scope in scopes if scope and scope.strip()})
|
||||
invalid = [scope for scope in normalized if scope not in PUBLIC_READ_SCOPES]
|
||||
if invalid:
|
||||
raise ValueError(f"Unsupported API token scope: {', '.join(invalid)}")
|
||||
if not normalized:
|
||||
raise ValueError("At least one API token scope is required")
|
||||
return normalized
|
||||
|
||||
|
||||
def scopes_to_string(scopes: Iterable[str]) -> str:
|
||||
"""Serialize scopes for storage."""
|
||||
return ",".join(normalize_scopes(scopes))
|
||||
|
||||
|
||||
def parse_scopes(value: str) -> Set[str]:
|
||||
"""Parse stored scope text into a set."""
|
||||
return {scope.strip().lower() for scope in (value or "").split(",") if scope.strip()}
|
||||
|
||||
|
||||
def generate_public_api_key() -> str:
|
||||
"""Generate an operator-facing API token secret."""
|
||||
return f"dmarq_{secrets.token_urlsafe(32)}"
|
||||
|
||||
|
||||
def hash_api_key(secret: str) -> str:
|
||||
"""Hash an API token for database storage."""
|
||||
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def create_api_token(db: Session, *, name: str, scopes: Iterable[str]) -> CreatedAPIToken:
|
||||
"""Create a persistent API token and return the raw secret once."""
|
||||
clean_name = name.strip()
|
||||
if not clean_name:
|
||||
raise ValueError("Token name is required")
|
||||
secret = generate_public_api_key()
|
||||
token = APIToken(
|
||||
name=clean_name,
|
||||
key_hash=hash_api_key(secret),
|
||||
key_prefix=secret[:12],
|
||||
scopes=scopes_to_string(scopes),
|
||||
active=True,
|
||||
)
|
||||
db.add(token)
|
||||
db.commit()
|
||||
db.refresh(token)
|
||||
return CreatedAPIToken(token=token, secret=secret)
|
||||
|
||||
|
||||
def find_api_token(db: Session, secret: str) -> Optional[APIToken]:
|
||||
"""Return the active token row matching *secret*, if any."""
|
||||
if not secret:
|
||||
return None
|
||||
return (
|
||||
db.query(APIToken)
|
||||
.filter(APIToken.key_hash == hash_api_key(secret), APIToken.active == True) # noqa: E712
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def record_api_token_use(db: Session, token: APIToken, *, ip_address: Optional[str]) -> None:
|
||||
"""Persist minimal audit data for a successful API token use."""
|
||||
token.last_used_at = datetime.utcnow()
|
||||
token.last_used_ip = ip_address
|
||||
token.usage_count = int(token.usage_count or 0) + 1
|
||||
db.commit()
|
||||
|
||||
|
||||
def revoke_api_token(db: Session, token_id: int) -> bool:
|
||||
"""Deactivate an API token by id."""
|
||||
token = db.query(APIToken).filter(APIToken.id == token_id).first()
|
||||
if token is None or not token.active:
|
||||
return False
|
||||
token.active = False
|
||||
token.revoked_at = datetime.utcnow()
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def token_to_dict(token: APIToken) -> dict:
|
||||
"""Return an API-safe token representation without the secret hash."""
|
||||
return {
|
||||
"id": token.id,
|
||||
"name": token.name,
|
||||
"key_prefix": token.key_prefix,
|
||||
"scopes": sorted(parse_scopes(token.scopes)),
|
||||
"active": token.active,
|
||||
"created_at": token.created_at.isoformat() if token.created_at else None,
|
||||
"last_used_at": token.last_used_at.isoformat() if token.last_used_at else None,
|
||||
"last_used_ip": token.last_used_ip,
|
||||
"usage_count": token.usage_count,
|
||||
"revoked_at": token.revoked_at.isoformat() if token.revoked_at else None,
|
||||
}
|
||||
Reference in New Issue
Block a user