feat: add scoped read-only public API

This commit is contained in:
Christian Krakau-Louis
2026-05-23 17:39:34 +02:00
parent 312808a002
commit ee663afffb
15 changed files with 645 additions and 24 deletions
+4
View File
@@ -1,12 +1,14 @@
from fastapi import APIRouter
from app.api.api_v1.endpoints import (
api_tokens,
auth,
domains,
forensics,
health,
imap,
mail_sources,
public,
reports,
settings,
setup,
@@ -19,7 +21,9 @@ api_router = APIRouter()
# Include all endpoint routers
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(api_tokens.router, prefix="/api-tokens", tags=["api-tokens"])
api_router.include_router(health.router, tags=["health"])
api_router.include_router(public.router, prefix="/public", tags=["public-api"])
api_router.include_router(domains.router, prefix="/domains", tags=["domains"])
api_router.include_router(reports.router, prefix="/reports", tags=["reports"])
api_router.include_router(forensics.router, prefix="/forensics", tags=["forensics"])
@@ -0,0 +1,103 @@
"""Admin API token management endpoints."""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import require_admin_auth
from app.models.api_token import APIToken
from app.services.api_tokens import (
PUBLIC_READ_SCOPES,
create_api_token,
revoke_api_token,
token_to_dict,
)
router = APIRouter()
class APITokenCreateRequest(BaseModel):
"""Request body for creating a scoped API token."""
name: str = Field(..., min_length=1, max_length=120)
scopes: List[str] = Field(default_factory=lambda: sorted(PUBLIC_READ_SCOPES))
class APITokenResponse(BaseModel):
"""API-safe token metadata."""
id: int
name: str
key_prefix: str
scopes: List[str]
active: bool
created_at: str
last_used_at: Optional[str] = None
last_used_ip: Optional[str] = None
usage_count: int
revoked_at: Optional[str] = None
class APITokenCreateResponse(BaseModel):
"""New token response. The secret is returned once."""
token: str
metadata: APITokenResponse
class APITokenListResponse(BaseModel):
"""List of API token metadata rows."""
tokens: List[APITokenResponse]
available_scopes: List[str]
@router.get("", response_model=APITokenListResponse)
async def list_api_tokens(
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
):
"""List API token metadata without exposing raw secrets or hashes."""
rows = db.query(APIToken).order_by(APIToken.created_at.desc(), APIToken.id.desc()).all()
return APITokenListResponse(
tokens=[APITokenResponse(**token_to_dict(row)) for row in rows],
available_scopes=sorted(PUBLIC_READ_SCOPES),
)
@router.post("", response_model=APITokenCreateResponse, status_code=status.HTTP_201_CREATED)
async def create_public_api_token(
payload: APITokenCreateRequest,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
):
"""Create a scoped API token for read-only automation."""
try:
created = create_api_token(db, name=payload.name, scopes=payload.scopes)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
return APITokenCreateResponse(
token=created.secret,
metadata=APITokenResponse(**token_to_dict(created.token)),
)
@router.delete("/{token_id}", status_code=status.HTTP_200_OK)
async def revoke_public_api_token(
token_id: int,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
):
"""Revoke a scoped API token."""
if not revoke_api_token(db, token_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="API token not found",
)
return {"revoked": True}
@@ -0,0 +1,72 @@
"""Stable read-only public API endpoints."""
from typing import Optional
from fastapi import APIRouter, Depends, Path, Query
from sqlalchemy.orm import Session
from app.api.api_v1.endpoints import domains, tls_reports
from app.core.database import get_db
from app.core.security import require_api_token_scope
from app.services.api_tokens import READ_POSTURE_SCOPE, READ_REPORTS_SCOPE, READ_TLS_SCOPE
router = APIRouter()
@router.get("/domains", response_model=domains.DomainSummaryResponse)
async def public_domain_summary(
db: Session = Depends(get_db),
_auth: dict = Depends(require_api_token_scope(READ_REPORTS_SCOPE)),
):
"""List monitored domains with report and DNS posture summary fields."""
return await domains.get_domains_summary(db=db)
@router.get(
"/domains/{domain_id}/posture",
response_model=domains.PostureDashboardResponse,
)
async def public_domain_posture(
domain_id: str = Path(..., title="The domain ID or name"),
refresh: bool = Query(False, title="Refresh cached DNS posture"),
db: Session = Depends(get_db),
_auth: dict = Depends(require_api_token_scope(READ_POSTURE_SCOPE)),
):
"""Return the stable evidence-first posture payload for one domain."""
return await domains.get_domain_posture_dashboard(
domain_id=domain_id,
refresh=refresh,
db=db,
)
@router.get(
"/domains/{domain_id}/reports",
response_model=domains.DomainReportsResponse,
)
async def public_domain_reports(
domain_id: str = Path(..., title="The domain ID or name"),
limit: int = Query(10, ge=1, le=200),
db: Session = Depends(get_db),
_auth: dict = Depends(require_api_token_scope(READ_REPORTS_SCOPE)),
):
"""Return recent DMARC aggregate report summaries for one domain."""
return await domains.get_domain_reports(domain_id=domain_id, limit=limit, db=db)
@router.get("/tls-reports/summary", response_model=tls_reports.TLSSummaryResponse)
async def public_tls_report_summary(
domain: Optional[str] = Query(default=None),
days: int = Query(default=30, ge=1, le=365),
limit: int = Query(default=10, ge=1, le=50),
db: Session = Depends(get_db),
_auth: dict = Depends(require_api_token_scope(READ_TLS_SCOPE)),
):
"""Return aggregate SMTP TLS reporting posture trends."""
return await tls_reports.tls_report_summary(
domain=domain,
days=days,
limit=limit,
db=db,
_auth=_auth,
)
+49 -2
View File
@@ -2,14 +2,17 @@ import logging
import os
import secrets
from datetime import datetime, timedelta
from typing import Any, Optional, Union
from typing import Any, Callable, Optional, Union
from fastapi import HTTPException, Request, Security, status
from fastapi import Depends, HTTPException, Request, Security, status
from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.database import get_db
from app.services.api_tokens import find_api_token, parse_scopes, record_api_token_use
settings = get_settings()
logger = logging.getLogger(__name__)
@@ -220,6 +223,50 @@ async def require_admin_auth(
)
def require_api_token_scope(required_scope: str) -> Callable:
"""Build a dependency that requires a scoped persistent API token."""
async def _require_api_token_scope(
request: Request,
db: Session = Depends(get_db),
api_key: Optional[str] = Security(api_key_header),
) -> dict:
if not api_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing API token",
headers={"WWW-Authenticate": "ApiKey"},
)
token = find_api_token(db, api_key)
if token is None:
suffix = api_key[-8:] if len(api_key) >= 8 else "invalid"
logger.warning("Invalid public API token attempt: ...%s", suffix)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API token",
headers={"WWW-Authenticate": "ApiKey"},
)
scopes = parse_scopes(token.scopes)
if required_scope not in scopes:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"API token requires scope: {required_scope}",
)
client_host = request.client.host if request.client else None
record_api_token_use(db, token, ip_address=client_host)
return {
"auth_type": "api_token",
"token_id": token.id,
"token_name": token.name,
"scopes": sorted(scopes),
}
return _require_api_token_scope
def create_access_token(subject: Union[str, Any], expires_delta: timedelta = None) -> str:
"""
Create a JWT access token for authentication
+1
View File
@@ -12,6 +12,7 @@ from fastapi.templating import Jinja2Templates
from starlette.concurrency import run_in_threadpool
import app.models.alert # noqa: F401 ensure AlertHistory table is registered
import app.models.api_token # noqa: F401 ensure APIToken table is registered
import app.models.dns_cache # noqa: F401 ensure DNSCache table is registered
import app.models.domain # noqa: F401 ensure Domain/UserDomain tables are registered
import app.models.mail_source_import # noqa: F401 ensure import history table is registered
+32
View File
@@ -0,0 +1,32 @@
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Index, Integer, String, Text
from app.core.database import Base
class APIToken(Base):
"""Scoped API token for stable automation access."""
__tablename__ = "api_tokens"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(120), nullable=False)
key_hash = Column(String(64), unique=True, nullable=False, index=True)
key_prefix = Column(String(16), nullable=False, index=True)
scopes = Column(Text, nullable=False)
active = Column(Boolean, default=True, nullable=False, index=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
revoked_at = Column(DateTime, nullable=True, index=True)
last_used_at = Column(DateTime, nullable=True, index=True)
last_used_ip = Column(String(64), nullable=True)
usage_count = Column(Integer, default=0, nullable=False)
__table_args__ = (
Index("ix_api_tokens_active_scope", "active", "scopes"),
Index("ix_api_tokens_last_used", "last_used_at"),
)
def __repr__(self):
return f"<APIToken {self.name} active={self.active}>"
+127
View File
@@ -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,
}
+1
View File
@@ -7,6 +7,7 @@ from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
import app.models.alert # noqa: F401 # pylint: disable=unused-import
import app.models.api_token # noqa: F401 # pylint: disable=unused-import
import app.models.dns_cache # noqa: F401 # pylint: disable=unused-import
import app.models.domain # noqa: F401 # pylint: disable=unused-import
import app.models.mail_source as _mail_source_model # noqa: F401 # pylint: disable=unused-import
+105
View File
@@ -0,0 +1,105 @@
from fastapi.testclient import TestClient
from app.models.api_token import APIToken
from app.services.api_tokens import READ_POSTURE_SCOPE, READ_REPORTS_SCOPE, create_api_token
from app.services.report_store import ReportStore
DOMAIN = "example.com"
MINIMAL_REPORT = {
"domain": DOMAIN,
"report_id": "public-api-001",
"org_name": "Test Org",
"policy": {"p": "none", "sp": "", "pct": "100"},
"records": [
{
"source_ip": "1.2.3.4",
"count": 5,
"disposition": "none",
"dkim_result": "pass",
"spf_result": "pass",
"dkim": [{"domain": DOMAIN, "result": "pass", "selector": "google"}],
"spf": [{"domain": DOMAIN, "result": "pass"}],
}
],
"summary": {"total_count": 5, "passed_count": 5, "failed_count": 0, "pass_rate": 100.0},
}
def _seed_report_store():
ReportStore.get_instance().add_report(MINIMAL_REPORT)
def test_public_reports_api_requires_scoped_token(client: TestClient, db_session):
"""Stable public report endpoints require scoped tokens and audit usage."""
_seed_report_store()
created = create_api_token(db_session, name="report bot", scopes=[READ_REPORTS_SCOPE])
missing = client.get(f"/api/v1/public/domains/{DOMAIN}/reports")
assert missing.status_code == 401
invalid = client.get(
f"/api/v1/public/domains/{DOMAIN}/reports",
headers={"X-API-Key": "not-valid"},
)
assert invalid.status_code == 401
response = client.get(
f"/api/v1/public/domains/{DOMAIN}/reports",
headers={"X-API-Key": created.secret},
)
assert response.status_code == 200
assert response.json()["reports"][0]["id"] == "public-api-001"
token = db_session.query(APIToken).filter(APIToken.id == created.token.id).one()
assert token.usage_count == 1
assert token.last_used_at is not None
assert token.last_used_ip
def test_public_api_rejects_token_without_required_scope(client: TestClient, db_session):
"""Tokens are least-privilege: reports scope cannot read posture payloads."""
_seed_report_store()
created = create_api_token(db_session, name="report bot", scopes=[READ_REPORTS_SCOPE])
response = client.get(
f"/api/v1/public/domains/{DOMAIN}/posture",
headers={"X-API-Key": created.secret},
)
assert response.status_code == 403
assert response.json()["detail"] == f"API token requires scope: {READ_POSTURE_SCOPE}"
def test_admin_can_create_list_and_revoke_api_tokens(authed_client: TestClient, db_session):
"""Admin token management never returns stored hashes and revocation disables access."""
_seed_report_store()
created = authed_client.post(
"/api/v1/api-tokens",
json={"name": "automation", "scopes": [READ_REPORTS_SCOPE]},
)
assert created.status_code == 201
body = created.json()
assert body["token"].startswith("dmarq_")
assert body["metadata"]["scopes"] == [READ_REPORTS_SCOPE]
assert "key_hash" not in body["metadata"]
listed = authed_client.get("/api/v1/api-tokens")
assert listed.status_code == 200
assert listed.json()["tokens"][0]["name"] == "automation"
assert "key_hash" not in listed.text
allowed = authed_client.get(
f"/api/v1/public/domains/{DOMAIN}/reports",
headers={"X-API-Key": body["token"]},
)
assert allowed.status_code == 200
revoked = authed_client.delete(f"/api/v1/api-tokens/{body['metadata']['id']}")
assert revoked.status_code == 200
denied = authed_client.get(
f"/api/v1/public/domains/{DOMAIN}/reports",
headers={"X-API-Key": body["token"]},
)
assert denied.status_code == 401