From ee663afffb8b2ebbd4a07451d3eb69752099cd99 Mon Sep 17 00:00:00 2001 From: Christian Krakau-Louis Date: Sat, 23 May 2026 17:39:34 +0200 Subject: [PATCH 1/2] feat: add scoped read-only public API --- backend/alembic/env.py | 1 + .../0a1b2c3d4e5f_add_scoped_api_tokens.py | 62 +++++++++ backend/app/api/api_v1/api.py | 4 + .../app/api/api_v1/endpoints/api_tokens.py | 103 ++++++++++++++ backend/app/api/api_v1/endpoints/public.py | 72 ++++++++++ backend/app/core/security.py | 51 ++++++- backend/app/main.py | 1 + backend/app/models/api_token.py | 32 +++++ backend/app/services/api_tokens.py | 127 ++++++++++++++++++ backend/app/tests/conftest.py | 1 + backend/app/tests/test_public_api.py | 105 +++++++++++++++ docs/development/roadmap.md | 7 +- docs/milestones.md | 6 +- docs/reference/api.md | 71 +++++++++- docs/reference/database.md | 26 ++-- 15 files changed, 645 insertions(+), 24 deletions(-) create mode 100644 backend/alembic/versions/0a1b2c3d4e5f_add_scoped_api_tokens.py create mode 100644 backend/app/api/api_v1/endpoints/api_tokens.py create mode 100644 backend/app/api/api_v1/endpoints/public.py create mode 100644 backend/app/models/api_token.py create mode 100644 backend/app/services/api_tokens.py create mode 100644 backend/app/tests/test_public_api.py diff --git a/backend/alembic/env.py b/backend/alembic/env.py index f7281e9..d030b14 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -21,6 +21,7 @@ if database_url: config.set_main_option("sqlalchemy.url", _make_sync_db_url(database_url)) import app.models.alert # noqa: E402, F401 +import app.models.api_token # noqa: E402, F401 import app.models.dns_cache # noqa: E402, F401 import app.models.domain # noqa: E402, F401 import app.models.mail_source # noqa: E402, F401 diff --git a/backend/alembic/versions/0a1b2c3d4e5f_add_scoped_api_tokens.py b/backend/alembic/versions/0a1b2c3d4e5f_add_scoped_api_tokens.py new file mode 100644 index 0000000..716ea96 --- /dev/null +++ b/backend/alembic/versions/0a1b2c3d4e5f_add_scoped_api_tokens.py @@ -0,0 +1,62 @@ +"""add scoped api tokens + +Revision ID: 0a1b2c3d4e5f +Revises: f7a8b9c0d1e2 +Create Date: 2026-05-23 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "0a1b2c3d4e5f" +down_revision: Union[str, Sequence[str], None] = "f7a8b9c0d1e2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Create scoped API token storage.""" + op.create_table( + "api_tokens", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("key_hash", sa.String(length=64), nullable=False), + sa.Column("key_prefix", sa.String(length=16), nullable=False), + sa.Column("scopes", sa.Text(), nullable=False), + sa.Column("active", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.Column("revoked_at", sa.DateTime(), nullable=True), + sa.Column("last_used_at", sa.DateTime(), nullable=True), + sa.Column("last_used_ip", sa.String(length=64), nullable=True), + sa.Column("usage_count", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("key_hash"), + ) + op.create_index(op.f("ix_api_tokens_id"), "api_tokens", ["id"]) + op.create_index(op.f("ix_api_tokens_key_hash"), "api_tokens", ["key_hash"]) + op.create_index(op.f("ix_api_tokens_key_prefix"), "api_tokens", ["key_prefix"]) + op.create_index(op.f("ix_api_tokens_active"), "api_tokens", ["active"]) + op.create_index(op.f("ix_api_tokens_created_at"), "api_tokens", ["created_at"]) + op.create_index(op.f("ix_api_tokens_revoked_at"), "api_tokens", ["revoked_at"]) + op.create_index(op.f("ix_api_tokens_last_used_at"), "api_tokens", ["last_used_at"]) + op.create_index("ix_api_tokens_active_scope", "api_tokens", ["active", "scopes"]) + op.create_index("ix_api_tokens_last_used", "api_tokens", ["last_used_at"]) + + +def downgrade() -> None: + """Drop scoped API token storage.""" + op.drop_index("ix_api_tokens_last_used", table_name="api_tokens") + op.drop_index("ix_api_tokens_active_scope", table_name="api_tokens") + op.drop_index(op.f("ix_api_tokens_last_used_at"), table_name="api_tokens") + op.drop_index(op.f("ix_api_tokens_revoked_at"), table_name="api_tokens") + op.drop_index(op.f("ix_api_tokens_created_at"), table_name="api_tokens") + op.drop_index(op.f("ix_api_tokens_active"), table_name="api_tokens") + op.drop_index(op.f("ix_api_tokens_key_prefix"), table_name="api_tokens") + op.drop_index(op.f("ix_api_tokens_key_hash"), table_name="api_tokens") + op.drop_index(op.f("ix_api_tokens_id"), table_name="api_tokens") + op.drop_table("api_tokens") diff --git a/backend/app/api/api_v1/api.py b/backend/app/api/api_v1/api.py index db7bffd..f49a9a2 100644 --- a/backend/app/api/api_v1/api.py +++ b/backend/app/api/api_v1/api.py @@ -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"]) diff --git a/backend/app/api/api_v1/endpoints/api_tokens.py b/backend/app/api/api_v1/endpoints/api_tokens.py new file mode 100644 index 0000000..f2308ca --- /dev/null +++ b/backend/app/api/api_v1/endpoints/api_tokens.py @@ -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} diff --git a/backend/app/api/api_v1/endpoints/public.py b/backend/app/api/api_v1/endpoints/public.py new file mode 100644 index 0000000..96f833d --- /dev/null +++ b/backend/app/api/api_v1/endpoints/public.py @@ -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, + ) diff --git a/backend/app/core/security.py b/backend/app/core/security.py index eab7d9a..6ac558f 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index b73022c..0e581cc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/models/api_token.py b/backend/app/models/api_token.py new file mode 100644 index 0000000..ce8bdec --- /dev/null +++ b/backend/app/models/api_token.py @@ -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"" diff --git a/backend/app/services/api_tokens.py b/backend/app/services/api_tokens.py new file mode 100644 index 0000000..9ad3ca4 --- /dev/null +++ b/backend/app/services/api_tokens.py @@ -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, + } diff --git a/backend/app/tests/conftest.py b/backend/app/tests/conftest.py index 915d41c..1976b63 100644 --- a/backend/app/tests/conftest.py +++ b/backend/app/tests/conftest.py @@ -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 diff --git a/backend/app/tests/test_public_api.py b/backend/app/tests/test_public_api.py new file mode 100644 index 0000000..652ecef --- /dev/null +++ b/backend/app/tests/test_public_api.py @@ -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 diff --git a/docs/development/roadmap.md b/docs/development/roadmap.md index 802874d..78743b7 100644 --- a/docs/development/roadmap.md +++ b/docs/development/roadmap.md @@ -87,6 +87,7 @@ Follow-up: - DNS health and Cloudflare read-only inspection are in place, including zone import, record recommendations, and DNS change tracking. - Guided setup and operator health screens. - Forensic/RUF report support. +- Email security posture beyond DMARC, including MTA-STS, TLS-RPT, BIMI, and evidence-first posture playbooks. See [milestones.md](../milestones.md) for the full milestone breakdown and exit criteria. @@ -120,9 +121,9 @@ The milestone breakdown in `docs/milestones.md` is intentionally focused on exit ### Priority (Now / Next / Later) -- **Now**: finish Milestones 8–9 (DNS health guidance + setup/ops polish). -- **Next**: Milestones 10–12 (failure reports, DMARC format compatibility, Microsoft 365 ingestion). -- **Later**: Milestones 13–16 (posture suite beyond DMARC, public API/webhooks, MSP/workspaces, AI/MCP). +- **Now**: finish Milestone 14 (public API, webhooks, and integration templates). +- **Next**: Milestone 15 (workspaces/MSP governance). +- **Later**: Milestone 16 (optional AI/MCP automation). ### Tentative Release Plan (Subject to Change) diff --git a/docs/milestones.md b/docs/milestones.md index 899cdab..cf1dc99 100644 --- a/docs/milestones.md +++ b/docs/milestones.md @@ -214,7 +214,7 @@ Exit criteria: ## Milestone 13: Email Security Posture (Beyond DMARC) -Status: In Progress +Status: Complete Goal: turn DMARQ into a broader email authentication posture console (still privacy-first and self-hostable). @@ -230,12 +230,12 @@ Exit criteria: ## Milestone 14: Public API, Webhooks, and Core Integrations -Status: Backlog +Status: In progress Goal: let DMARQ integrate cleanly into existing security and operations workflows. Planned: -- A stable, documented read-only API surface for posture and reporting queries. +- A stable, documented read-only API surface for posture and reporting queries. Delivered with scoped `reports:read`, `posture:read`, and `tls-reports:read` API tokens, public read-only endpoints, and per-token usage audit fields. - Webhook event delivery for key events (new sender source, compliance drop, missing reports, alert lifecycle). - Integration templates for SIEM and ticketing workflows (export formats, payload schemas, examples). - Token/scoping model for API access that matches governance needs (service accounts, least privilege). diff --git a/docs/reference/api.md b/docs/reference/api.md index d9694e9..f514e65 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -4,18 +4,31 @@ DMARQ provides a comprehensive REST API that allows you to integrate with extern ## Authentication -All API requests require authentication using an API key. +Stable automation endpoints live under `/api/v1/public` and require a scoped +API token in the `X-API-Key` header. Admin endpoints continue to require an +administrator session or admin API key. ### API Keys To use the API, you need to generate an API key: -1. Navigate to **Settings** > **API Access** in the DMARQ UI -2. Click **Create API Key** -3. Enter a description for the key (e.g., "Integration with Slack") -4. Select the permissions you want to grant to this key -5. Click **Generate Key** -6. Copy the key immediately (it will only be shown once) +Create scoped API tokens with: + +``` +POST /api-tokens +``` + +Request: + +```json +{ + "name": "SIEM export", + "scopes": ["reports:read", "posture:read", "tls-reports:read"] +} +``` + +The raw token is returned once. DMARQ stores only a hash, prefix, scopes, and +usage audit metadata. ### Authentication Header @@ -37,6 +50,50 @@ Replace `your-dmarq-instance.com` with your actual DMARQ hostname. ## API Endpoints +### Stable Public API + +These endpoints are read-only and intended for automation. They are versioned +through the `/public` path and avoid UI-specific payloads. + +| Endpoint | Required scope | Purpose | +| --- | --- | --- | +| `GET /public/domains` | `reports:read` | Domain report and DNS summary list | +| `GET /public/domains/{domain_id}/reports` | `reports:read` | Recent DMARC aggregate report summaries | +| `GET /public/domains/{domain_id}/posture` | `posture:read` | Evidence-first posture dashboard payload | +| `GET /public/tls-reports/summary` | `tls-reports:read` | SMTP TLS report trends and failure groups | + +Successful public API calls update the token's last-used timestamp, source IP, +and usage count for auditing. + +### API Tokens + +#### List API Tokens + +``` +GET /api-tokens +``` + +Returns token metadata, scopes, activity state, and audit fields. Raw token +secrets and hashes are never returned. + +#### Create API Token + +``` +POST /api-tokens +``` + +Creates a scoped read-only token. The response includes `token` once and +`metadata` for future list/revoke operations. + +#### Revoke API Token + +``` +DELETE /api-tokens/{token_id} +``` + +Deactivates a token immediately. Revoked tokens can no longer access public API +endpoints. + ### Domains #### List Domains diff --git a/docs/reference/database.md b/docs/reference/database.md index 317da15..de09330 100644 --- a/docs/reference/database.md +++ b/docs/reference/database.md @@ -114,20 +114,26 @@ The `users` table stores user account information. | created_at | TIMESTAMP | When the account was created | | last_login | TIMESTAMP | When the user last logged in | -### API_Keys +### API_Tokens -The `api_keys` table stores API keys for programmatic access. +The `api_tokens` table stores hashed, scoped API tokens for stable read-only +automation access. Raw token secrets are returned only once at creation time +and are never stored. | Column | Type | Description | |--------|------|-------------| | id | INTEGER | Primary key | -| key_hash | VARCHAR(255) | Hashed API key | -| user_id | INTEGER | Foreign key to users.id | -| name | VARCHAR(100) | Name/description of the key | +| name | VARCHAR(120) | Name/description of the token | +| key_hash | VARCHAR(64) | SHA-256 hash of the token secret | +| key_prefix | VARCHAR(16) | Non-secret prefix for operator identification | +| scopes | TEXT | Comma-separated scopes such as `reports:read` | +| active | BOOLEAN | Whether the token can be used | | created_at | TIMESTAMP | When the key was created | -| expires_at | TIMESTAMP | When the key expires (optional) | -| last_used | TIMESTAMP | When the key was last used | -| permissions | TEXT | JSON array of permissions | +| updated_at | TIMESTAMP | When the token row was last changed | +| revoked_at | TIMESTAMP | When the token was revoked | +| last_used_at | TIMESTAMP | Last successful API use | +| last_used_ip | VARCHAR(64) | Source IP from the last successful API use | +| usage_count | INTEGER | Successful API use count | ## DNS and Configuration Tables @@ -234,7 +240,9 @@ The schema includes several indexes to optimize query performance: - `idx_users_username`: On users.username - `idx_users_email`: On users.email - `idx_domains_name`: On domains.name -- `idx_api_keys_key_hash`: On api_keys.key_hash +- `ix_api_tokens_key_hash`: On api_tokens.key_hash +- `ix_api_tokens_key_prefix`: On api_tokens.key_prefix +- `ix_api_tokens_active_scope`: On api_tokens.active and api_tokens.scopes - `idx_activity_logs_timestamp`: On activity_logs.timestamp - `idx_activity_logs_user_id`: On activity_logs.user_id - `idx_system_logs_timestamp`: On system_logs.timestamp From e2b95f183d61a9117ad75b94a69c00f1562c0380 Mon Sep 17 00:00:00 2001 From: Christian Krakau-Louis Date: Sat, 23 May 2026 17:47:13 +0200 Subject: [PATCH 2/2] fix: harden public api token storage --- .../0a1b2c3d4e5f_add_scoped_api_tokens.py | 2 +- backend/app/core/security.py | 3 +-- backend/app/models/api_token.py | 2 +- backend/app/services/api_tokens.py | 22 ++++++++++++++----- backend/requirements.txt | 3 ++- docs/reference/database.md | 2 +- 6 files changed, 23 insertions(+), 11 deletions(-) diff --git a/backend/alembic/versions/0a1b2c3d4e5f_add_scoped_api_tokens.py b/backend/alembic/versions/0a1b2c3d4e5f_add_scoped_api_tokens.py index 716ea96..c80f2d4 100644 --- a/backend/alembic/versions/0a1b2c3d4e5f_add_scoped_api_tokens.py +++ b/backend/alembic/versions/0a1b2c3d4e5f_add_scoped_api_tokens.py @@ -24,7 +24,7 @@ def upgrade() -> None: "api_tokens", sa.Column("id", sa.Integer(), nullable=False), sa.Column("name", sa.String(length=120), nullable=False), - sa.Column("key_hash", sa.String(length=64), nullable=False), + sa.Column("key_hash", sa.String(length=255), nullable=False), sa.Column("key_prefix", sa.String(length=16), nullable=False), sa.Column("scopes", sa.Text(), nullable=False), sa.Column("active", sa.Boolean(), nullable=False), diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 6ac558f..b961b32 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -240,8 +240,7 @@ def require_api_token_scope(required_scope: str) -> Callable: 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) + logger.warning("Invalid public API token attempt") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API token", diff --git a/backend/app/models/api_token.py b/backend/app/models/api_token.py index ce8bdec..388b014 100644 --- a/backend/app/models/api_token.py +++ b/backend/app/models/api_token.py @@ -12,7 +12,7 @@ class APIToken(Base): 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_hash = Column(String(255), 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) diff --git a/backend/app/services/api_tokens.py b/backend/app/services/api_tokens.py index 9ad3ca4..4a92387 100644 --- a/backend/app/services/api_tokens.py +++ b/backend/app/services/api_tokens.py @@ -2,12 +2,12 @@ from __future__ import annotations -import hashlib import secrets from dataclasses import dataclass from datetime import datetime from typing import Iterable, List, Optional, Set +import bcrypt from sqlalchemy.orm import Session from app.models.api_token import APIToken @@ -59,7 +59,15 @@ def generate_public_api_key() -> str: def hash_api_key(secret: str) -> str: """Hash an API token for database storage.""" - return hashlib.sha256(secret.encode("utf-8")).hexdigest() + return bcrypt.hashpw(secret.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + +def verify_api_key_secret(secret: str, hashed_secret: str) -> bool: + """Return True when a raw API token matches the stored hash.""" + try: + return bcrypt.checkpw(secret.encode("utf-8"), hashed_secret.encode("utf-8")) + except ValueError: + return False def create_api_token(db: Session, *, name: str, scopes: Iterable[str]) -> CreatedAPIToken: @@ -85,11 +93,15 @@ 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 ( + candidates = ( db.query(APIToken) - .filter(APIToken.key_hash == hash_api_key(secret), APIToken.active == True) # noqa: E712 - .first() + .filter(APIToken.key_prefix == secret[:12], APIToken.active == True) # noqa: E712 + .all() ) + for token in candidates: + if verify_api_key_secret(secret, token.key_hash): + return token + return None def record_api_token_use(db: Session, token: APIToken, *, ip_address: Optional[str]) -> None: diff --git a/backend/requirements.txt b/backend/requirements.txt index 8e82af1..703232c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,6 +5,7 @@ pydantic>=2.0.0 pydantic-settings>=2.0.0 python-jose[cryptography]>=3.3.0 passlib[bcrypt]>=1.7.4 +bcrypt>=4.0.0 python-multipart>=0.0.6 logto>=0.2.0 aiohttp>=3.8.0 @@ -28,4 +29,4 @@ aiosmtplib>=2.0.2 jinja2>=3.1.2 google-auth>=2.0.0 google-api-python-client>=2.0.0 -google-auth-httplib2>=0.2.0 \ No newline at end of file +google-auth-httplib2>=0.2.0 diff --git a/docs/reference/database.md b/docs/reference/database.md index de09330..5afe60c 100644 --- a/docs/reference/database.md +++ b/docs/reference/database.md @@ -124,7 +124,7 @@ and are never stored. |--------|------|-------------| | id | INTEGER | Primary key | | name | VARCHAR(120) | Name/description of the token | -| key_hash | VARCHAR(64) | SHA-256 hash of the token secret | +| key_hash | VARCHAR(255) | Bcrypt hash of the token secret | | key_prefix | VARCHAR(16) | Non-secret prefix for operator identification | | scopes | TEXT | Comma-separated scopes such as `reports:read` | | active | BOOLEAN | Whether the token can be used |