Compare commits
2 Commits
fefe70baed
...
1ddbd6989f
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ddbd6989f | |||
| bd26b09827 |
@@ -16,6 +16,9 @@ from fastapi import APIRouter, Cookie, Depends, Request, Response
|
||||
# Import OAuth2PasswordRequestForm from fastapi.security
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
# Import datetime, timezone from datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Import jwt (PyJWT)
|
||||
import jwt
|
||||
from jwt.exceptions import PyJWTError as JWTError
|
||||
@@ -24,7 +27,7 @@ from jwt.exceptions import PyJWTError as JWTError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# Import blacklist_token, create_access_token, verify_pa... from app.auth
|
||||
from app.auth import blacklist_token, create_access_token, verify_password
|
||||
from app.auth import blacklist_token, create_access_token, is_token_blacklisted, verify_password
|
||||
|
||||
# Import settings from app.config
|
||||
from app.config import settings
|
||||
@@ -272,19 +275,28 @@ def logout(
|
||||
return {"detail": "Logged out"}
|
||||
|
||||
|
||||
# A token that has *just* expired can still be refreshed within this window.
|
||||
# Without this grace period, `/auth/refresh` decodes the same token with the
|
||||
# same strict expiry check as every other endpoint — so by the time a 401
|
||||
# triggers a refresh attempt, the refresh call itself has also expired and
|
||||
# the session is unrecoverable. The grace window only widens the refresh
|
||||
# check; a brand-new token is always issued with the full expiry.
|
||||
_REFRESH_GRACE_SECONDS = 15 * 60
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
def refresh_token(
|
||||
response: Response,
|
||||
aegis_token: str | None = Cookie(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Issue a new access token if the current one is valid.
|
||||
"""Issue a new access token if the current one is valid or recently expired.
|
||||
|
||||
Called automatically by the frontend when it detects an expired
|
||||
session while the user is actively using the app. If the current
|
||||
cookie token is still valid (not blacklisted, not expired), a fresh
|
||||
token is issued and the cookie is renewed — keeping the session alive
|
||||
without requiring re-authentication.
|
||||
session while the user is actively using the app. Expiry is checked
|
||||
manually (with `_REFRESH_GRACE_SECONDS` of leeway) instead of relying on
|
||||
PyJWT's built-in check, so a token that expired moments ago can still
|
||||
be refreshed. Revoked (blacklisted) tokens are never refreshable.
|
||||
"""
|
||||
if not aegis_token:
|
||||
raise PermissionViolation("No active session")
|
||||
@@ -294,10 +306,20 @@ def refresh_token(
|
||||
aegis_token,
|
||||
settings.SECRET_KEY,
|
||||
algorithms=[settings.ALGORITHM],
|
||||
options={"verify_exp": False},
|
||||
)
|
||||
except JWTError:
|
||||
raise PermissionViolation("Session expired — please log in again")
|
||||
|
||||
jti: str | None = payload.get("jti")
|
||||
if jti and is_token_blacklisted(jti):
|
||||
raise PermissionViolation("Session expired — please log in again")
|
||||
|
||||
exp = payload.get("exp")
|
||||
now = datetime.now(timezone.utc).timestamp()
|
||||
if exp is None or now - exp > _REFRESH_GRACE_SECONDS:
|
||||
raise PermissionViolation("Session expired — please log in again")
|
||||
|
||||
username: str | None = payload.get("sub")
|
||||
if not username:
|
||||
raise PermissionViolation("Invalid session")
|
||||
|
||||
@@ -134,6 +134,42 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||
|
||||
|
||||
def _create_jira_tickets_for_campaign(db: Session, campaign: Campaign, campaign_id: str, user: User) -> None:
|
||||
"""Create Jira tickets for a campaign and its already-linked tests, if missing.
|
||||
|
||||
Shared by both paths that bring a campaign to ``active``: the admin-only
|
||||
emergency `/activate` override and the normal manager `/approve` flow.
|
||||
Tests may already be linked to the campaign from when it was still a
|
||||
draft, i.e. before any Jira ticket existed for the campaign — so they
|
||||
need their own tickets created here too. Best-effort: failures are
|
||||
logged, not raised, since a Jira outage must not block activation.
|
||||
"""
|
||||
try:
|
||||
from app.services.jira_service import (
|
||||
auto_create_campaign_issue,
|
||||
auto_create_test_issue,
|
||||
get_campaign_jira_key,
|
||||
get_test_jira_key,
|
||||
)
|
||||
campaign_jira_key = get_campaign_jira_key(db, campaign_id)
|
||||
if not campaign_jira_key:
|
||||
campaign_jira_key = auto_create_campaign_issue(db, campaign, user)
|
||||
if campaign_jira_key:
|
||||
for ct in campaign.campaign_tests:
|
||||
if ct.test and not get_test_jira_key(db, ct.test.id):
|
||||
auto_create_test_issue(
|
||||
db, ct.test, user,
|
||||
parent_ticket_override=campaign_jira_key,
|
||||
campaign_start_date=campaign.start_date,
|
||||
)
|
||||
db.commit()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Jira ticket creation failed while activating campaign %s",
|
||||
campaign_id,
|
||||
)
|
||||
|
||||
|
||||
# ── Pydantic schemas ─────────────────────────────────────────────────
|
||||
|
||||
class CampaignCreate(BaseModel):
|
||||
@@ -498,6 +534,11 @@ def approve_campaign_endpoint(
|
||||
)
|
||||
uow.commit()
|
||||
db.refresh(campaign)
|
||||
|
||||
# Create Jira tickets for campaign and its already-linked tests (non-fatal).
|
||||
# Mirrors the admin-only /activate override — this is the normal path.
|
||||
_create_jira_tickets_for_campaign(db, campaign, campaign_id, current_user)
|
||||
|
||||
return serialize_campaign(db, campaign)
|
||||
|
||||
|
||||
@@ -837,30 +878,7 @@ def activate_campaign(
|
||||
|
||||
# Create Jira tickets for campaign and tests at activation time (non-fatal).
|
||||
# Campaign ticket is created here if it doesn't already exist (deferred from creation).
|
||||
try:
|
||||
from app.services.jira_service import (
|
||||
auto_create_campaign_issue,
|
||||
auto_create_test_issue,
|
||||
get_campaign_jira_key,
|
||||
get_test_jira_key,
|
||||
)
|
||||
campaign_jira_key = get_campaign_jira_key(db, campaign_id)
|
||||
if not campaign_jira_key:
|
||||
campaign_jira_key = auto_create_campaign_issue(db, campaign, current_user)
|
||||
if campaign_jira_key:
|
||||
for ct in campaign.campaign_tests:
|
||||
if ct.test and not get_test_jira_key(db, ct.test.id):
|
||||
auto_create_test_issue(
|
||||
db, ct.test, current_user,
|
||||
parent_ticket_override=campaign_jira_key,
|
||||
campaign_start_date=campaign.start_date,
|
||||
)
|
||||
db.commit()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Jira ticket creation failed during activation of campaign %s",
|
||||
campaign_id,
|
||||
)
|
||||
_create_jira_tickets_for_campaign(db, campaign, campaign_id, current_user)
|
||||
|
||||
return serialize_campaign(db, campaign)
|
||||
|
||||
@@ -950,7 +968,7 @@ def get_campaign_progress_endpoint(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class GenerateFromActorPayload(BaseModel):
|
||||
pass
|
||||
start_date: Optional[datetime] = None
|
||||
|
||||
|
||||
@router.post("/from-threat-actor/{actor_id}", status_code=201)
|
||||
@@ -980,6 +998,7 @@ def generate_campaign_from_actor(
|
||||
db,
|
||||
uuid.UUID(actor_id),
|
||||
current_user,
|
||||
start_date=payload.start_date,
|
||||
)
|
||||
|
||||
# Open context manager
|
||||
|
||||
@@ -178,6 +178,8 @@ def generate_campaign_from_threat_actor(
|
||||
actor_id: uuid.UUID,
|
||||
# Entry: user
|
||||
user: User,
|
||||
# Entry: start_date
|
||||
start_date: datetime | None = None,
|
||||
) -> Campaign:
|
||||
"""Auto-generate a campaign from a threat actor's uncovered techniques.
|
||||
|
||||
@@ -235,6 +237,8 @@ def generate_campaign_from_threat_actor(
|
||||
created_by=user.id,
|
||||
# Keyword argument: tags
|
||||
tags=[actor.name, "auto-generated"],
|
||||
# Keyword argument: start_date
|
||||
start_date=start_date,
|
||||
)
|
||||
# Stage new record(s) for database insertion
|
||||
db.add(campaign)
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
"""Tests for authentication endpoints."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def _make_token(username: str, *, expired_seconds_ago: int | None = None) -> str:
|
||||
"""Build a raw JWT mirroring ``create_access_token`` with a controllable exp."""
|
||||
if expired_seconds_ago is None:
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=30)
|
||||
else:
|
||||
expire = datetime.now(timezone.utc) - timedelta(seconds=expired_seconds_ago)
|
||||
payload = {"sub": username, "exp": expire, "jti": str(uuid.uuid4())}
|
||||
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
|
||||
def test_login_success(client, admin_user):
|
||||
"""Test successful login returns a token."""
|
||||
@@ -122,3 +138,49 @@ def test_logout_revokes_token(client, admin_user):
|
||||
)
|
||||
assert me.status_code == 401
|
||||
assert me.json()["detail"] == "Token has been revoked"
|
||||
|
||||
|
||||
def test_refresh_without_cookie_fails(client):
|
||||
response = client.post("/api/v1/auth/refresh")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_refresh_valid_token_succeeds(client, admin_user):
|
||||
token = _make_token("admin")
|
||||
client.cookies.set("aegis_token", token)
|
||||
response = client.post("/api/v1/auth/refresh")
|
||||
assert response.status_code == 200
|
||||
assert "access_token" in response.json()
|
||||
|
||||
|
||||
def test_refresh_recently_expired_token_succeeds_within_grace(client, admin_user):
|
||||
"""A token that expired moments ago must still be refreshable.
|
||||
|
||||
This is the core of the refresh-token bug fix: without a grace window,
|
||||
`/auth/refresh` decodes with the same strict expiry check as every
|
||||
other endpoint, so a 401-triggered refresh attempt always also fails.
|
||||
"""
|
||||
token = _make_token("admin", expired_seconds_ago=60)
|
||||
client.cookies.set("aegis_token", token)
|
||||
response = client.post("/api/v1/auth/refresh")
|
||||
assert response.status_code == 200
|
||||
assert "access_token" in response.json()
|
||||
|
||||
|
||||
def test_refresh_long_expired_token_fails(client, admin_user):
|
||||
token = _make_token("admin", expired_seconds_ago=60 * 60)
|
||||
client.cookies.set("aegis_token", token)
|
||||
response = client.post("/api/v1/auth/refresh")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_refresh_blacklisted_token_fails_even_if_not_expired(client, admin_user):
|
||||
from app.auth import blacklist_token
|
||||
|
||||
token = _make_token("admin")
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
blacklist_token(payload["jti"], float(payload["exp"]))
|
||||
|
||||
client.cookies.set("aegis_token", token)
|
||||
response = client.post("/api/v1/auth/refresh")
|
||||
assert response.status_code == 403
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Router-level tests for the campaign manager-approval workflow."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.models.campaign import Campaign, CampaignTest
|
||||
from app.models.technique import Technique
|
||||
from app.models.test import Test
|
||||
@@ -51,6 +53,30 @@ def test_manager_can_approve_and_sets_start_date(api, db, red_lead_user, red_lea
|
||||
assert body["start_date"] is not None
|
||||
|
||||
|
||||
def test_manager_approval_creates_jira_tickets(api, db, red_lead_user, red_lead_headers, manager_headers):
|
||||
"""The normal manager-approval path must create Jira tickets too, not just
|
||||
the admin-only emergency /activate override — this was the Block 1 gap:
|
||||
campaigns approved through the standard flow never got a Jira ticket."""
|
||||
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||
|
||||
with patch("app.services.jira_service.get_campaign_jira_key", return_value=None) as mock_get_key, \
|
||||
patch("app.services.jira_service.auto_create_campaign_issue", return_value="PT-100") as mock_create_campaign, \
|
||||
patch("app.services.jira_service.get_test_jira_key", return_value=None), \
|
||||
patch("app.services.jira_service.auto_create_test_issue") as mock_create_test:
|
||||
resp = api(
|
||||
"post",
|
||||
f"/api/v1/campaigns/{campaign.id}/approve",
|
||||
manager_headers,
|
||||
json={"start_date": "2026-09-01T00:00:00"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
mock_get_key.assert_called_once()
|
||||
mock_create_campaign.assert_called_once()
|
||||
mock_create_test.assert_called_once()
|
||||
|
||||
|
||||
def test_lead_cannot_approve(api, db, red_lead_user, red_lead_headers):
|
||||
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Tests for generating a campaign from a threat actor's uncovered techniques.
|
||||
|
||||
Covers the Block 1 fix: the frontend sends a `start_date` when generating a
|
||||
campaign from a Threat Actor, but `GenerateFromActorPayload` used to be an
|
||||
empty schema, so the date was silently discarded and the campaign was
|
||||
created with `start_date = NULL`.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.enums import TechniqueStatus
|
||||
from app.models.technique import Technique
|
||||
from app.models.test_template import TestTemplate
|
||||
from app.models.threat_actor import ThreatActor, ThreatActorTechnique
|
||||
from app.services.campaign_service import generate_campaign_from_threat_actor
|
||||
|
||||
|
||||
def _seed_actor_with_gap_technique(db):
|
||||
tech = Technique(
|
||||
mitre_id="T1059.001",
|
||||
name="PowerShell",
|
||||
tactic="execution",
|
||||
platforms=["windows"],
|
||||
status_global=TechniqueStatus.not_evaluated,
|
||||
)
|
||||
db.add(tech)
|
||||
db.flush()
|
||||
|
||||
template = TestTemplate(
|
||||
mitre_technique_id=tech.mitre_id,
|
||||
name="PowerShell template",
|
||||
source="custom",
|
||||
severity="high",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(template)
|
||||
|
||||
actor = ThreatActor(name="APT-Test", mitre_id="G9999")
|
||||
db.add(actor)
|
||||
db.flush()
|
||||
|
||||
db.add(ThreatActorTechnique(threat_actor_id=actor.id, technique_id=tech.id))
|
||||
db.commit()
|
||||
db.refresh(actor)
|
||||
return actor
|
||||
|
||||
|
||||
def test_generate_from_actor_without_start_date_leaves_it_null(db, red_lead_user):
|
||||
actor = _seed_actor_with_gap_technique(db)
|
||||
campaign = generate_campaign_from_threat_actor(db, actor.id, red_lead_user)
|
||||
assert campaign.start_date is None
|
||||
|
||||
|
||||
def test_generate_from_actor_persists_start_date(db, red_lead_user):
|
||||
actor = _seed_actor_with_gap_technique(db)
|
||||
start_date = datetime(2026, 9, 1)
|
||||
campaign = generate_campaign_from_threat_actor(
|
||||
db, actor.id, red_lead_user, start_date=start_date,
|
||||
)
|
||||
assert campaign.start_date == start_date
|
||||
|
||||
|
||||
def test_generate_from_actor_router_forwards_start_date(api, db, red_lead_user, red_lead_headers):
|
||||
actor = _seed_actor_with_gap_technique(db)
|
||||
resp = api(
|
||||
"post",
|
||||
f"/api/v1/campaigns/from-threat-actor/{actor.id}",
|
||||
red_lead_headers,
|
||||
json={"start_date": "2026-09-01T00:00:00"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
assert resp.json()["start_date"] is not None
|
||||
@@ -35,6 +35,11 @@ export async function getMe(): Promise<User> {
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Silently renew the session cookie. Throws if there is nothing to renew. */
|
||||
export async function refreshToken(): Promise<void> {
|
||||
await client.post("/auth/refresh");
|
||||
}
|
||||
|
||||
/** Change the current user's password. */
|
||||
export async function changePassword(
|
||||
currentPassword: string,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
login as apiLogin,
|
||||
logout as apiLogout,
|
||||
getMe,
|
||||
refreshToken as apiRefreshToken,
|
||||
} from "../api/auth";
|
||||
import type { User } from "../types/models";
|
||||
import ChangePasswordModal from "../components/ChangePasswordModal";
|
||||
@@ -45,6 +46,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
refreshUser().finally(() => setIsLoading(false));
|
||||
}, [refreshUser]);
|
||||
|
||||
// Proactively renew the session cookie while the app is open, so an
|
||||
// actively-used session never actually reaches the hard 8-hour expiry
|
||||
// (relying solely on the reactive on-401 refresh risks losing the race
|
||||
// against the token's own expiry — see api/client.ts).
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const PROACTIVE_REFRESH_INTERVAL_MS = 20 * 60 * 1000;
|
||||
const id = setInterval(() => {
|
||||
apiRefreshToken().catch(() => {
|
||||
// Reactive on-401 handling in api/client.ts takes over from here.
|
||||
});
|
||||
}, PROACTIVE_REFRESH_INTERVAL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [user]);
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
await apiLogin(username, password);
|
||||
const me = await getMe();
|
||||
|
||||
@@ -32,6 +32,11 @@ import { getCoverageByTactic } from "../api/metrics";
|
||||
import { getThreatActors } from "../api/threat-actors";
|
||||
import { getTechniques, type TechniqueSummary } from "../api/techniques";
|
||||
import { getRiskProfiles, computeRiskScores, type RiskProfile } from "../api/risk";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
||||
// Roles allowed to trigger a risk-score recompute (matches the backend's
|
||||
// POST /risk/compute role gate) — viewer must never call this endpoint.
|
||||
const CAN_COMPUTE_RISK_ROLES = ["admin", "red_lead", "blue_lead"];
|
||||
|
||||
// ── Score Gauge Component ────────────────────────────────────────────
|
||||
|
||||
@@ -159,6 +164,8 @@ function sinceDate(days: number | null): string | undefined {
|
||||
|
||||
export default function ExecutiveDashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const canComputeRisk = CAN_COMPUTE_RISK_ROLES.includes(user?.role ?? "");
|
||||
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>("all");
|
||||
const rangeOption = TIME_RANGE_OPTIONS.find((o) => o.value === timeRange)!;
|
||||
@@ -214,9 +221,10 @@ export default function ExecutiveDashboardPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-compute on first load if no profiles exist
|
||||
// Auto-compute on first load if no profiles exist — only for roles the
|
||||
// backend actually allows to call POST /risk/compute (viewer would 403).
|
||||
useEffect(() => {
|
||||
if (!loadingRisk && riskProfiles !== undefined && riskProfiles.length === 0) {
|
||||
if (canComputeRisk && !loadingRisk && riskProfiles !== undefined && riskProfiles.length === 0) {
|
||||
computeMutation.mutate();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -708,19 +716,21 @@ export default function ExecutiveDashboardPage() {
|
||||
Critical Gaps — Top 10 by Risk Priority
|
||||
<MetricTooltip title="Critical Gaps" description="The 10 most dangerous attack techniques with no detection coverage, ranked by a composite risk score based on: how many threat actors use this technique, recent threat intelligence, and test history." context="These are your highest-priority gaps — real adversaries actively use these techniques and you cannot currently detect them." />
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => computeMutation.mutate()}
|
||||
disabled={computeMutation.isPending}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-2.5 py-1 text-xs text-gray-400 hover:text-cyan-400 hover:border-cyan-500/30 disabled:opacity-50 transition-colors"
|
||||
title="Recompute risk scores"
|
||||
>
|
||||
{computeMutation.isPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
)}
|
||||
{computeMutation.isPending ? "Computing…" : "Refresh scores"}
|
||||
</button>
|
||||
{canComputeRisk && (
|
||||
<button
|
||||
onClick={() => computeMutation.mutate()}
|
||||
disabled={computeMutation.isPending}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-2.5 py-1 text-xs text-gray-400 hover:text-cyan-400 hover:border-cyan-500/30 disabled:opacity-50 transition-colors"
|
||||
title="Recompute risk scores"
|
||||
>
|
||||
{computeMutation.isPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
)}
|
||||
{computeMutation.isPending ? "Computing…" : "Refresh scores"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
|
||||
Reference in New Issue
Block a user