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))
|
config.set_main_option("sqlalchemy.url", _make_sync_db_url(database_url))
|
||||||
|
|
||||||
import app.models.alert # noqa: E402, F401
|
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.dns_cache # noqa: E402, F401
|
||||||
import app.models.domain # noqa: E402, F401
|
import app.models.domain # noqa: E402, F401
|
||||||
import app.models.mail_source # 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 fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.api_v1.endpoints import (
|
from app.api.api_v1.endpoints import (
|
||||||
|
api_tokens,
|
||||||
auth,
|
auth,
|
||||||
domains,
|
domains,
|
||||||
forensics,
|
forensics,
|
||||||
health,
|
health,
|
||||||
imap,
|
imap,
|
||||||
mail_sources,
|
mail_sources,
|
||||||
|
public,
|
||||||
reports,
|
reports,
|
||||||
settings,
|
settings,
|
||||||
setup,
|
setup,
|
||||||
@@ -19,7 +21,9 @@ api_router = APIRouter()
|
|||||||
|
|
||||||
# Include all endpoint routers
|
# Include all endpoint routers
|
||||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
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(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(domains.router, prefix="/domains", tags=["domains"])
|
||||||
api_router.include_router(reports.router, prefix="/reports", tags=["reports"])
|
api_router.include_router(reports.router, prefix="/reports", tags=["reports"])
|
||||||
api_router.include_router(forensics.router, prefix="/forensics", tags=["forensics"])
|
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 os
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timedelta
|
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 fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import get_settings
|
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()
|
settings = get_settings()
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
def create_access_token(subject: Union[str, Any], expires_delta: timedelta = None) -> str:
|
||||||
"""
|
"""
|
||||||
Create a JWT access token for authentication
|
Create a JWT access token for authentication
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from fastapi.templating import Jinja2Templates
|
|||||||
from starlette.concurrency import run_in_threadpool
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
import app.models.alert # noqa: F401 – ensure AlertHistory table is registered
|
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.dns_cache # noqa: F401 – ensure DNSCache table is registered
|
||||||
import app.models.domain # noqa: F401 – ensure Domain/UserDomain tables are 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
|
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
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
import app.models.alert # noqa: F401 # pylint: disable=unused-import
|
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.dns_cache # noqa: F401 # pylint: disable=unused-import
|
||||||
import app.models.domain # 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
|
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.
|
- 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.
|
- Guided setup and operator health screens.
|
||||||
- Forensic/RUF report support.
|
- 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.
|
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)
|
### Priority (Now / Next / Later)
|
||||||
|
|
||||||
- **Now**: finish Milestones 8–9 (DNS health guidance + setup/ops polish).
|
- **Now**: finish Milestone 14 (public API, webhooks, and integration templates).
|
||||||
- **Next**: Milestones 10–12 (failure reports, DMARC format compatibility, Microsoft 365 ingestion).
|
- **Next**: Milestone 15 (workspaces/MSP governance).
|
||||||
- **Later**: Milestones 13–16 (posture suite beyond DMARC, public API/webhooks, MSP/workspaces, AI/MCP).
|
- **Later**: Milestone 16 (optional AI/MCP automation).
|
||||||
|
|
||||||
### Tentative Release Plan (Subject to Change)
|
### Tentative Release Plan (Subject to Change)
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -214,7 +214,7 @@ Exit criteria:
|
|||||||
|
|
||||||
## Milestone 13: Email Security Posture (Beyond DMARC)
|
## 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).
|
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
|
## Milestone 14: Public API, Webhooks, and Core Integrations
|
||||||
|
|
||||||
Status: Backlog
|
Status: In progress
|
||||||
|
|
||||||
Goal: let DMARQ integrate cleanly into existing security and operations workflows.
|
Goal: let DMARQ integrate cleanly into existing security and operations workflows.
|
||||||
|
|
||||||
Planned:
|
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).
|
- 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).
|
- 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).
|
- 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
|
## 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
|
### API Keys
|
||||||
|
|
||||||
To use the API, you need to generate an API key:
|
To use the API, you need to generate an API key:
|
||||||
|
|
||||||
1. Navigate to **Settings** > **API Access** in the DMARQ UI
|
Create scoped API tokens with:
|
||||||
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
|
POST /api-tokens
|
||||||
5. Click **Generate Key**
|
```
|
||||||
6. Copy the key immediately (it will only be shown once)
|
|
||||||
|
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
|
### Authentication Header
|
||||||
|
|
||||||
@@ -37,6 +50,50 @@ Replace `your-dmarq-instance.com` with your actual DMARQ hostname.
|
|||||||
|
|
||||||
## API Endpoints
|
## 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
|
### Domains
|
||||||
|
|
||||||
#### List Domains
|
#### List Domains
|
||||||
|
|||||||
@@ -114,20 +114,26 @@ The `users` table stores user account information.
|
|||||||
| created_at | TIMESTAMP | When the account was created |
|
| created_at | TIMESTAMP | When the account was created |
|
||||||
| last_login | TIMESTAMP | When the user last logged in |
|
| 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 |
|
| Column | Type | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| id | INTEGER | Primary key |
|
| id | INTEGER | Primary key |
|
||||||
| key_hash | VARCHAR(255) | Hashed API key |
|
| name | VARCHAR(120) | Name/description of the token |
|
||||||
| user_id | INTEGER | Foreign key to users.id |
|
| key_hash | VARCHAR(64) | SHA-256 hash of the token secret |
|
||||||
| name | VARCHAR(100) | Name/description of the key |
|
| 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 |
|
| created_at | TIMESTAMP | When the key was created |
|
||||||
| expires_at | TIMESTAMP | When the key expires (optional) |
|
| updated_at | TIMESTAMP | When the token row was last changed |
|
||||||
| last_used | TIMESTAMP | When the key was last used |
|
| revoked_at | TIMESTAMP | When the token was revoked |
|
||||||
| permissions | TEXT | JSON array of permissions |
|
| 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
|
## 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_username`: On users.username
|
||||||
- `idx_users_email`: On users.email
|
- `idx_users_email`: On users.email
|
||||||
- `idx_domains_name`: On domains.name
|
- `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_timestamp`: On activity_logs.timestamp
|
||||||
- `idx_activity_logs_user_id`: On activity_logs.user_id
|
- `idx_activity_logs_user_id`: On activity_logs.user_id
|
||||||
- `idx_system_logs_timestamp`: On system_logs.timestamp
|
- `idx_system_logs_timestamp`: On system_logs.timestamp
|
||||||
|
|||||||
Reference in New Issue
Block a user