feat: add scoped read-only public API
This commit is contained in:
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user