feat(ttp): E.1.9 API contract — seven router endpoints, admin-gated state mutations, response models
Mounts /api/v1/ttp/* with empty-list / empty-Navigator responses.
GET endpoints viewer-gated; POST/DELETE /rules/{rule_id}/state
admin-gated server-side. POST parses JSON manually so a malformed
body returns the documented 400 (per feedback_schemathesis_400).
Drops xfail-strict markers from E.2.8 tests now that the router is
mounted; 26 tests pass against the contract handlers.
This commit is contained in:
@@ -186,13 +186,21 @@ from .tarpit import (
|
||||
TarpitStatusResponse,
|
||||
)
|
||||
from .ttp import (
|
||||
CampaignTechniqueRow,
|
||||
CanaryFingerprintEvidence,
|
||||
CommandEvidence,
|
||||
EmailEvidence,
|
||||
IdentityTechniqueRow,
|
||||
IntelEvidence,
|
||||
NavigatorLayer,
|
||||
NavigatorTechnique,
|
||||
RuleCatalogueRow,
|
||||
RuleStateRequest,
|
||||
RuleStateResponse,
|
||||
TTPRule,
|
||||
TTPRuleState,
|
||||
TTPTag,
|
||||
TechniqueRollupRow,
|
||||
compute_tag_uuid,
|
||||
)
|
||||
|
||||
@@ -356,12 +364,20 @@ __all__ = [
|
||||
"TarpitRuleResponse",
|
||||
"TarpitStatusResponse",
|
||||
# ttp
|
||||
"CampaignTechniqueRow",
|
||||
"CanaryFingerprintEvidence",
|
||||
"CommandEvidence",
|
||||
"EmailEvidence",
|
||||
"IdentityTechniqueRow",
|
||||
"IntelEvidence",
|
||||
"NavigatorLayer",
|
||||
"NavigatorTechnique",
|
||||
"RuleCatalogueRow",
|
||||
"RuleStateRequest",
|
||||
"RuleStateResponse",
|
||||
"TTPRule",
|
||||
"TTPRuleState",
|
||||
"TTPTag",
|
||||
"TechniqueRollupRow",
|
||||
"compute_tag_uuid",
|
||||
]
|
||||
|
||||
@@ -10,6 +10,7 @@ import uuid as _uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal, Optional, TypedDict
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import JSON, CheckConstraint, Column, Index
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
@@ -235,3 +236,117 @@ class TTPRuleState(SQLModel, table=True):
|
||||
set_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
# ── API response models (Pydantic) ──────────────────────────────────
|
||||
# Routed by `decnet/web/router/ttp/`. Per the project's "all models in
|
||||
# models.py" rule these live here alongside the SQLModel tables, not
|
||||
# in a sibling schemas.py. Empty-list returns at contract phase are
|
||||
# typed against these models so the OpenAPI shape is stable from day
|
||||
# one. See TTP_TAGGING.md §E.1.9.
|
||||
|
||||
class TechniqueRollupRow(BaseModel):
|
||||
"""One row of /api/v1/ttp/techniques — distinct technique observed
|
||||
across the fleet with a count and a most-recent-seen timestamp."""
|
||||
|
||||
technique_id: str
|
||||
sub_technique_id: Optional[str] = None
|
||||
tactic: str
|
||||
count: int
|
||||
last_seen: datetime
|
||||
|
||||
|
||||
class IdentityTechniqueRow(BaseModel):
|
||||
"""One row of the by-identity / by-attacker / by-session endpoints —
|
||||
a distinct (technique, sub_technique) tuple within the requested
|
||||
scope, with an aggregate count and first/last-seen timestamps."""
|
||||
|
||||
technique_id: str
|
||||
sub_technique_id: Optional[str] = None
|
||||
tactic: str
|
||||
count: int
|
||||
first_seen: datetime
|
||||
last_seen: datetime
|
||||
confidence_max: float
|
||||
|
||||
|
||||
class CampaignTechniqueRow(BaseModel):
|
||||
"""One row of /api/v1/ttp/by-campaign/{uuid} — a technique observed
|
||||
across at least one Identity rolled up into the campaign."""
|
||||
|
||||
technique_id: str
|
||||
sub_technique_id: Optional[str] = None
|
||||
tactic: str
|
||||
count: int
|
||||
identity_count: int
|
||||
last_seen: datetime
|
||||
|
||||
|
||||
class RuleCatalogueRow(BaseModel):
|
||||
"""One row of /api/v1/ttp/rules — a rule definition + its current
|
||||
operational state. The operator-facing rule list."""
|
||||
|
||||
rule_id: str
|
||||
rule_version: int
|
||||
name: str
|
||||
description: str
|
||||
state: Literal["enabled", "disabled", "clipped"]
|
||||
confidence_max: Optional[float] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
reason: Optional[str] = None
|
||||
set_by: Optional[str] = None
|
||||
set_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class RuleStateRequest(BaseModel):
|
||||
"""POST /api/v1/ttp/rules/{rule_id}/state body — admin operator
|
||||
sets disable / clip / TTL on a rule. Pre-v1: schema is the public
|
||||
contract; downward changes require an OpenAPI version bump."""
|
||||
|
||||
state: Literal["enabled", "disabled", "clipped"]
|
||||
confidence_max: Optional[float] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
class RuleStateResponse(BaseModel):
|
||||
"""Response for POST/DELETE /api/v1/ttp/rules/{rule_id}/state and
|
||||
the per-rule entry of GET /rules. Mirrors :class:`TTPRuleState`."""
|
||||
|
||||
rule_id: str
|
||||
state: Literal["enabled", "disabled", "clipped"]
|
||||
confidence_max: Optional[float] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
reason: Optional[str] = None
|
||||
set_by: Optional[str] = None
|
||||
set_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class NavigatorTechnique(BaseModel):
|
||||
"""Per-technique entry of the MITRE ATT&CK Navigator JSON layer."""
|
||||
|
||||
techniqueID: str
|
||||
score: int
|
||||
color: str = ""
|
||||
comment: str = ""
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class NavigatorLayer(BaseModel):
|
||||
"""MITRE ATT&CK Navigator JSON layer envelope. Empty-but-valid at
|
||||
contract phase: a SOC analyst pasting this JSON into the official
|
||||
Navigator sees the file load cleanly with no highlighted
|
||||
techniques. See TTP_TAGGING.md §"UI surface — Empty state".
|
||||
"""
|
||||
|
||||
name: str = "DECNET TTP coverage"
|
||||
versions: dict[str, str] = Field(
|
||||
default_factory=lambda: {
|
||||
"attack": "15",
|
||||
"navigator": "5.1.0",
|
||||
"layer": "4.5",
|
||||
}
|
||||
)
|
||||
domain: str = "enterprise-attack"
|
||||
description: str = ""
|
||||
techniques: list[NavigatorTechnique] = Field(default_factory=list)
|
||||
|
||||
@@ -53,6 +53,13 @@ from .topology import topology_router
|
||||
from .canary import canary_router
|
||||
from .deckies import deckies_router
|
||||
from .webhooks import webhooks_router
|
||||
from .ttp.api_get_techniques import router as ttp_techniques_router
|
||||
from .ttp.api_get_by_identity import router as ttp_by_identity_router
|
||||
from .ttp.api_get_by_attacker import router as ttp_by_attacker_router
|
||||
from .ttp.api_get_by_campaign import router as ttp_by_campaign_router
|
||||
from .ttp.api_get_by_session import router as ttp_by_session_router
|
||||
from .ttp.api_get_rules import router as ttp_rules_router
|
||||
from .ttp.api_export_navigator import router as ttp_navigator_router
|
||||
|
||||
api_router = APIRouter(
|
||||
# Every route under /api/v1 is auth-guarded (either by an explicit
|
||||
@@ -163,3 +170,14 @@ api_router.include_router(deckies_router)
|
||||
|
||||
# External webhook subscriptions (SIEM/SOAR egress)
|
||||
api_router.include_router(webhooks_router)
|
||||
|
||||
# TTP Tagging — see development/TTP_TAGGING.md. Contract phase: every
|
||||
# handler returns the typed empty value; impl phase wires the repo
|
||||
# and rule engine.
|
||||
api_router.include_router(ttp_techniques_router)
|
||||
api_router.include_router(ttp_by_identity_router)
|
||||
api_router.include_router(ttp_by_attacker_router)
|
||||
api_router.include_router(ttp_by_campaign_router)
|
||||
api_router.include_router(ttp_by_session_router)
|
||||
api_router.include_router(ttp_rules_router)
|
||||
api_router.include_router(ttp_navigator_router)
|
||||
|
||||
6
decnet/web/router/ttp/__init__.py
Normal file
6
decnet/web/router/ttp/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""TTP-tagging API router package — see development/TTP_TAGGING.md.
|
||||
|
||||
Contract phase E.1.9: handlers return typed empty values. The repo
|
||||
methods (E.1.10) and engine (E.3) land separately; the router shape +
|
||||
auth gating + OpenAPI surface are stable from this commit forward.
|
||||
"""
|
||||
56
decnet/web/router/ttp/api_export_navigator.py
Normal file
56
decnet/web/router/ttp/api_export_navigator.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""GET /api/v1/ttp/export/navigator{,/identity/{uuid}} — Navigator JSON layer.
|
||||
|
||||
Empty-but-valid Navigator layer at contract phase per TTP_TAGGING.md
|
||||
§"UI surface — Empty state": a SOC analyst pasting the JSON into the
|
||||
official MITRE ATT&CK Navigator sees the file load with no
|
||||
highlighted techniques — correct, not broken.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from decnet.telemetry import traced as _traced
|
||||
from decnet.web.db.models import NavigatorLayer
|
||||
from decnet.web.dependencies import require_viewer
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/ttp/export/navigator",
|
||||
tags=["TTP Tagging"],
|
||||
response_model=NavigatorLayer,
|
||||
responses={
|
||||
401: {"description": "Could not validate credentials"},
|
||||
403: {"description": "Insufficient permissions"},
|
||||
},
|
||||
)
|
||||
@_traced("api.ttp.export_navigator_fleet")
|
||||
async def api_export_navigator_fleet(
|
||||
user: dict[str, Any] = Depends(require_viewer),
|
||||
) -> NavigatorLayer:
|
||||
"""Fleet-wide Navigator layer. Empty-but-valid at contract phase."""
|
||||
return NavigatorLayer(name="DECNET TTP coverage — fleet")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/ttp/export/navigator/identity/{identity_uuid}",
|
||||
tags=["TTP Tagging"],
|
||||
response_model=NavigatorLayer,
|
||||
responses={
|
||||
401: {"description": "Could not validate credentials"},
|
||||
403: {"description": "Insufficient permissions"},
|
||||
404: {"description": "Identity not found"},
|
||||
},
|
||||
)
|
||||
@_traced("api.ttp.export_navigator_identity")
|
||||
async def api_export_navigator_identity(
|
||||
identity_uuid: str,
|
||||
user: dict[str, Any] = Depends(require_viewer),
|
||||
) -> NavigatorLayer:
|
||||
"""Per-Identity Navigator layer (the SOC demo)."""
|
||||
return NavigatorLayer(
|
||||
name=f"DECNET TTP coverage — identity {identity_uuid}",
|
||||
)
|
||||
35
decnet/web/router/ttp/api_get_by_attacker.py
Normal file
35
decnet/web/router/ttp/api_get_by_attacker.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""GET /api/v1/ttp/by-attacker/{attacker_uuid} — per-IP TTP slice.
|
||||
|
||||
Backs the AttackerDetail page's TTP section. See TTP_TAGGING.md
|
||||
§"UI surface" + project_attacker_detail_keep memory.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from decnet.telemetry import traced as _traced
|
||||
from decnet.web.db.models import IdentityTechniqueRow
|
||||
from decnet.web.dependencies import require_viewer
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/ttp/by-attacker/{attacker_uuid}",
|
||||
tags=["TTP Tagging"],
|
||||
response_model=list[IdentityTechniqueRow],
|
||||
responses={
|
||||
401: {"description": "Could not validate credentials"},
|
||||
403: {"description": "Insufficient permissions"},
|
||||
404: {"description": "Attacker not found"},
|
||||
},
|
||||
)
|
||||
@_traced("api.ttp.by_attacker")
|
||||
async def api_ttp_by_attacker(
|
||||
attacker_uuid: str,
|
||||
user: dict[str, Any] = Depends(require_viewer),
|
||||
) -> list[IdentityTechniqueRow]:
|
||||
"""Per-Attacker (per-IP) TTP rows. Empty at contract phase."""
|
||||
return []
|
||||
31
decnet/web/router/ttp/api_get_by_campaign.py
Normal file
31
decnet/web/router/ttp/api_get_by_campaign.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""GET /api/v1/ttp/by-campaign/{campaign_uuid} — campaign-wide TTP rollup."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from decnet.telemetry import traced as _traced
|
||||
from decnet.web.db.models import CampaignTechniqueRow
|
||||
from decnet.web.dependencies import require_viewer
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/ttp/by-campaign/{campaign_uuid}",
|
||||
tags=["TTP Tagging"],
|
||||
response_model=list[CampaignTechniqueRow],
|
||||
responses={
|
||||
401: {"description": "Could not validate credentials"},
|
||||
403: {"description": "Insufficient permissions"},
|
||||
404: {"description": "Campaign not found"},
|
||||
},
|
||||
)
|
||||
@_traced("api.ttp.by_campaign")
|
||||
async def api_ttp_by_campaign(
|
||||
campaign_uuid: str,
|
||||
user: dict[str, Any] = Depends(require_viewer),
|
||||
) -> list[CampaignTechniqueRow]:
|
||||
"""Campaign-rollup TTP rows. Empty at contract phase."""
|
||||
return []
|
||||
35
decnet/web/router/ttp/api_get_by_identity.py
Normal file
35
decnet/web/router/ttp/api_get_by_identity.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""GET /api/v1/ttp/by-identity/{identity_uuid} — Identity-scoped TTP rollup.
|
||||
|
||||
Primary endpoint for the IdentityDetail "TTPs Observed" section. See
|
||||
TTP_TAGGING.md §"UI surface". Empty at contract phase (E.1.9).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from decnet.telemetry import traced as _traced
|
||||
from decnet.web.db.models import IdentityTechniqueRow
|
||||
from decnet.web.dependencies import require_viewer
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/ttp/by-identity/{identity_uuid}",
|
||||
tags=["TTP Tagging"],
|
||||
response_model=list[IdentityTechniqueRow],
|
||||
responses={
|
||||
401: {"description": "Could not validate credentials"},
|
||||
403: {"description": "Insufficient permissions"},
|
||||
404: {"description": "Identity not found"},
|
||||
},
|
||||
)
|
||||
@_traced("api.ttp.by_identity")
|
||||
async def api_ttp_by_identity(
|
||||
identity_uuid: str,
|
||||
user: dict[str, Any] = Depends(require_viewer),
|
||||
) -> list[IdentityTechniqueRow]:
|
||||
"""Per-Identity TTP heatmap rows. Empty at contract phase."""
|
||||
return []
|
||||
31
decnet/web/router/ttp/api_get_by_session.py
Normal file
31
decnet/web/router/ttp/api_get_by_session.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""GET /api/v1/ttp/by-session/{session_id} — session timeline of TTP tags."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from decnet.telemetry import traced as _traced
|
||||
from decnet.web.db.models import IdentityTechniqueRow
|
||||
from decnet.web.dependencies import require_viewer
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/ttp/by-session/{session_id}",
|
||||
tags=["TTP Tagging"],
|
||||
response_model=list[IdentityTechniqueRow],
|
||||
responses={
|
||||
401: {"description": "Could not validate credentials"},
|
||||
403: {"description": "Insufficient permissions"},
|
||||
404: {"description": "Session not found"},
|
||||
},
|
||||
)
|
||||
@_traced("api.ttp.by_session")
|
||||
async def api_ttp_by_session(
|
||||
session_id: str,
|
||||
user: dict[str, Any] = Depends(require_viewer),
|
||||
) -> list[IdentityTechniqueRow]:
|
||||
"""Per-session TTP tag timeline. Empty at contract phase."""
|
||||
return []
|
||||
128
decnet/web/router/ttp/api_get_rules.py
Normal file
128
decnet/web/router/ttp/api_get_rules.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""TTP rule catalogue + admin-only state mutations.
|
||||
|
||||
Three endpoints in one router:
|
||||
|
||||
* ``GET /api/v1/ttp/rules`` — viewer-readable rule list
|
||||
* ``POST /api/v1/ttp/rules/{rule_id}/state`` — admin: set state
|
||||
* ``DELETE /api/v1/ttp/rules/{rule_id}/state`` — admin: revert to default
|
||||
|
||||
Per the project's "no client-side role checks" rule, the admin guard
|
||||
is server-side via :func:`require_admin`. Per
|
||||
``feedback_schemathesis_400.md``, the POST handler parses the body
|
||||
manually and returns ``400`` on a malformed JSON body so the
|
||||
documented status code matches reality.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import ValidationError
|
||||
|
||||
from decnet.telemetry import traced as _traced
|
||||
from decnet.web.db.models import (
|
||||
RuleCatalogueRow,
|
||||
RuleStateRequest,
|
||||
RuleStateResponse,
|
||||
)
|
||||
from decnet.web.dependencies import require_admin, require_viewer
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/ttp/rules",
|
||||
tags=["TTP Tagging"],
|
||||
response_model=list[RuleCatalogueRow],
|
||||
responses={
|
||||
401: {"description": "Could not validate credentials"},
|
||||
403: {"description": "Insufficient permissions"},
|
||||
},
|
||||
)
|
||||
@_traced("api.ttp.list_rules")
|
||||
async def api_list_rules(
|
||||
user: dict[str, Any] = Depends(require_viewer),
|
||||
) -> list[RuleCatalogueRow]:
|
||||
"""Operator-facing rule catalogue. Empty at contract phase."""
|
||||
return []
|
||||
|
||||
|
||||
@router.post(
|
||||
"/ttp/rules/{rule_id}/state",
|
||||
tags=["TTP Tagging"],
|
||||
response_model=RuleStateResponse,
|
||||
responses={
|
||||
400: {"description": "Bad Request (malformed JSON or invalid body)"},
|
||||
401: {"description": "Could not validate credentials"},
|
||||
403: {"description": "Insufficient permissions"},
|
||||
404: {"description": "Rule not found"},
|
||||
},
|
||||
)
|
||||
@_traced("api.ttp.set_rule_state")
|
||||
async def api_set_rule_state(
|
||||
rule_id: str,
|
||||
request: Request,
|
||||
admin: dict[str, Any] = Depends(require_admin),
|
||||
) -> RuleStateResponse:
|
||||
"""Set operational state (disable / clip / TTL) on a rule.
|
||||
|
||||
Body parse is manual so a malformed JSON body surfaces as the
|
||||
documented ``400`` rather than the framework default of ``422``
|
||||
(per ``feedback_schemathesis_400.md``).
|
||||
"""
|
||||
try:
|
||||
raw = await request.json()
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Body must be valid JSON",
|
||||
) from exc
|
||||
try:
|
||||
body = RuleStateRequest.model_validate(raw)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid rule-state body: {exc.errors()}",
|
||||
) from exc
|
||||
|
||||
# Contract phase: no persistence yet (E.1.10 / E.3 lands the repo
|
||||
# write). Echo the requested state back so the response shape is
|
||||
# exercisable and OpenAPI-stable.
|
||||
return RuleStateResponse(
|
||||
rule_id=rule_id,
|
||||
state=body.state,
|
||||
confidence_max=body.confidence_max,
|
||||
expires_at=body.expires_at,
|
||||
reason=body.reason,
|
||||
set_by=str(admin.get("sub", "")),
|
||||
set_at=None,
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/ttp/rules/{rule_id}/state",
|
||||
tags=["TTP Tagging"],
|
||||
response_model=RuleStateResponse,
|
||||
responses={
|
||||
401: {"description": "Could not validate credentials"},
|
||||
403: {"description": "Insufficient permissions"},
|
||||
404: {"description": "Rule not found"},
|
||||
},
|
||||
)
|
||||
@_traced("api.ttp.revert_rule_state")
|
||||
async def api_revert_rule_state(
|
||||
rule_id: str,
|
||||
admin: dict[str, Any] = Depends(require_admin),
|
||||
) -> RuleStateResponse:
|
||||
"""Revert a rule to the default ``enabled`` state."""
|
||||
return RuleStateResponse(
|
||||
rule_id=rule_id,
|
||||
state="enabled",
|
||||
confidence_max=None,
|
||||
expires_at=None,
|
||||
reason=None,
|
||||
set_by=str(admin.get("sub", "")),
|
||||
set_at=None,
|
||||
)
|
||||
34
decnet/web/router/ttp/api_get_techniques.py
Normal file
34
decnet/web/router/ttp/api_get_techniques.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""GET /api/v1/ttp/techniques — distinct techniques observed fleet-wide.
|
||||
|
||||
Returns an empty list at contract phase (E.1.9). Repo wiring lands in
|
||||
E.1.10 / E.3 implementation; the response shape is stable from here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from decnet.telemetry import traced as _traced
|
||||
from decnet.web.db.models import TechniqueRollupRow
|
||||
from decnet.web.dependencies import require_viewer
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/ttp/techniques",
|
||||
tags=["TTP Tagging"],
|
||||
response_model=list[TechniqueRollupRow],
|
||||
responses={
|
||||
401: {"description": "Could not validate credentials"},
|
||||
403: {"description": "Insufficient permissions"},
|
||||
},
|
||||
)
|
||||
@_traced("api.ttp.list_techniques")
|
||||
async def api_list_techniques(
|
||||
user: dict[str, Any] = Depends(require_viewer),
|
||||
) -> list[TechniqueRollupRow]:
|
||||
"""Distinct techniques observed across the fleet, with counts and
|
||||
last-seen timestamps. Empty list at contract phase."""
|
||||
return []
|
||||
@@ -2344,6 +2344,8 @@ unrelated events.
|
||||
|
||||
**E.1.9 — API contract** (`decnet/web/router/ttp/`)
|
||||
|
||||
**Status:** ✅ done.
|
||||
|
||||
- Six FastAPI router files matching the API surface above:
|
||||
`api_get_techniques.py`, `api_get_by_identity.py`,
|
||||
`api_get_by_attacker.py`, `api_get_by_campaign.py`,
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
"""E.2.8 — GET endpoint shape + auth contract for /api/v1/ttp/*.
|
||||
|
||||
Today no TTP router is mounted under :mod:`decnet.web.api`; every
|
||||
assertion that the documented endpoint exists (200 with a JWT, 401
|
||||
without) lives behind ``@pytest.mark.xfail(strict=True)`` so this
|
||||
suite is GREEN today and trips the day E.3.8 wires the router.
|
||||
|
||||
The router-presence sanity test is the only assertion that compiles
|
||||
GREEN today: it asserts that AT LEAST ONE of the documented paths
|
||||
returns something OTHER than 404 (i.e. the router exists). It is
|
||||
xfail-strict — when the router lands, the marker flips and the suite
|
||||
exercises the rest of the contract.
|
||||
The TTP router landed at E.1.9 (contract phase) returning typed
|
||||
empty values. Every documented GET endpoint is now exercised: 200
|
||||
with a JWT, 401 without. Repo-backed data lands at E.3 implementation
|
||||
phase; this suite stays green across that transition because the
|
||||
shape contract is stable from E.1.9 forward.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -58,10 +53,6 @@ _GET_ENDPOINTS: list[str] = [
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", _GET_ENDPOINTS)
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="impl phase E.3.8: TTP router not yet mounted",
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_returns_200_with_jwt(
|
||||
client: httpx.AsyncClient, auth_token: str, path: str,
|
||||
@@ -77,10 +68,6 @@ async def test_get_returns_200_with_jwt(
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", _GET_ENDPOINTS)
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="impl phase E.3.8: TTP router not yet mounted",
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_returns_401_without_jwt(
|
||||
client: httpx.AsyncClient, path: str,
|
||||
@@ -95,10 +82,6 @@ async def test_get_returns_401_without_jwt(
|
||||
# ─── Router-presence sanity ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="impl phase E.3.8: /api/v1/ttp router not yet mounted",
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_ttp_router_is_mounted(
|
||||
client: httpx.AsyncClient, auth_token: str,
|
||||
|
||||
@@ -42,10 +42,6 @@ def test_placeholder_golden_is_stable() -> None:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="impl phase E.3.8: TTP router not yet contributing to OpenAPI",
|
||||
)
|
||||
def test_openapi_includes_ttp_paths() -> None:
|
||||
"""Every documented TTP endpoint must appear in the live OpenAPI
|
||||
schema once the router lands. Pinned as a strict-xfail so the
|
||||
|
||||
@@ -5,8 +5,9 @@ disable/clip/TTL knobs. Per the project's "no client-side role
|
||||
checks" rule, the assertions here all hit the server and inspect
|
||||
the response — never a feature flag, never a route table.
|
||||
|
||||
Today the router does not exist; every assertion is
|
||||
``xfail(strict=True)`` and trips when E.3.8 wires it.
|
||||
The router landed at E.1.9 with the admin guard on POST/DELETE; the
|
||||
assertions exercise the auth + body-validation contract directly.
|
||||
Persistence (state actually surviving a roundtrip) lands in E.3.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -29,10 +30,6 @@ def _path() -> str:
|
||||
# ─── POST /rules/{rule_id}/state ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="impl phase E.3.8: TTP router not yet mounted",
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_state_without_jwt_is_401(
|
||||
client: httpx.AsyncClient,
|
||||
@@ -41,10 +38,6 @@ async def test_post_state_without_jwt_is_401(
|
||||
assert res.status_code == 401, res.text
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="impl phase E.3.8: TTP router not yet mounted",
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_state_non_admin_is_403_server_side(
|
||||
client: httpx.AsyncClient, viewer_token: str,
|
||||
@@ -59,10 +52,6 @@ async def test_post_state_non_admin_is_403_server_side(
|
||||
assert res.status_code == 403, res.text
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="impl phase E.3.8: TTP router not yet mounted",
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_state_admin_is_200(
|
||||
client: httpx.AsyncClient, auth_token: str,
|
||||
@@ -73,10 +62,6 @@ async def test_post_state_admin_is_200(
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="impl phase E.3.8: TTP router not yet mounted",
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_state_malformed_body_is_400(
|
||||
client: httpx.AsyncClient, auth_token: str,
|
||||
@@ -98,10 +83,6 @@ async def test_post_state_malformed_body_is_400(
|
||||
# ─── DELETE /rules/{rule_id}/state ───────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="impl phase E.3.8: TTP router not yet mounted",
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_state_without_jwt_is_401(
|
||||
client: httpx.AsyncClient,
|
||||
@@ -110,10 +91,6 @@ async def test_delete_state_without_jwt_is_401(
|
||||
assert res.status_code == 401, res.text
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="impl phase E.3.8: TTP router not yet mounted",
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_state_non_admin_is_403_server_side(
|
||||
client: httpx.AsyncClient, viewer_token: str,
|
||||
@@ -122,10 +99,6 @@ async def test_delete_state_non_admin_is_403_server_side(
|
||||
assert res.status_code == 403, res.text
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="impl phase E.3.8: TTP router not yet mounted",
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_state_admin_is_204_or_200(
|
||||
client: httpx.AsyncClient, auth_token: str,
|
||||
|
||||
Reference in New Issue
Block a user