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