feat(phase-39): role-based access control overhaul + forced password change
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

- Add must_change_password field to User model with migration b023

- Add POST /auth/change-password endpoint with password policy validation

- Add require_password_changed dependency to block requests until password is changed

- Add ChangePasswordModal with live password policy checklist (forced on first login)

- Show password policy in CreateUserModal and EditUserModal

- Fix backend permissions: tests, campaigns, templates, reports, evidence, worklogs

- red_tech/blue_tech: execute only, cannot create tests/campaigns/templates

- red_lead/blue_lead: create/edit tests/campaigns/templates, generate reports, no system access

- viewer: read-only everywhere, can generate reports

- Fix frontend role checks across TestDetailPage, TestDetailHeader, TeamTabs, TestsPage, CampaignsPage, CampaignDetailPage, Sidebar
This commit is contained in:
2026-02-18 10:37:02 +01:00
parent 8f764d8e39
commit a4a2adccee
24 changed files with 338 additions and 72 deletions

View File

@@ -0,0 +1,30 @@
"""add_must_change_password
Revision ID: b023mustchgpwd
Revises: b022osintitems
Create Date: 2026-02-17 23:00:00.000000
Add must_change_password column to users table to force password
change on first login.
"""
from alembic import op
revision = "b023mustchgpwd"
down_revision = "b022osintitems"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
ALTER TABLE users
ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN DEFAULT true;
""")
def downgrade() -> None:
op.execute("""
ALTER TABLE users
DROP COLUMN IF EXISTS must_change_password;
""")

View File

@@ -91,6 +91,22 @@ async def get_current_user(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def require_password_changed(
current_user: User = Depends(get_current_user),
) -> User:
"""Block all requests when the user still needs to change their password.
Only ``/auth/change-password`` and ``/auth/me`` are exempt — those
endpoints do **not** depend on this function.
"""
if getattr(current_user, "must_change_password", False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="PASSWORD_CHANGE_REQUIRED",
)
return current_user
def require_role(required_role: str): def require_role(required_role: str):
"""Return a FastAPI dependency that enforces *required_role*. """Return a FastAPI dependency that enforces *required_role*.

View File

@@ -27,5 +27,6 @@ class User(Base):
hashed_password = Column(String, nullable=False) hashed_password = Column(String, nullable=False)
role = Column(String, nullable=False, default="viewer") role = Column(String, nullable=False, default="viewer")
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
must_change_password = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
last_login = Column(DateTime, nullable=True) last_login = Column(DateTime, nullable=True)

View File

@@ -17,12 +17,13 @@ from sqlalchemy.orm import Session
from jose import jwt, JWTError from jose import jwt, JWTError
from app.auth import verify_password, create_access_token, blacklist_token from app.auth import verify_password, hash_password, create_access_token, blacklist_token
from app.config import settings from app.config import settings
from app.database import get_db from app.database import get_db
from app.dependencies.auth import get_current_user from app.dependencies.auth import get_current_user
from app.models.user import User from app.models.user import User
from app.schemas.auth import TokenResponse, UserOut from app.schemas.auth import TokenResponse, UserOut
from app.schemas.user import PasswordChange
# Rate limiter instance (shares backend state via app.state.limiter) # Rate limiter instance (shares backend state via app.state.limiter)
limiter = Limiter(key_func=get_remote_address) limiter = Limiter(key_func=get_remote_address)
@@ -137,3 +138,33 @@ def logout(
def read_current_user(current_user: User = Depends(get_current_user)): def read_current_user(current_user: User = Depends(get_current_user)):
"""Return the profile of the currently authenticated user.""" """Return the profile of the currently authenticated user."""
return current_user return current_user
# ---------------------------------------------------------------------------
# POST /auth/change-password
# ---------------------------------------------------------------------------
@router.post("/change-password")
def change_password(
body: PasswordChange,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Change the current user's password.
Requires the current password for verification. On success the
``must_change_password`` flag is cleared so the user can proceed
normally.
"""
if not verify_password(body.current_password, current_user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Current password is incorrect",
)
current_user.hashed_password = hash_password(body.new_password)
current_user.must_change_password = False
db.commit()
return {"detail": "Password changed successfully"}

View File

@@ -198,7 +198,7 @@ def list_campaigns(
def create_campaign( def create_campaign(
payload: CampaignCreate, payload: CampaignCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech", "admin")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Create a new campaign.""" """Create a new campaign."""
campaign = Campaign( campaign = Campaign(
@@ -254,7 +254,7 @@ def update_campaign(
campaign_id: str, campaign_id: str,
payload: CampaignUpdate, payload: CampaignUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech", "admin")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Update a campaign. Only allowed in draft or active state.""" """Update a campaign. Only allowed in draft or active state."""
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first() campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
@@ -299,7 +299,7 @@ def add_test_to_campaign(
campaign_id: str, campaign_id: str,
payload: AddTestPayload, payload: AddTestPayload,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech", "admin")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Add a test to a campaign with optional ordering and dependency.""" """Add a test to a campaign with optional ordering and dependency."""
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first() campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
@@ -370,7 +370,7 @@ def remove_test_from_campaign(
campaign_id: str, campaign_id: str,
campaign_test_id: str, campaign_test_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech", "admin")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Remove a test from a campaign.""" """Remove a test from a campaign."""
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first() campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
@@ -414,7 +414,7 @@ def remove_test_from_campaign(
def activate_campaign( def activate_campaign(
campaign_id: str, campaign_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech", "admin")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Activate a campaign, moving it from draft to active.""" """Activate a campaign, moving it from draft to active."""
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first() campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
@@ -524,7 +524,7 @@ def get_campaign_progress_endpoint(
def generate_campaign_from_actor( def generate_campaign_from_actor(
actor_id: str, actor_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech", "admin")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Auto-generate a campaign from a threat actor's uncovered techniques. """Auto-generate a campaign from a threat actor's uncovered techniques.
@@ -558,7 +558,7 @@ def schedule_campaign(
campaign_id: str, campaign_id: str,
payload: SchedulePayload, payload: SchedulePayload,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("admin")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Configure or update the recurrence schedule for a campaign. """Configure or update the recurrence schedule for a campaign.

View File

@@ -98,11 +98,10 @@ def _validate_upload_permission(
return return
if team == TeamSide.red: if team == TeamSide.red:
# Only red_tech can upload red evidence if user.role not in ("red_tech", "red_lead"):
if user.role != "red_tech":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Only red_tech or admin can upload red evidence", detail="Only red_tech, red_lead or admin can upload red evidence",
) )
if test.state not in _RED_EDITABLE_STATES: if test.state not in _RED_EDITABLE_STATES:
raise HTTPException( raise HTTPException(
@@ -111,11 +110,10 @@ def _validate_upload_permission(
f"(allowed in: draft, red_executing)", f"(allowed in: draft, red_executing)",
) )
elif team == TeamSide.blue: elif team == TeamSide.blue:
# Only blue_tech can upload blue evidence if user.role not in ("blue_tech", "blue_lead"):
if user.role != "blue_tech":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Only blue_tech or admin can upload blue evidence", detail="Only blue_tech, blue_lead or admin can upload blue evidence",
) )
if test.state not in _BLUE_EDITABLE_STATES: if test.state not in _BLUE_EDITABLE_STATES:
raise HTTPException( raise HTTPException(
@@ -150,7 +148,7 @@ def _validate_delete_permission(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot delete red evidence outside draft/red_executing", detail="Cannot delete red evidence outside draft/red_executing",
) )
if user.role != "red_tech" and evidence.uploaded_by != user.id: if user.role not in ("red_tech", "red_lead") and evidence.uploaded_by != user.id:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to delete this evidence", detail="Not enough permissions to delete this evidence",
@@ -161,7 +159,7 @@ def _validate_delete_permission(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot delete blue evidence outside blue_evaluating", detail="Cannot delete blue evidence outside blue_evaluating",
) )
if user.role != "blue_tech" and evidence.uploaded_by != user.id: if user.role not in ("blue_tech", "blue_lead") and evidence.uploaded_by != user.id:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to delete this evidence", detail="Not enough permissions to delete this evidence",

View File

@@ -25,7 +25,7 @@ def generate_purple_report(
campaign_id: UUID, campaign_id: UUID,
format: str = Query("pdf", pattern="^(pdf|docx|html)$"), format: str = Query("pdf", pattern="^(pdf|docx|html)$"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: User = Depends(require_any_role("red_lead", "blue_lead")), user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
): ):
"""Generate a Purple Team campaign assessment report.""" """Generate a Purple Team campaign assessment report."""
filepath = report_generation_service.generate_purple_campaign_report( filepath = report_generation_service.generate_purple_campaign_report(
@@ -42,7 +42,7 @@ def generate_purple_report(
def generate_coverage_report( def generate_coverage_report(
format: str = Query("pdf", pattern="^(pdf|docx|html)$"), format: str = Query("pdf", pattern="^(pdf|docx|html)$"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: User = Depends(get_current_user), user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
): ):
"""Generate an organization-wide MITRE ATT&CK coverage report.""" """Generate an organization-wide MITRE ATT&CK coverage report."""
filepath = report_generation_service.generate_coverage_report( filepath = report_generation_service.generate_coverage_report(
@@ -59,7 +59,7 @@ def generate_coverage_report(
def generate_executive_report( def generate_executive_report(
format: str = Query("pdf", pattern="^(pdf|docx|html)$"), format: str = Query("pdf", pattern="^(pdf|docx|html)$"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: User = Depends(require_any_role("red_lead", "blue_lead")), user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
): ):
"""Generate an executive security summary report.""" """Generate an executive security summary report."""
filepath = report_generation_service.generate_executive_summary( filepath = report_generation_service.generate_executive_summary(

View File

@@ -30,7 +30,7 @@ from sqlalchemy import func, or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.dependencies.auth import get_current_user, require_role from app.dependencies.auth import get_current_user, require_role, require_any_role
from app.models.test_template import TestTemplate from app.models.test_template import TestTemplate
from app.models.user import User from app.models.user import User
from app.schemas.test_template import ( from app.schemas.test_template import (
@@ -103,7 +103,7 @@ def list_templates(
@router.get("/stats") @router.get("/stats")
def template_stats( def template_stats(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Return catalog statistics: totals by source, platform, active/inactive.""" """Return catalog statistics: totals by source, platform, active/inactive."""
@@ -151,9 +151,9 @@ def template_stats(
def bulk_activate_templates( def bulk_activate_templates(
activate: bool = Query(True, description="True to activate all, False to deactivate all"), activate: bool = Query(True, description="True to activate all, False to deactivate all"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Set all templates to active or inactive. Admin only.""" """Set all templates to active or inactive."""
count = ( count = (
db.query(TestTemplate) db.query(TestTemplate)
.filter(TestTemplate.is_active != activate) .filter(TestTemplate.is_active != activate)
@@ -235,9 +235,9 @@ def get_template(
def create_template( def create_template(
payload: TestTemplateCreate, payload: TestTemplateCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Create a custom test template. Admin only.""" """Create a custom test template."""
template = TestTemplate(**payload.model_dump()) template = TestTemplate(**payload.model_dump())
db.add(template) db.add(template)
db.commit() db.commit()
@@ -269,9 +269,9 @@ def update_template(
template_id: uuid.UUID, template_id: uuid.UUID,
payload: TestTemplateCreate, payload: TestTemplateCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Update fields of an existing test template. Admin only.""" """Update fields of an existing test template."""
template = db.query(TestTemplate).filter(TestTemplate.id == template_id).first() template = db.query(TestTemplate).filter(TestTemplate.id == template_id).first()
if template is None: if template is None:
raise HTTPException( raise HTTPException(
@@ -307,9 +307,9 @@ def update_template(
def toggle_template_active( def toggle_template_active(
template_id: uuid.UUID, template_id: uuid.UUID,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Toggle a template between active and inactive. Admin only.""" """Toggle a template between active and inactive."""
template = db.query(TestTemplate).filter(TestTemplate.id == template_id).first() template = db.query(TestTemplate).filter(TestTemplate.id == template_id).first()
if template is None: if template is None:
raise HTTPException( raise HTTPException(
@@ -342,9 +342,9 @@ def toggle_template_active(
def delete_template( def delete_template(
template_id: uuid.UUID, template_id: uuid.UUID,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Soft-delete a test template by setting ``is_active=False``. Admin only.""" """Soft-delete a test template by setting ``is_active=False``."""
template = db.query(TestTemplate).filter(TestTemplate.id == template_id).first() template = db.query(TestTemplate).filter(TestTemplate.id == template_id).first()
if template is None: if template is None:
raise HTTPException( raise HTTPException(

View File

@@ -143,7 +143,7 @@ def list_tests(
def create_test( def create_test(
payload: TestCreate, payload: TestCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Create a new test linked to an existing technique. """Create a new test linked to an existing technique.
@@ -190,7 +190,7 @@ def create_test(
def create_test_from_template( def create_test_from_template(
payload: TestTemplateInstantiate, payload: TestTemplateInstantiate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Instantiate a real Test from an existing TestTemplate. """Instantiate a real Test from an existing TestTemplate.
@@ -289,11 +289,11 @@ def update_test(
test_id: uuid.UUID, test_id: uuid.UUID,
payload: TestUpdate, payload: TestUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Update one or more fields of an existing test. """Update one or more fields of an existing test.
Only the original creator or an admin can update. Only leads or admins can update general test fields.
The test must be in ``draft`` or ``rejected`` state. The test must be in ``draft`` or ``rejected`` state.
""" """
test = _get_test_or_404(db, test_id) test = _get_test_or_404(db, test_id)
@@ -343,7 +343,7 @@ def update_test_red(
test_id: uuid.UUID, test_id: uuid.UUID,
payload: TestRedUpdate, payload: TestRedUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech")), current_user: User = Depends(require_any_role("red_tech", "red_lead")),
): ):
"""Red Team updates their fields (allowed in ``draft`` and ``red_executing``).""" """Red Team updates their fields (allowed in ``draft`` and ``red_executing``)."""
test = _get_test_or_404(db, test_id) test = _get_test_or_404(db, test_id)
@@ -387,7 +387,7 @@ def update_test_blue(
test_id: uuid.UUID, test_id: uuid.UUID,
payload: TestBlueUpdate, payload: TestBlueUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("blue_tech")), current_user: User = Depends(require_any_role("blue_tech", "blue_lead")),
): ):
"""Blue Team updates their fields (allowed only in ``blue_evaluating``).""" """Blue Team updates their fields (allowed only in ``blue_evaluating``)."""
test = _get_test_or_404(db, test_id) test = _get_test_or_404(db, test_id)
@@ -430,7 +430,7 @@ def update_test_blue(
def start_execution( def start_execution(
test_id: uuid.UUID, test_id: uuid.UUID,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech")), current_user: User = Depends(require_any_role("red_tech", "red_lead")),
): ):
"""Move a test from ``draft`` to ``red_executing``.""" """Move a test from ``draft`` to ``red_executing``."""
test = _get_test_or_404(db, test_id) test = _get_test_or_404(db, test_id)
@@ -448,7 +448,7 @@ def start_execution(
def submit_red( def submit_red(
test_id: uuid.UUID, test_id: uuid.UUID,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech")), current_user: User = Depends(require_any_role("red_tech", "red_lead")),
): ):
"""Red Team finalises — move from ``red_executing`` to ``blue_evaluating``.""" """Red Team finalises — move from ``red_executing`` to ``blue_evaluating``."""
test = _get_test_or_404(db, test_id) test = _get_test_or_404(db, test_id)
@@ -466,7 +466,7 @@ def submit_red(
def submit_blue( def submit_blue(
test_id: uuid.UUID, test_id: uuid.UUID,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("blue_tech")), current_user: User = Depends(require_any_role("blue_tech", "blue_lead")),
): ):
"""Blue Team finalises — move from ``blue_evaluating`` to ``in_review``.""" """Blue Team finalises — move from ``blue_evaluating`` to ``in_review``."""
test = _get_test_or_404(db, test_id) test = _get_test_or_404(db, test_id)
@@ -484,7 +484,7 @@ def submit_blue(
def pause_timer( def pause_timer(
test_id: uuid.UUID, test_id: uuid.UUID,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(require_any_role("red_tech", "blue_tech", "red_lead", "blue_lead")),
): ):
"""Pause the running timer for the current phase (red_executing or blue_evaluating).""" """Pause the running timer for the current phase (red_executing or blue_evaluating)."""
test = _get_test_or_404(db, test_id) test = _get_test_or_404(db, test_id)
@@ -502,7 +502,7 @@ def pause_timer(
def resume_timer( def resume_timer(
test_id: uuid.UUID, test_id: uuid.UUID,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(require_any_role("red_tech", "blue_tech", "red_lead", "blue_lead")),
): ):
"""Resume the paused timer for the current phase.""" """Resume the paused timer for the current phase."""
test = _get_test_or_404(db, test_id) test = _get_test_or_404(db, test_id)
@@ -595,9 +595,9 @@ def update_remediation(
test_id: uuid.UUID, test_id: uuid.UUID,
payload: TestRemediationUpdate, payload: TestRemediationUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Update remediation fields on a test (any authenticated user). """Update remediation fields on a test.
When ``remediation_status`` transitions to ``'completed'``, an automatic When ``remediation_status`` transitions to ``'completed'``, an automatic
re-test is created (subject to ``MAX_RETEST_COUNT``). re-test is created (subject to ``MAX_RETEST_COUNT``).

View File

@@ -9,7 +9,7 @@ from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.dependencies.auth import get_current_user from app.dependencies.auth import get_current_user, require_any_role
from app.domain.exceptions import EntityNotFoundError from app.domain.exceptions import EntityNotFoundError
from app.models.user import User from app.models.user import User
from app.models.worklog import Worklog from app.models.worklog import Worklog
@@ -56,7 +56,7 @@ class WorklogOut(BaseModel):
def create( def create(
body: WorklogCreate, body: WorklogCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: User = Depends(get_current_user), user: User = Depends(require_any_role("red_tech", "blue_tech", "red_lead", "blue_lead")),
): ):
"""Create a manually-logged worklog entry.""" """Create a manually-logged worklog entry."""
wl = worklog_service.create_worklog( wl = worklog_service.create_worklog(

View File

@@ -28,6 +28,7 @@ class UserOut(BaseModel):
email: str | None = None email: str | None = None
role: str role: str
is_active: bool is_active: bool
must_change_password: bool = True
class Config: class Config:
from_attributes = True from_attributes = True

View File

@@ -83,6 +83,18 @@ class UserUpdate(BaseModel):
# ── Read (full) ───────────────────────────────────────────────────── # ── Read (full) ─────────────────────────────────────────────────────
class PasswordChange(BaseModel):
"""Payload for changing the current user's password."""
current_password: str
new_password: str
@field_validator("new_password")
@classmethod
def new_password_strength(cls, v: str) -> str:
return _validate_password_strength(v)
class UserOut(BaseModel): class UserOut(BaseModel):
"""Complete representation returned by the API.""" """Complete representation returned by the API."""
@@ -91,6 +103,7 @@ class UserOut(BaseModel):
email: str | None = None email: str | None = None
role: str role: str
is_active: bool is_active: bool
must_change_password: bool = True
created_at: datetime | None = None created_at: datetime | None = None
last_login: datetime | None = None last_login: datetime | None = None

View File

@@ -34,3 +34,14 @@ export async function getMe(): Promise<User> {
const { data } = await client.get<User>("/auth/me"); const { data } = await client.get<User>("/auth/me");
return data; return data;
} }
/** Change the current user's password. */
export async function changePassword(
currentPassword: string,
newPassword: string,
): Promise<void> {
await client.post("/auth/change-password", {
current_password: currentPassword,
new_password: newPassword,
});
}

View File

@@ -0,0 +1,148 @@
import { useState, useMemo } from "react";
import { changePassword } from "../api/auth";
interface PasswordRule {
label: string;
test: (pw: string) => boolean;
}
const PASSWORD_RULES: PasswordRule[] = [
{ label: "At least 12 characters", test: (pw) => pw.length >= 12 },
{ label: "At least one uppercase letter", test: (pw) => /[A-Z]/.test(pw) },
{ label: "At least one lowercase letter", test: (pw) => /[a-z]/.test(pw) },
{ label: "At least one digit", test: (pw) => /[0-9]/.test(pw) },
{
label: "At least one special character (!@#$%^&*…)",
test: (pw) => /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?`~]/.test(pw),
},
];
export function PasswordPolicyChecklist({ password }: { password: string }) {
return (
<ul className="mt-2 space-y-1 text-xs">
{PASSWORD_RULES.map((rule) => {
const ok = password.length > 0 && rule.test(password);
return (
<li key={rule.label} className="flex items-center gap-1.5">
<span className={ok ? "text-green-400" : "text-gray-500"}>
{ok ? "✓" : "○"}
</span>
<span className={ok ? "text-green-300" : "text-gray-400"}>
{rule.label}
</span>
</li>
);
})}
</ul>
);
}
interface Props {
onSuccess: () => void;
isForced?: boolean;
}
export default function ChangePasswordModal({ onSuccess, isForced }: Props) {
const [currentPw, setCurrentPw] = useState("");
const [newPw, setNewPw] = useState("");
const [confirmPw, setConfirmPw] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const allRulesPass = useMemo(
() => PASSWORD_RULES.every((r) => r.test(newPw)),
[newPw],
);
const canSubmit =
currentPw.length > 0 &&
allRulesPass &&
newPw === confirmPw &&
!loading;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!canSubmit) return;
setLoading(true);
setError(null);
try {
await changePassword(currentPw, newPw);
onSuccess();
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { detail?: string } } })?.response?.data
?.detail ?? "Failed to change password";
setError(msg);
} finally {
setLoading(false);
}
}
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm">
<form
onSubmit={handleSubmit}
className="w-full max-w-md rounded-xl border border-gray-700 bg-gray-900 p-6 shadow-2xl"
>
<h2 className="mb-1 text-lg font-semibold text-white">
Change Password
</h2>
{isForced && (
<p className="mb-4 text-sm text-amber-400">
You must change your password before continuing.
</p>
)}
{error && (
<div className="mb-3 rounded bg-red-900/50 px-3 py-2 text-sm text-red-300">
{error}
</div>
)}
<label className="mb-1 block text-sm text-gray-400">
Current password
</label>
<input
type="password"
className="mb-4 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white focus:border-cyan-500 focus:outline-none"
value={currentPw}
onChange={(e) => setCurrentPw(e.target.value)}
autoFocus
/>
<label className="mb-1 block text-sm text-gray-400">
New password
</label>
<input
type="password"
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white focus:border-cyan-500 focus:outline-none"
value={newPw}
onChange={(e) => setNewPw(e.target.value)}
/>
<PasswordPolicyChecklist password={newPw} />
<label className="mb-1 mt-4 block text-sm text-gray-400">
Confirm new password
</label>
<input
type="password"
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white focus:border-cyan-500 focus:outline-none"
value={confirmPw}
onChange={(e) => setConfirmPw(e.target.value)}
/>
{confirmPw.length > 0 && newPw !== confirmPw && (
<p className="mt-1 text-xs text-red-400">Passwords do not match</p>
)}
<button
type="submit"
disabled={!canSubmit}
className="mt-6 w-full rounded-lg bg-cyan-600 py-2.5 text-sm font-medium text-white transition-colors hover:bg-cyan-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? "Changing…" : "Change Password"}
</button>
</form>
</div>
);
}

View File

@@ -33,7 +33,7 @@ interface NavItem {
const mainLinks: NavItem[] = [ const mainLinks: NavItem[] = [
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, { to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ to: "/executive-dashboard", label: "Executive Dashboard", icon: Gauge, roles: ["admin", "red_lead", "blue_lead"] }, { to: "/executive-dashboard", label: "Executive Dashboard", icon: Gauge, roles: ["admin", "red_lead", "blue_lead", "viewer"] },
{ to: "/matrix", label: "ATT&CK Matrix", icon: Grid3X3 }, { to: "/matrix", label: "ATT&CK Matrix", icon: Grid3X3 },
{ {
to: "/tests", to: "/tests",
@@ -48,7 +48,7 @@ const mainLinks: NavItem[] = [
{ to: "/campaigns", label: "Campaigns", icon: Zap }, { to: "/campaigns", label: "Campaigns", icon: Zap },
{ to: "/threat-actors", label: "Threat Actors", icon: Crosshair }, { to: "/threat-actors", label: "Threat Actors", icon: Crosshair },
{ to: "/compliance", label: "Compliance", icon: ShieldCheck }, { to: "/compliance", label: "Compliance", icon: ShieldCheck },
{ to: "/comparison", label: "Comparison", icon: GitCompareArrows, roles: ["admin", "red_lead", "blue_lead"] }, { to: "/comparison", label: "Comparison", icon: GitCompareArrows, roles: ["admin", "red_lead", "blue_lead", "viewer"] },
{ to: "/reports", label: "Reports", icon: BarChart3 }, { to: "/reports", label: "Reports", icon: BarChart3 },
]; ];

View File

@@ -119,11 +119,11 @@ export default function TeamTabs({
const canEditRed = const canEditRed =
RED_EDITABLE_STATES.includes(test.state) && RED_EDITABLE_STATES.includes(test.state) &&
(role === "red_tech" || role === "admin"); (role === "red_tech" || role === "red_lead" || role === "admin");
const canEditBlue = const canEditBlue =
BLUE_EDITABLE_STATES.includes(test.state) && BLUE_EDITABLE_STATES.includes(test.state) &&
(role === "blue_tech" || role === "admin"); (role === "blue_tech" || role === "blue_lead" || role === "admin");
// ── Red Team Tab ───────────────────────────────────────────────── // ── Red Team Tab ─────────────────────────────────────────────────

View File

@@ -91,10 +91,10 @@ export default function TestDetailHeader({
const renderActions = () => { const renderActions = () => {
const buttons: React.ReactNode[] = []; const buttons: React.ReactNode[] = [];
// Red Tech in draft -> Start Execution // Red Team in draft -> Start Execution
if ( if (
test.state === "draft" && test.state === "draft" &&
(role === "red_tech" || role === "admin") (role === "red_tech" || role === "red_lead" || role === "admin")
) { ) {
buttons.push( buttons.push(
<button <button
@@ -109,10 +109,10 @@ export default function TestDetailHeader({
); );
} }
// Red Tech in red_executing -> Submit to Blue Team // Red Team in red_executing -> Submit to Blue Team
if ( if (
test.state === "red_executing" && test.state === "red_executing" &&
(role === "red_tech" || role === "admin") (role === "red_tech" || role === "red_lead" || role === "admin")
) { ) {
buttons.push( buttons.push(
<button <button
@@ -127,10 +127,10 @@ export default function TestDetailHeader({
); );
} }
// Blue Tech in blue_evaluating -> Submit for Review // Blue Team in blue_evaluating -> Submit for Review
if ( if (
test.state === "blue_evaluating" && test.state === "blue_evaluating" &&
(role === "blue_tech" || role === "admin") (role === "blue_tech" || role === "blue_lead" || role === "admin")
) { ) {
buttons.push( buttons.push(
<button <button
@@ -245,8 +245,8 @@ export default function TestDetailHeader({
// ── Live timer ─────────────────────────────────────────────────── // ── Live timer ───────────────────────────────────────────────────
const canControlTimer = const canControlTimer =
(test.state === "red_executing" && (role === "red_tech" || role === "admin")) || (test.state === "red_executing" && (role === "red_tech" || role === "red_lead" || role === "admin")) ||
(test.state === "blue_evaluating" && (role === "blue_tech" || role === "admin")); (test.state === "blue_evaluating" && (role === "blue_tech" || role === "blue_lead" || role === "admin"));
const renderLiveTimer = () => { const renderLiveTimer = () => {
if (test.state === "red_executing" && test.red_started_at) { if (test.state === "red_executing" && test.red_started_at) {

View File

@@ -12,6 +12,7 @@ import {
getMe, getMe,
} from "../api/auth"; } from "../api/auth";
import type { User } from "../types/models"; import type { User } from "../types/models";
import ChangePasswordModal from "../components/ChangePasswordModal";
/* ── Context shape ────────────────────────────────────────────────── */ /* ── Context shape ────────────────────────────────────────────────── */
@@ -31,18 +32,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
// On mount — try to hydrate the user from the existing HttpOnly cookie. const refreshUser = useCallback(async () => {
// If no valid cookie exists the /auth/me call will 401 and we stay try {
// unauthenticated — no localStorage involved. const me = await getMe();
useEffect(() => { setUser(me);
getMe() } catch {
.then(setUser) setUser(null);
.catch(() => setUser(null)) }
.finally(() => setIsLoading(false));
}, []); }, []);
useEffect(() => {
refreshUser().finally(() => setIsLoading(false));
}, [refreshUser]);
const login = useCallback(async (username: string, password: string) => { const login = useCallback(async (username: string, password: string) => {
// The backend sets the HttpOnly cookie automatically
await apiLogin(username, password); await apiLogin(username, password);
const me = await getMe(); const me = await getMe();
setUser(me); setUser(me);
@@ -53,6 +56,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(null); setUser(null);
}, []); }, []);
const mustChangePassword = user?.must_change_password === true;
return ( return (
<AuthContext.Provider <AuthContext.Provider
value={{ value={{
@@ -64,6 +69,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}} }}
> >
{children} {children}
{mustChangePassword && (
<ChangePasswordModal isForced onSuccess={refreshUser} />
)}
</AuthContext.Provider> </AuthContext.Provider>
); );
} }

View File

@@ -70,7 +70,7 @@ export default function CampaignDetailPage() {
}; };
const role = user?.role ?? ""; const role = user?.role ?? "";
const canManage = role === "admin" || role === "red_tech"; const canManage = role === "admin" || role === "red_lead" || role === "blue_lead";
const canComplete = role === "admin" || role === "red_lead"; const canComplete = role === "admin" || role === "red_lead";
const { const {

View File

@@ -56,7 +56,7 @@ export default function CampaignsPage() {
target_platform: "", target_platform: "",
}); });
const canCreate = user?.role === "admin" || user?.role === "red_tech"; const canCreate = user?.role === "admin" || user?.role === "red_lead" || user?.role === "blue_lead";
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ["campaigns", filters], queryKey: ["campaigns", filters],

View File

@@ -344,10 +344,10 @@ export default function TestDetailPage() {
const role = user?.role ?? ""; const role = user?.role ?? "";
const canSaveRed = const canSaveRed =
(test.state === "draft" || test.state === "red_executing") && (test.state === "draft" || test.state === "red_executing") &&
(role === "red_tech" || role === "admin"); (role === "red_tech" || role === "red_lead" || role === "admin");
const canSaveBlue = const canSaveBlue =
test.state === "blue_evaluating" && test.state === "blue_evaluating" &&
(role === "blue_tech" || role === "admin"); (role === "blue_tech" || role === "blue_lead" || role === "admin");
// ── Render ───────────────────────────────────────────────────── // ── Render ─────────────────────────────────────────────────────

View File

@@ -75,7 +75,7 @@ export default function TestsPage() {
const { user } = useAuth(); const { user } = useAuth();
const canCreate = const canCreate =
user?.role === "admin" || user?.role === "red_tech"; user?.role === "admin" || user?.role === "red_lead" || user?.role === "blue_lead";
// ── Filter state ────────────────────────────────────────────────── // ── Filter state ──────────────────────────────────────────────────
const [stateFilter, setStateFilter] = useState<TestState | "">(""); const [stateFilter, setStateFilter] = useState<TestState | "">("");

View File

@@ -12,6 +12,7 @@ import {
Edit, Edit,
} from "lucide-react"; } from "lucide-react";
import { getUsers, createUser, updateUser, type UserOut, type UserCreatePayload } from "../api/users"; import { getUsers, createUser, updateUser, type UserOut, type UserCreatePayload } from "../api/users";
import { PasswordPolicyChecklist } from "../components/ChangePasswordModal";
const ROLES = [ const ROLES = [
{ value: "viewer", label: "Viewer" }, { value: "viewer", label: "Viewer" },
@@ -323,7 +324,11 @@ function CreateUserModal({ onClose, onSubmit, isSubmitting, error }: CreateUserM
errors.password ? "border-red-500" : "border-gray-700" errors.password ? "border-red-500" : "border-gray-700"
}`} }`}
/> />
<PasswordPolicyChecklist password={formData.password} />
{errors.password && <p className="mt-1 text-sm text-red-400">{errors.password}</p>} {errors.password && <p className="mt-1 text-sm text-red-400">{errors.password}</p>}
<p className="mt-1 text-xs text-amber-400/70">
The user will be required to change this password on first login.
</p>
</div> </div>
<div> <div>
@@ -446,6 +451,9 @@ function EditUserModal({ user, onClose, onSubmit, isSubmitting, error }: EditUse
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-gray-200" className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-gray-200"
placeholder="••••••••" placeholder="••••••••"
/> />
{formData.password.length > 0 && (
<PasswordPolicyChecklist password={formData.password} />
)}
</div> </div>
<div className="flex justify-end gap-3 pt-4"> <div className="flex justify-end gap-3 pt-4">

View File

@@ -6,6 +6,7 @@ export interface User {
id: string; id: string;
username: string; username: string;
role: string; role: string;
must_change_password?: boolean;
} }
// ── Techniques ───────────────────────────────────────────────────── // ── Techniques ─────────────────────────────────────────────────────