feat(tests,users): blocking procedure-suggestion review on test detail, multi-role switcher, Power Automate webhook payload
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

- A lead opening a test with a pending procedure suggestion awaiting
  their review now gets a blocking popup (approve/reject only) instead
  of discovering it later in a separate queue.
- Users can be granted more than one role via extra_roles; only one is
  ever active at a time (no permission mixing) and a top-bar switcher
  lets the user swap which one, taking effect immediately since role
  is read fresh from the DB on every request.
- The password-setup/reset webhook now posts the agreed Power Automate
  contract ({to, subject, body} with the platform's standard greeting
  and signature template) and supports an admin-configured API key
  sent as an x-api-key header.
This commit is contained in:
kitos
2026-07-20 09:29:36 +02:00
parent 4dea19cae9
commit bbe7f49c86
24 changed files with 708 additions and 49 deletions
@@ -0,0 +1,32 @@
"""Add users.extra_roles for multi-role support.
A user can be granted more than one role, but only ever acts under a
single "active" role at a time (``users.role``, unchanged — every
existing permission check keeps reading it as-is). ``extra_roles`` holds
the *other* roles a user can switch into via the top-bar role switcher;
switching swaps the active role with one from this list.
Revision ID: b066
Revises: b065
Create Date: 2026-07-20
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "b066"
down_revision = "b065"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("extra_roles", postgresql.JSONB(), nullable=False, server_default="[]"),
)
def downgrade() -> None:
op.drop_column("users", "extra_roles")
+4
View File
@@ -37,6 +37,10 @@ class User(Base):
hashed_password = Column(String, nullable=False) hashed_password = Column(String, nullable=False)
# Assign role = Column(String, nullable=False, default="viewer") # Assign role = Column(String, nullable=False, default="viewer")
role = Column(String, nullable=False, default="viewer") role = Column(String, nullable=False, default="viewer")
# Other roles this user can switch into (the currently-active role
# lives in `role` above and is what every permission check reads —
# switching just swaps which one is active, never grants both at once).
extra_roles = Column(JSONB, nullable=False, default=list, server_default="[]")
# Assign is_active = Column(Boolean, default=True) # Assign is_active = Column(Boolean, default=True)
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
# Assign must_change_password = Column(Boolean, default=True) # Assign must_change_password = Column(Boolean, default=True)
@@ -73,6 +73,25 @@ def list_suggestions(
return [_to_out(s, template_by_id) for s in suggestions] return [_to_out(s, template_by_id) for s in suggestions]
@router.get("/for-test/{test_id}", response_model=list[ProcedureSuggestionOut])
def list_suggestions_for_test(
test_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")),
) -> list[ProcedureSuggestionOut]:
"""List pending suggestions tied to a specific test, scoped to the
caller's team (admins see both) — powers the blocking review popup a
lead sees when opening a test that has one awaiting them."""
team = _team_for_role(current_user)
suggestions = list_pending_suggestions(db, team=team, source_test_id=test_id)
template_ids = {s.template_id for s in suggestions}
templates = (
db.query(TestTemplate).filter(TestTemplate.id.in_(template_ids)).all() if template_ids else []
)
template_by_id = {t.id: t for t in templates}
return [_to_out(s, template_by_id) for s in suggestions]
@router.post("/{suggestion_id}/approve", response_model=ProcedureSuggestionOut) @router.post("/{suggestion_id}/approve", response_model=ProcedureSuggestionOut)
def approve_suggestion( def approve_suggestion(
suggestion_id: uuid.UUID, suggestion_id: uuid.UUID,
+19 -5
View File
@@ -347,10 +347,13 @@ def update_jira_config(
class PasswordWebhookConfigOut(BaseModel): class PasswordWebhookConfigOut(BaseModel):
configured: bool configured: bool
url: str url: str
api_key_set: bool
class PasswordWebhookConfigUpdate(BaseModel): class PasswordWebhookConfigUpdate(BaseModel):
url: str url: str
# Optional — omit or send "" to leave the currently-stored key unchanged.
api_key: Optional[str] = None
@router.get("/password-webhook-config", response_model=PasswordWebhookConfigOut) @router.get("/password-webhook-config", response_model=PasswordWebhookConfigOut)
@@ -360,12 +363,15 @@ def get_password_webhook_config(
): ):
"""Return the configured webhook URL used to email set-password links. """Return the configured webhook URL used to email set-password links.
The API key itself is never returned, only whether one is set.
**Requires** the ``admin`` role. **Requires** the ``admin`` role.
""" """
from app.services.password_setup_service import get_password_webhook_url from app.services.password_setup_service import get_password_webhook_api_key, get_password_webhook_url
url = get_password_webhook_url(db) or "" url = get_password_webhook_url(db) or ""
return PasswordWebhookConfigOut(configured=bool(url), url=url) api_key_set = bool(get_password_webhook_api_key(db))
return PasswordWebhookConfigOut(configured=bool(url), url=url, api_key_set=api_key_set)
@router.patch("/password-webhook-config", response_model=PasswordWebhookConfigOut) @router.patch("/password-webhook-config", response_model=PasswordWebhookConfigOut)
@@ -374,16 +380,24 @@ def update_password_webhook_config(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")), current_user: User = Depends(require_role("admin")),
): ):
"""Set the webhook URL used to email set-password links. """Set the webhook URL (and optionally API key) used to email set-password links.
**Requires** the ``admin`` role. **Requires** the ``admin`` role.
""" """
from app.services.password_setup_service import get_password_webhook_url, set_password_webhook_url from app.services.password_setup_service import (
get_password_webhook_api_key,
get_password_webhook_url,
set_password_webhook_api_key,
set_password_webhook_url,
)
set_password_webhook_url(db, payload.url) set_password_webhook_url(db, payload.url)
if payload.api_key:
set_password_webhook_api_key(db, payload.api_key)
db.commit() db.commit()
url = get_password_webhook_url(db) or "" url = get_password_webhook_url(db) or ""
return PasswordWebhookConfigOut(configured=bool(url), url=url) api_key_set = bool(get_password_webhook_api_key(db))
return PasswordWebhookConfigOut(configured=bool(url), url=url, api_key_set=api_key_set)
@router.post("/jira-test") @router.post("/jira-test")
+35
View File
@@ -28,6 +28,7 @@ from app.schemas.user import (
UserPreferencesUpdate, UserPreferencesUpdate,
UserOperatorOut, UserOperatorOut,
SendPasswordEmailOut, SendPasswordEmailOut,
SwitchRoleRequest,
) )
from app.services.audit_service import log_action from app.services.audit_service import log_action
@@ -36,6 +37,7 @@ from app.services.user_service import (
create_user_without_password, create_user_without_password,
get_user_or_raise, get_user_or_raise,
list_users, list_users,
switch_active_role,
update_user, update_user,
) )
from app.services.password_setup_service import send_password_setup_email from app.services.password_setup_service import send_password_setup_email
@@ -85,6 +87,39 @@ def get_me(
return current_user return current_user
# ---------------------------------------------------------------------------
# POST /users/me/switch-role — swap the caller's active role
# ---------------------------------------------------------------------------
@router.post("/me/switch-role", response_model=UserOut)
def switch_my_role(
payload: SwitchRoleRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> UserOut:
"""Switch the caller's active role to one of their granted extra_roles.
Roles never mix — this changes which single role is active, it never
grants permissions from more than one role at once. Takes effect
immediately since every permission check reads ``role`` fresh from the
DB on each request.
"""
with UnitOfWork(db) as uow:
user = switch_active_role(db, current_user, payload.role)
log_action(
db,
user_id=current_user.id,
action="switch_role",
entity_type="user",
entity_id=current_user.id,
details={"new_role": payload.role},
)
uow.commit()
db.refresh(user)
return user
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# GET /users — list all users # GET /users — list all users
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+1
View File
@@ -44,6 +44,7 @@ class UserOut(BaseModel):
email: str | None = None email: str | None = None
# role: str # role: str
role: str role: str
extra_roles: list[str] = []
# is_active: bool # is_active: bool
is_active: bool is_active: bool
# Assign must_change_password = True # Assign must_change_password = True
+11
View File
@@ -182,6 +182,8 @@ class UserUpdate(BaseModel):
full_name: str | None = None full_name: str | None = None
# Assign role = None # Assign role = None
role: str | None = None role: str | None = None
# Other roles this user can switch into via the top-bar switcher.
extra_roles: list[str] | None = None
# Assign is_active = None # Assign is_active = None
is_active: bool | None = None is_active: bool | None = None
# Assign password = None # Assign password = None
@@ -265,6 +267,7 @@ class UserOut(BaseModel):
email: str | None = None email: str | None = None
# role: str # role: str
role: str role: str
extra_roles: list[str] = Field(default_factory=list)
# is_active: bool # is_active: bool
is_active: bool is_active: bool
# Assign must_change_password = True # Assign must_change_password = True
@@ -335,3 +338,11 @@ class SetPasswordRequest(BaseModel):
@classmethod @classmethod
def new_password_strength(cls, v: str) -> str: def new_password_strength(cls, v: str) -> str:
return _validate_password_strength(v) return _validate_password_strength(v)
# ── Multi-role switching ─────────────────────────────────────────────
class SwitchRoleRequest(BaseModel):
"""Payload for switching the caller's currently-active role."""
role: str
+52 -9
View File
@@ -27,8 +27,26 @@ from app.models.user import User
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_WEBHOOK_URL_CONFIG_KEY = "password_setup.webhook_url" _WEBHOOK_URL_CONFIG_KEY = "password_setup.webhook_url"
_WEBHOOK_API_KEY_CONFIG_KEY = "password_setup.webhook_api_key"
_TOKEN_TTL = timedelta(hours=24) _TOKEN_TTL = timedelta(hours=24)
# Standard footer appended to every Power-Automate-delivered notification
# email, matching the platform's established template.
_EMAIL_SIGNATURE = (
"\n\nRegards,\n"
"AEGIS Security Platform\n"
"Purple Team Engineering\n"
"Owned and operated by Enterprise Corp.\n\n"
"Assume breach. Validate controls. Improve continuously.\n\n"
"This is an automated notification. Please do not reply."
)
def _build_email_body(full_name: str | None, message: str) -> str:
"""Wrap *message* in the standard greeting + signature template."""
greeting = full_name or "there"
return f"HI {greeting}\n\n{message}{_EMAIL_SIGNATURE}"
def _read_system_config(db: Session, key: str) -> str | None: def _read_system_config(db: Session, key: str) -> str | None:
from app.models.system_config import SystemConfig # avoid circular at import time from app.models.system_config import SystemConfig # avoid circular at import time
@@ -37,6 +55,16 @@ def _read_system_config(db: Session, key: str) -> str | None:
return row.value if row else None return row.value if row else None
def _write_system_config(db: Session, key: str, value: str) -> None:
from app.models.system_config import SystemConfig
row = db.query(SystemConfig).filter(SystemConfig.key == key).first()
if row:
row.value = value
else:
db.add(SystemConfig(key=key, value=value))
def get_password_webhook_url(db: Session) -> str | None: def get_password_webhook_url(db: Session) -> str | None:
"""Return the configured Power-Automate-style webhook URL, or None.""" """Return the configured Power-Automate-style webhook URL, or None."""
return _read_system_config(db, _WEBHOOK_URL_CONFIG_KEY) or None return _read_system_config(db, _WEBHOOK_URL_CONFIG_KEY) or None
@@ -44,13 +72,17 @@ def get_password_webhook_url(db: Session) -> str | None:
def set_password_webhook_url(db: Session, url: str) -> None: def set_password_webhook_url(db: Session, url: str) -> None:
"""Persist the webhook URL. Does not commit; caller commits.""" """Persist the webhook URL. Does not commit; caller commits."""
from app.models.system_config import SystemConfig _write_system_config(db, _WEBHOOK_URL_CONFIG_KEY, url)
row = db.query(SystemConfig).filter(SystemConfig.key == _WEBHOOK_URL_CONFIG_KEY).first()
if row: def get_password_webhook_api_key(db: Session) -> str | None:
row.value = url """Return the configured webhook API key, or None."""
else: return _read_system_config(db, _WEBHOOK_API_KEY_CONFIG_KEY) or None
db.add(SystemConfig(key=_WEBHOOK_URL_CONFIG_KEY, value=url))
def set_password_webhook_api_key(db: Session, api_key: str) -> None:
"""Persist the webhook API key. Does not commit; caller commits."""
_write_system_config(db, _WEBHOOK_API_KEY_CONFIG_KEY, api_key)
def create_setup_token(db: Session, user: User) -> PasswordSetupToken: def create_setup_token(db: Session, user: User) -> PasswordSetupToken:
@@ -88,15 +120,26 @@ def send_password_setup_email(db: Session, user: User) -> None:
token = create_setup_token(db, user) token = create_setup_token(db, user)
set_password_url = f"{settings.PLATFORM_URL}/set-password?token={token.token}" set_password_url = f"{settings.PLATFORM_URL}/set-password?token={token.token}"
is_reset = not user.must_change_password
subject = "Reset Your Password" if is_reset else "Set Your Password"
message = (
f'Click the link below to {"reset" if is_reset else "set"} your Aegis password:\n\n'
f"{set_password_url}\n\n"
"This link expires in 24 hours and can only be used once."
)
api_key = get_password_webhook_api_key(db)
headers = {"x-api-key": api_key} if api_key else {}
try: try:
requests.post( requests.post(
webhook_url, webhook_url,
json={ json={
"email": user.email, "to": user.email,
"full_name": user.full_name, "subject": subject,
"set_password_url": set_password_url, "body": _build_email_body(user.full_name, message),
}, },
headers=headers,
timeout=10, timeout=10,
) )
except requests.RequestException: except requests.RequestException:
@@ -131,11 +131,17 @@ def create_suggestion_from_submission(db: Session, test: Test, team: str) -> Pro
return suggestion return suggestion
def list_pending_suggestions(db: Session, *, team: str | None = None) -> list[ProcedureSuggestion]: def list_pending_suggestions(
"""List pending suggestions, optionally filtered to one team.""" db: Session, *, team: str | None = None, source_test_id: uuid.UUID | None = None,
) -> list[ProcedureSuggestion]:
"""List pending suggestions, optionally filtered to one team and/or the
specific test that originated them (used to block a lead's test-detail
view on a suggestion awaiting their review)."""
query = db.query(ProcedureSuggestion).filter(ProcedureSuggestion.status == "pending") query = db.query(ProcedureSuggestion).filter(ProcedureSuggestion.status == "pending")
if team is not None: if team is not None:
query = query.filter(ProcedureSuggestion.team == team) query = query.filter(ProcedureSuggestion.team == team)
if source_test_id is not None:
query = query.filter(ProcedureSuggestion.source_test_id == source_test_id)
return query.order_by(ProcedureSuggestion.created_at.asc()).all() return query.order_by(ProcedureSuggestion.created_at.asc()).all()
+29
View File
@@ -159,6 +159,13 @@ def update_user(db: Session, user_id: uuid.UUID, **fields: object) -> User:
f"Invalid role '{update_data['role']}'. Must be one of: {', '.join(sorted(VALID_ROLES))}" f"Invalid role '{update_data['role']}'. Must be one of: {', '.join(sorted(VALID_ROLES))}"
) )
if update_data.get("extra_roles") is not None:
invalid = [r for r in update_data["extra_roles"] if r not in VALID_ROLES]
if invalid:
raise BusinessRuleViolation(
f"Invalid role(s) {', '.join(invalid)}. Must be one of: {', '.join(sorted(VALID_ROLES))}"
)
# Check: "password" in update_data # Check: "password" in update_data
if "password" in update_data: if "password" in update_data:
# Assign update_data["hashed_password"] = hash_password(str(update_data.pop("password"))) # Assign update_data["hashed_password"] = hash_password(str(update_data.pop("password")))
@@ -171,3 +178,25 @@ def update_user(db: Session, user_id: uuid.UUID, **fields: object) -> User:
# Return user # Return user
return user return user
def switch_active_role(db: Session, user: User, new_role: str) -> User:
"""Swap *user*'s currently-active role with one from their extra_roles.
Roles never mix — the user acts under exactly one role at a time, this
just changes which one. Raises BusinessRuleViolation if new_role isn't
one this user has been granted. Does not commit; caller commits.
"""
available = {user.role, *(user.extra_roles or [])}
if new_role not in available:
raise BusinessRuleViolation(f"Role '{new_role}' is not available to this user")
if new_role == user.role:
return user
old_role = user.role
remaining = [r for r in (user.extra_roles or []) if r != new_role]
remaining.append(old_role)
user.role = new_role
user.extra_roles = remaining
return user
+96
View File
@@ -0,0 +1,96 @@
"""A user can be granted more than one role but only ever acts under a
single active one at a time — switching swaps which role is active
instead of ever mixing permissions from more than one.
"""
def test_admin_can_grant_extra_roles(api, auth_headers):
created = api(
"post", "/api/v1/users", auth_headers,
json={"full_name": "Multi Role User", "email": "multirole@test.com", "role": "red_tech"},
)
assert created.status_code == 201, created.text
user_id = created.json()["id"]
resp = api(
"patch", f"/api/v1/users/{user_id}", auth_headers,
json={"extra_roles": ["blue_tech", "red_lead"]},
)
assert resp.status_code == 200, resp.text
assert resp.json()["role"] == "red_tech"
assert set(resp.json()["extra_roles"]) == {"blue_tech", "red_lead"}
def test_admin_cannot_grant_invalid_extra_role(api, auth_headers):
created = api(
"post", "/api/v1/users", auth_headers,
json={"full_name": "Bad Extra Role", "email": "badextrarole@test.com", "role": "viewer"},
)
user_id = created.json()["id"]
resp = api(
"patch", f"/api/v1/users/{user_id}", auth_headers,
json={"extra_roles": ["superuser"]},
)
assert resp.status_code == 400
def test_user_can_switch_to_a_granted_extra_role(api, db, auth_headers):
from app.auth import hash_password
from app.models.user import User
user = User(
username="switcher@test.com",
email="switcher@test.com",
full_name="Switcher",
hashed_password=hash_password("SwitcherPass123!@#"),
role="red_tech",
extra_roles=["blue_tech"],
must_change_password=False,
)
db.add(user)
db.commit()
login = api(
"post", "/api/v1/auth/login", {},
data={"username": "switcher@test.com", "password": "SwitcherPass123!@#"},
)
assert login.status_code == 200, login.text
token = login.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
resp = api("post", "/api/v1/users/me/switch-role", headers, json={"role": "blue_tech"})
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["role"] == "blue_tech"
assert body["extra_roles"] == ["red_tech"]
me = api("get", "/api/v1/auth/me", headers)
assert me.json()["role"] == "blue_tech"
def test_user_cannot_switch_to_an_ungranted_role(api, db, auth_headers):
from app.auth import hash_password
from app.models.user import User
user = User(
username="switcher2@test.com",
email="switcher2@test.com",
full_name="Switcher Two",
hashed_password=hash_password("SwitcherPass123!@#"),
role="red_tech",
extra_roles=["blue_tech"],
must_change_password=False,
)
db.add(user)
db.commit()
login = api(
"post", "/api/v1/auth/login", {},
data={"username": "switcher2@test.com", "password": "SwitcherPass123!@#"},
)
token = login.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
resp = api("post", "/api/v1/users/me/switch-role", headers, json={"role": "admin"})
assert resp.status_code == 400
+29 -2
View File
@@ -36,6 +36,29 @@ def test_admin_can_configure_password_webhook(api, auth_headers):
assert get_resp.json()["url"] == "https://example.com/power-automate-hook" assert get_resp.json()["url"] == "https://example.com/power-automate-hook"
def test_admin_can_configure_webhook_api_key(api, auth_headers):
resp = api(
"patch", "/api/v1/system/password-webhook-config", auth_headers,
json={"url": "https://example.com/power-automate-hook", "api_key": "super-secret-key"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["api_key_set"] is True
# The key itself is never echoed back.
assert "api_key" not in resp.json()
assert "super-secret-key" not in resp.text
def test_send_password_email_sends_api_key_header(api, db, auth_headers, new_user_id):
api(
"patch", "/api/v1/system/password-webhook-config", auth_headers,
json={"url": "https://example.com/power-automate-hook", "api_key": "super-secret-key"},
)
with patch("app.services.password_setup_service.requests.post") as mock_post:
api("post", f"/api/v1/users/{new_user_id}/send-password-email", auth_headers)
call_kwargs = mock_post.call_args
assert call_kwargs.kwargs["headers"] == {"x-api-key": "super-secret-key"}
def test_send_password_email_posts_to_webhook_and_issues_token(api, db, auth_headers, new_user_id): def test_send_password_email_posts_to_webhook_and_issues_token(api, db, auth_headers, new_user_id):
api( api(
"patch", "/api/v1/system/password-webhook-config", auth_headers, "patch", "/api/v1/system/password-webhook-config", auth_headers,
@@ -48,8 +71,12 @@ def test_send_password_email_posts_to_webhook_and_issues_token(api, db, auth_hea
mock_post.assert_called_once() mock_post.assert_called_once()
call_kwargs = mock_post.call_args call_kwargs = mock_post.call_args
assert call_kwargs.args[0] == "https://example.com/power-automate-hook" assert call_kwargs.args[0] == "https://example.com/power-automate-hook"
assert call_kwargs.kwargs["json"]["email"] == "setpw@test.com" payload = call_kwargs.kwargs["json"]
assert "token=" in call_kwargs.kwargs["json"]["set_password_url"] assert payload["to"] == "setpw@test.com"
assert payload["subject"] == "Set Your Password"
assert "HI Set Password User" in payload["body"]
assert "token=" in payload["body"]
assert "Purple Team Engineering" in payload["body"]
from app.models.password_setup_token import PasswordSetupToken from app.models.password_setup_token import PasswordSetupToken
token_row = db.query(PasswordSetupToken).filter(PasswordSetupToken.user_id == uuid.UUID(new_user_id)).first() token_row = db.query(PasswordSetupToken).filter(PasswordSetupToken.user_id == uuid.UUID(new_user_id)).first()
@@ -306,3 +306,67 @@ def test_detect_procedure_defaults_to_template_expected_detection(
) )
assert resp.status_code == 201, resp.text assert resp.status_code == 201, resp.text
assert resp.json()["detect_procedure"] == "Check process creation logs." assert resp.json()["detect_procedure"] == "Check process creation logs."
class TestSuggestionsForTest:
"""GET /procedure-suggestions/for-test/{test_id} — powers the blocking
review popup a lead sees when opening a test with a pending suggestion."""
def test_returns_pending_suggestion_for_the_right_test(
self, client, db, api, test_from_template, red_tech_headers, red_lead_headers,
):
test_id = test_from_template
api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers)
api(
"patch", f"/api/v1/tests/{test_id}/red", red_tech_headers,
json={"procedure_text": "Ran discovery.\nwhoami /priv\nDone."},
)
_add_evidence(db, test_id, TeamSide.red)
api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
resp = api("get", f"/api/v1/procedure-suggestions/for-test/{test_id}", red_lead_headers)
assert resp.status_code == 200, resp.text
suggestions = resp.json()
assert len(suggestions) == 1
assert suggestions[0]["suggested_text"] == "whoami /priv"
def test_returns_empty_for_a_test_with_no_suggestion(
self, client, db, api, test_from_template, red_lead_headers,
):
resp = api("get", f"/api/v1/procedure-suggestions/for-test/{test_from_template}", red_lead_headers)
assert resp.status_code == 200, resp.text
assert resp.json() == []
def test_blue_lead_does_not_see_a_red_suggestion_for_the_test(
self, client, db, api, test_from_template, red_tech_headers, blue_lead_headers,
):
test_id = test_from_template
api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers)
api(
"patch", f"/api/v1/tests/{test_id}/red", red_tech_headers,
json={"procedure_text": "Ran discovery.\nwhoami /priv\nDone."},
)
_add_evidence(db, test_id, TeamSide.red)
api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
resp = api("get", f"/api/v1/procedure-suggestions/for-test/{test_id}", blue_lead_headers)
assert resp.status_code == 200, resp.text
assert resp.json() == []
def test_suggestion_disappears_from_for_test_once_approved(
self, client, db, api, test_from_template, red_tech_headers, red_lead_headers,
):
test_id = test_from_template
api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers)
api(
"patch", f"/api/v1/tests/{test_id}/red", red_tech_headers,
json={"procedure_text": "Ran discovery.\nwhoami /priv\nDone."},
)
_add_evidence(db, test_id, TeamSide.red)
api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
suggestion = api("get", f"/api/v1/procedure-suggestions/for-test/{test_id}", red_lead_headers).json()[0]
api("post", f"/api/v1/procedure-suggestions/{suggestion['id']}/approve", red_lead_headers)
resp = api("get", f"/api/v1/procedure-suggestions/for-test/{test_id}", red_lead_headers)
assert resp.json() == []
@@ -7,6 +7,13 @@ export async function getProcedureSuggestions(): Promise<ProcedureSuggestion[]>
return data; return data;
} }
/** List pending suggestions tied to a specific test — powers the blocking
* review popup a lead sees when opening a test that has one awaiting them. */
export async function getProcedureSuggestionsForTest(testId: string): Promise<ProcedureSuggestion[]> {
const { data } = await client.get<ProcedureSuggestion[]>(`/procedure-suggestions/for-test/${testId}`);
return data;
}
/** Approve a suggestion — writes it into the template's suggested-procedure field. */ /** Approve a suggestion — writes it into the template's suggested-procedure field. */
export async function approveProcedureSuggestion(id: string): Promise<ProcedureSuggestion> { export async function approveProcedureSuggestion(id: string): Promise<ProcedureSuggestion> {
const { data } = await client.post<ProcedureSuggestion>(`/procedure-suggestions/${id}/approve`); const { data } = await client.post<ProcedureSuggestion>(`/procedure-suggestions/${id}/approve`);
+10 -2
View File
@@ -202,6 +202,7 @@ export async function testJiraConnection(): Promise<{
export interface PasswordWebhookConfigOut { export interface PasswordWebhookConfigOut {
configured: boolean; configured: boolean;
url: string; url: string;
api_key_set: boolean;
} }
export async function getPasswordWebhookConfig(): Promise<PasswordWebhookConfigOut> { export async function getPasswordWebhookConfig(): Promise<PasswordWebhookConfigOut> {
@@ -209,8 +210,15 @@ export async function getPasswordWebhookConfig(): Promise<PasswordWebhookConfigO
return data; return data;
} }
export async function updatePasswordWebhookConfig(url: string): Promise<PasswordWebhookConfigOut> { /** apiKey is optional — omit or pass "" to leave the currently-stored key unchanged. */
const { data } = await client.patch<PasswordWebhookConfigOut>("/system/password-webhook-config", { url }); export async function updatePasswordWebhookConfig(
url: string,
apiKey?: string,
): Promise<PasswordWebhookConfigOut> {
const { data } = await client.patch<PasswordWebhookConfigOut>("/system/password-webhook-config", {
url,
api_key: apiKey || undefined,
});
return data; return data;
} }
+8
View File
@@ -6,6 +6,7 @@ export interface UserOut {
full_name: string | null; full_name: string | null;
email: string | null; email: string | null;
role: string; role: string;
extra_roles: string[];
is_active: boolean; is_active: boolean;
must_change_password: boolean; must_change_password: boolean;
created_at: string | null; created_at: string | null;
@@ -22,6 +23,7 @@ export interface UserUpdatePayload {
email?: string; email?: string;
full_name?: string; full_name?: string;
role?: string; role?: string;
extra_roles?: string[];
is_active?: boolean; is_active?: boolean;
// Not sent by the current UI — the set-password-email flow replaces // Not sent by the current UI — the set-password-email flow replaces
// admin-set passwords — but still supported server-side. // admin-set passwords — but still supported server-side.
@@ -64,3 +66,9 @@ export async function sendPasswordEmail(userId: string): Promise<{ detail: strin
const { data } = await client.post<{ detail: string }>(`/users/${userId}/send-password-email`); const { data } = await client.post<{ detail: string }>(`/users/${userId}/send-password-email`);
return data; return data;
} }
/** Switch the current user's active role to one of their granted extra_roles. */
export async function switchRole(role: string): Promise<UserOut> {
const { data } = await client.post<UserOut>("/users/me/switch-role", { role });
return data;
}
+2
View File
@@ -3,6 +3,7 @@ import { LogOut, AlertTriangle, RefreshCw } from "lucide-react";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Sidebar from "./Sidebar"; import Sidebar from "./Sidebar";
import NotificationBell from "./NotificationBell"; import NotificationBell from "./NotificationBell";
import RoleSwitcher from "./RoleSwitcher";
import React from "react"; import React from "react";
/* ── Error Boundary ────────────────────────────────────────────────── /* ── Error Boundary ──────────────────────────────────────────────────
@@ -71,6 +72,7 @@ export default function Layout() {
{/* Header */} {/* Header */}
<header className="flex h-16 items-center justify-end gap-4 border-b border-gray-800 bg-gray-900 px-6"> <header className="flex h-16 items-center justify-end gap-4 border-b border-gray-800 bg-gray-900 px-6">
<NotificationBell /> <NotificationBell />
<RoleSwitcher />
<span className="text-sm text-gray-300">{user?.full_name || user?.username}</span> <span className="text-sm text-gray-300">{user?.full_name || user?.username}</span>
<button <button
onClick={logout} onClick={logout}
+71
View File
@@ -0,0 +1,71 @@
import { useState } from "react";
import { ChevronDown, Repeat, Loader2 } from "lucide-react";
import { useAuth } from "../context/AuthContext";
const ROLE_LABELS: Record<string, string> = {
admin: "Admin",
red_tech: "Red Tech",
blue_tech: "Blue Tech",
red_lead: "Red Lead",
blue_lead: "Blue Lead",
manager: "Manager",
viewer: "Viewer",
};
/** Dropdown in the top bar letting a multi-role user switch which single
* role is currently active — only rendered when the user has extra_roles;
* roles never mix, this just changes which one is in effect. */
export default function RoleSwitcher() {
const { user, switchRole } = useAuth();
const [open, setOpen] = useState(false);
const [switching, setSwitching] = useState(false);
const extraRoles = user?.extra_roles ?? [];
if (extraRoles.length === 0) return null;
const allRoles = [user!.role, ...extraRoles];
const handleSwitch = async (role: string) => {
if (role === user!.role || switching) return;
setSwitching(true);
try {
await switchRole(role);
} finally {
setSwitching(false);
setOpen(false);
}
};
return (
<div className="relative">
<button
onClick={() => setOpen((v) => !v)}
disabled={switching}
className="flex items-center gap-1.5 rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-gray-300 transition-colors hover:bg-gray-800 disabled:opacity-50"
title="Switch active role"
>
{switching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Repeat className="h-3.5 w-3.5" />}
{ROLE_LABELS[user!.role] || user!.role}
<ChevronDown className="h-3.5 w-3.5" />
</button>
{open && (
<div className="absolute right-0 top-full z-30 mt-1 w-44 rounded-lg border border-gray-700 bg-gray-900 p-1.5 shadow-xl">
{allRoles.map((role) => (
<button
key={role}
onClick={() => handleSwitch(role)}
className={`block w-full rounded px-2 py-1.5 text-left text-sm transition-colors ${
role === user!.role
? "bg-cyan-500/10 text-cyan-400"
: "text-gray-300 hover:bg-gray-800"
}`}
>
{ROLE_LABELS[role] || role}
</button>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,65 @@
import { Lightbulb, CheckCircle, XCircle, Loader2 } from "lucide-react";
import type { ProcedureSuggestion } from "../../types/models";
interface Props {
suggestion: ProcedureSuggestion;
onApprove: () => void;
onReject: () => void;
isBusy: boolean;
}
/** Blocking popup shown to a lead opening a test that has a pending
* procedure-improvement suggestion awaiting their review — no backdrop
* click-to-close, no X button; the rest of the page stays inert until
* they approve or reject it. */
export default function PendingSuggestionModal({ suggestion, onApprove, onReject, isBusy }: Props) {
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div className="w-full max-w-lg rounded-xl border border-yellow-500/30 bg-gray-900 p-6 shadow-2xl">
<div className="mb-4 flex items-center gap-2">
<Lightbulb className="h-5 w-5 text-yellow-400" />
<h3 className="text-lg font-semibold text-white">Procedure Suggestion Needs Your Review</h3>
</div>
<p className="mb-4 text-sm text-gray-400">
This test proposed an update to{" "}
<span className="font-medium text-gray-300">{suggestion.template_name || "its template"}</span>.
Approve or reject it before continuing.
</p>
{suggestion.template_current_text && (
<div className="mb-3">
<p className="text-xs font-medium uppercase text-gray-500">Current</p>
<pre className="mt-1 whitespace-pre-wrap break-words rounded bg-gray-950 p-2 font-mono text-xs text-gray-400">
{suggestion.template_current_text}
</pre>
</div>
)}
<div className="mb-6">
<p className="text-xs font-medium uppercase text-gray-500">Suggested</p>
<pre className="mt-1 whitespace-pre-wrap break-words rounded bg-gray-950 p-2 font-mono text-xs text-green-400">
{suggestion.suggested_text}
</pre>
</div>
<div className="flex justify-end gap-2">
<button
onClick={onReject}
disabled={isBusy}
className="flex items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-900/20 px-4 py-2 text-sm font-medium text-red-400 hover:bg-red-900/40 disabled:opacity-50"
>
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : <XCircle className="h-4 w-4" />}
Reject
</button>
<button
onClick={onApprove}
disabled={isBusy}
className="flex items-center gap-1.5 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-500 disabled:opacity-50"
>
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : <CheckCircle className="h-4 w-4" />}
Approve
</button>
</div>
</div>
</div>
);
}
+11
View File
@@ -12,6 +12,7 @@ import {
getMe, getMe,
refreshToken as apiRefreshToken, refreshToken as apiRefreshToken,
} from "../api/auth"; } from "../api/auth";
import { switchRole as apiSwitchRole } from "../api/users";
import type { User } from "../types/models"; import type { User } from "../types/models";
import ChangePasswordModal from "../components/ChangePasswordModal"; import ChangePasswordModal from "../components/ChangePasswordModal";
@@ -23,6 +24,10 @@ interface AuthState {
isLoading: boolean; isLoading: boolean;
login: (username: string, password: string) => Promise<void>; login: (username: string, password: string) => Promise<void>;
logout: () => void; logout: () => void;
/** Switch the active role to one of the user's granted extra_roles.
* Reloads the page afterward so every role-gated component (nav, route
* guards, etc.) re-evaluates cleanly against the new role. */
switchRole: (role: string) => Promise<void>;
} }
const AuthContext = createContext<AuthState | undefined>(undefined); const AuthContext = createContext<AuthState | undefined>(undefined);
@@ -72,6 +77,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(null); setUser(null);
}, []); }, []);
const switchRole = useCallback(async (role: string) => {
await apiSwitchRole(role);
window.location.reload();
}, []);
const mustChangePassword = user?.must_change_password === true; const mustChangePassword = user?.must_change_password === true;
return ( return (
@@ -82,6 +92,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
isLoading, isLoading,
login, login,
logout, logout,
switchRole,
}} }}
> >
{children} {children}
+43 -22
View File
@@ -1688,6 +1688,7 @@ function SystemInfoSection() {
function PasswordWebhookSection() { function PasswordWebhookSection() {
const qc = useQueryClient(); const qc = useQueryClient();
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const [apiKey, setApiKey] = useState("");
const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null); const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
@@ -1700,12 +1701,13 @@ function PasswordWebhookSection() {
}, [data]); }, [data]);
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: () => updatePasswordWebhookConfig(url), mutationFn: () => updatePasswordWebhookConfig(url, apiKey),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["password-webhook-config"] }); qc.invalidateQueries({ queryKey: ["password-webhook-config"] });
setToast({ msg: "Webhook URL saved", type: "success" }); setApiKey("");
setToast({ msg: "Webhook configuration saved", type: "success" });
}, },
onError: () => setToast({ msg: "Failed to save webhook URL", type: "error" }), onError: () => setToast({ msg: "Failed to save webhook configuration", type: "error" }),
}); });
if (isLoading) { if (isLoading) {
@@ -1722,26 +1724,45 @@ function PasswordWebhookSection() {
<Mail className="h-5 w-5 text-cyan-400" /> Password Setup Email Webhook <Mail className="h-5 w-5 text-cyan-400" /> Password Setup Email Webhook
</h2> </h2>
<p className="mb-4 text-sm text-gray-500"> <p className="mb-4 text-sm text-gray-500">
When an admin sends a set-password or password-reset link from User Management, a When an admin sends a set-password or password-reset link from User Management, a{" "}
payload with the link is POSTed to this URL point it at your Power Automate flow (or <code className="text-gray-400">{"{ to, subject, body }"}</code> payload is POSTed to
similar) that actually delivers the email. this URL point it at your Power Automate flow (or similar) that actually delivers the
email. The API key, if set, is sent as an <code className="text-gray-400">x-api-key</code>{" "}
header.
</p> </p>
<div className="flex gap-3"> <div className="space-y-3">
<input <div>
type="url" <label className="mb-1 block text-xs font-medium text-gray-500">Webhook URL</label>
value={url} <input
onChange={(e) => setUrl(e.target.value)} type="url"
placeholder="https://prod-00.westeurope.logic.azure.com/..." value={url}
className="flex-1 rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 placeholder-gray-500 focus:border-cyan-500 focus:outline-none" onChange={(e) => setUrl(e.target.value)}
/> placeholder="https://prod-00.westeurope.logic.azure.com/..."
<button className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
onClick={() => saveMutation.mutate()} />
disabled={saveMutation.isPending} </div>
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-cyan-500 disabled:opacity-50" <div>
> <label className="mb-1 block text-xs font-medium text-gray-500">
{saveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />} API Key {data?.api_key_set && <span className="text-gray-600">(already set leave blank to keep it)</span>}
Save </label>
</button> <input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={data?.api_key_set ? "••••••••" : "Optional"}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
/>
</div>
<div className="flex justify-end">
<button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-cyan-500 disabled:opacity-50"
>
{saveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Save
</button>
</div>
</div> </div>
{!data?.configured && ( {!data?.configured && (
<p className="mt-2 text-xs text-amber-400/80"> <p className="mt-2 text-xs text-amber-400/80">
+39
View File
@@ -43,6 +43,12 @@ import ConfirmDialog from "../components/ConfirmDialog";
import JiraLinkPanel from "../components/JiraLinkPanel"; import JiraLinkPanel from "../components/JiraLinkPanel";
import TestPhaseTimeline from "../components/TestPhaseTimeline"; import TestPhaseTimeline from "../components/TestPhaseTimeline";
import { createTemplate } from "../api/test-templates"; import { createTemplate } from "../api/test-templates";
import {
getProcedureSuggestionsForTest,
approveProcedureSuggestion,
rejectProcedureSuggestion,
} from "../api/procedure-suggestions";
import PendingSuggestionModal from "../components/test-detail/PendingSuggestionModal";
import { isoToDatetimeLocal, datetimeLocalToIso } from "../utils/datetime"; import { isoToDatetimeLocal, datetimeLocalToIso } from "../utils/datetime";
// ── Page Component ───────────────────────────────────────────────── // ── Page Component ─────────────────────────────────────────────────
@@ -146,6 +152,28 @@ export default function TestDetailPage() {
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
// A lead opening a test with a pending procedure suggestion (from either
// team, scoped server-side to their own) must resolve it before doing
// anything else on the page — see PendingSuggestionModal below.
const isReviewLead = user?.role === "red_lead" || user?.role === "blue_lead";
const { data: pendingSuggestions = [] } = useQuery({
queryKey: ["procedure-suggestions-for-test", testId],
queryFn: () => getProcedureSuggestionsForTest(testId!),
enabled: !!testId && isReviewLead,
});
const blockingSuggestion = pendingSuggestions[0];
const invalidateSuggestions = () =>
queryClient.invalidateQueries({ queryKey: ["procedure-suggestions-for-test", testId] });
const approveSuggestionMutation = useMutation({
mutationFn: approveProcedureSuggestion,
onSuccess: invalidateSuggestions,
});
const rejectSuggestionMutation = useMutation({
mutationFn: rejectProcedureSuggestion,
onSuccess: invalidateSuggestions,
});
// Hydrate drafts from test data — only once per test, on first load or // Hydrate drafts from test data — only once per test, on first load or
// when navigating to a different test. Refetches triggered by evidence // when navigating to a different test. Refetches triggered by evidence
// uploads, timer ticks, etc. must NOT re-run this, or they'd stomp // uploads, timer ticks, etc. must NOT re-run this, or they'd stomp
@@ -879,6 +907,17 @@ export default function TestDetailPage() {
/> />
)} )}
{/* Blocking popup — a pending procedure suggestion for this test must
be resolved before the lead can do anything else on the page. */}
{blockingSuggestion && (
<PendingSuggestionModal
suggestion={blockingSuggestion}
isBusy={approveSuggestionMutation.isPending || rejectSuggestionMutation.isPending}
onApprove={() => approveSuggestionMutation.mutate(blockingSuggestion.id)}
onReject={() => rejectSuggestionMutation.mutate(blockingSuggestion.id)}
/>
)}
{/* Resolve Dispute Modal (approver flips vote to reject, picks a queue) */} {/* Resolve Dispute Modal (approver flips vote to reject, picks a queue) */}
{resolveDisputeModalOpen && ( {resolveDisputeModalOpen && (
<ResolveDisputeModal <ResolveDisputeModal
+52 -7
View File
@@ -159,13 +159,25 @@ export default function UsersPage() {
{user.email || "—"} {user.email || "—"}
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<span <div className="flex flex-wrap gap-1">
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${ <span
roleBadgeColors[user.role] || roleBadgeColors.viewer className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
}`} roleBadgeColors[user.role] || roleBadgeColors.viewer
> }`}
{user.role.replace(/_/g, " ")} >
</span> {user.role.replace(/_/g, " ")}
</span>
{user.extra_roles?.map((role) => (
<span
key={role}
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium opacity-60 ${
roleBadgeColors[role] || roleBadgeColors.viewer
}`}
>
{role.replace(/_/g, " ")}
</span>
))}
</div>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
{user.is_active ? ( {user.is_active ? (
@@ -408,14 +420,25 @@ function EditUserModal({ user, onClose, onSubmit, isSubmitting, error }: EditUse
full_name: user.full_name || "", full_name: user.full_name || "",
email: user.email || "", email: user.email || "",
role: user.role, role: user.role,
extra_roles: user.extra_roles || [],
}); });
const toggleExtraRole = (role: string) => {
setFormData((f) => ({
...f,
extra_roles: f.extra_roles.includes(role)
? f.extra_roles.filter((r) => r !== role)
: [...f.extra_roles, role],
}));
};
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
onSubmit({ onSubmit({
full_name: formData.full_name || undefined, full_name: formData.full_name || undefined,
email: formData.email || undefined, email: formData.email || undefined,
role: formData.role, role: formData.role,
extra_roles: formData.extra_roles,
}); });
}; };
@@ -471,6 +494,28 @@ function EditUserModal({ user, onClose, onSubmit, isSubmitting, error }: EditUse
</select> </select>
</div> </div>
<div>
<label className="block text-sm font-medium text-gray-300">
Additional Roles <span className="text-gray-500">(switchable via the top-bar role switcher)</span>
</label>
<div className="mt-1.5 grid grid-cols-2 gap-1.5">
{ROLES.filter((r) => r.value !== formData.role).map((role) => (
<label
key={role.value}
className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-800 px-2.5 py-1.5 text-sm text-gray-300"
>
<input
type="checkbox"
checked={formData.extra_roles.includes(role.value)}
onChange={() => toggleExtraRole(role.value)}
className="h-3.5 w-3.5 rounded border-gray-600 bg-gray-900 text-cyan-500 focus:ring-cyan-500"
/>
{role.label}
</label>
))}
</div>
</div>
<div className="flex justify-end gap-3 pt-4"> <div className="flex justify-end gap-3 pt-4">
<button <button
type="button" type="button"
+1
View File
@@ -7,6 +7,7 @@ export interface User {
username: string; username: string;
full_name?: string | null; full_name?: string | null;
role: string; role: string;
extra_roles?: string[];
must_change_password?: boolean; must_change_password?: boolean;
} }