feat: add scoped read-only public API

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