Compare commits

...

2 Commits

Author SHA1 Message Date
kitos 1ddbd6989f fix(frontend): hide viewer-only risk-compute UI, proactively renew session
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
- Executive Dashboard no longer auto-triggers POST /risk/compute or shows
  the Refresh scores button for roles the backend rejects (viewer), which
  was causing a 403 on load
- AuthContext now proactively renews the session cookie every 20 minutes
  while the app is open, so an actively-used session doesn't rely solely
  on the reactive on-401 refresh racing against its own token's expiry
2026-07-07 13:42:54 +02:00
kitos bd26b09827 fix(backend): resolve refresh-token expiry deadlock, missing Jira on normal campaign approval, discarded threat-actor campaign start_date
- /auth/refresh now allows a short grace window past expiry and checks
  the blacklist, so an active session's silent refresh no longer fails
  the instant its own token expires
- normal manager /approve flow now creates Jira tickets for the campaign
  and its already-linked tests, matching the admin-only /activate path
- GenerateFromActorPayload now accepts start_date and threads it through
  to the new campaign instead of silently discarding it
2026-07-07 13:42:26 +02:00
9 changed files with 282 additions and 46 deletions
+28 -6
View File
@@ -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")
+44 -25
View File
@@ -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
+4
View File
@@ -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)
+62
View File
@@ -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
+5
View File
@@ -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,
+16
View File
@@ -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();
+25 -15
View File
@@ -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">