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)
# Assign 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)
is_active = 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]
@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)
def approve_suggestion(
suggestion_id: uuid.UUID,
+19 -5
View File
@@ -347,10 +347,13 @@ def update_jira_config(
class PasswordWebhookConfigOut(BaseModel):
configured: bool
url: str
api_key_set: bool
class PasswordWebhookConfigUpdate(BaseModel):
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)
@@ -360,12 +363,15 @@ def get_password_webhook_config(
):
"""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.
"""
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 ""
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)
@@ -374,16 +380,24 @@ def update_password_webhook_config(
db: Session = Depends(get_db),
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.
"""
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)
if payload.api_key:
set_password_webhook_api_key(db, payload.api_key)
db.commit()
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")
+35
View File
@@ -28,6 +28,7 @@ from app.schemas.user import (
UserPreferencesUpdate,
UserOperatorOut,
SendPasswordEmailOut,
SwitchRoleRequest,
)
from app.services.audit_service import log_action
@@ -36,6 +37,7 @@ from app.services.user_service import (
create_user_without_password,
get_user_or_raise,
list_users,
switch_active_role,
update_user,
)
from app.services.password_setup_service import send_password_setup_email
@@ -85,6 +87,39 @@ def get_me(
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
# ---------------------------------------------------------------------------
+1
View File
@@ -44,6 +44,7 @@ class UserOut(BaseModel):
email: str | None = None
# role: str
role: str
extra_roles: list[str] = []
# is_active: bool
is_active: bool
# Assign must_change_password = True
+11
View File
@@ -182,6 +182,8 @@ class UserUpdate(BaseModel):
full_name: str | None = None
# Assign role = 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
is_active: bool | None = None
# Assign password = None
@@ -265,6 +267,7 @@ class UserOut(BaseModel):
email: str | None = None
# role: str
role: str
extra_roles: list[str] = Field(default_factory=list)
# is_active: bool
is_active: bool
# Assign must_change_password = True
@@ -335,3 +338,11 @@ class SetPasswordRequest(BaseModel):
@classmethod
def new_password_strength(cls, v: str) -> str:
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__)
_WEBHOOK_URL_CONFIG_KEY = "password_setup.webhook_url"
_WEBHOOK_API_KEY_CONFIG_KEY = "password_setup.webhook_api_key"
_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:
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
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:
"""Return the configured Power-Automate-style webhook URL, 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:
"""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:
row.value = url
else:
db.add(SystemConfig(key=_WEBHOOK_URL_CONFIG_KEY, value=url))
def get_password_webhook_api_key(db: Session) -> str | None:
"""Return the configured webhook API key, or None."""
return _read_system_config(db, _WEBHOOK_API_KEY_CONFIG_KEY) or None
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:
@@ -88,15 +120,26 @@ def send_password_setup_email(db: Session, user: User) -> None:
token = create_setup_token(db, user)
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:
requests.post(
webhook_url,
json={
"email": user.email,
"full_name": user.full_name,
"set_password_url": set_password_url,
"to": user.email,
"subject": subject,
"body": _build_email_body(user.full_name, message),
},
headers=headers,
timeout=10,
)
except requests.RequestException:
@@ -131,11 +131,17 @@ def create_suggestion_from_submission(db: Session, test: Test, team: str) -> Pro
return suggestion
def list_pending_suggestions(db: Session, *, team: str | None = None) -> list[ProcedureSuggestion]:
"""List pending suggestions, optionally filtered to one team."""
def list_pending_suggestions(
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")
if team is not None:
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()
+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))}"
)
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
if "password" in update_data:
# 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
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"
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):
api(
"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()
call_kwargs = mock_post.call_args
assert call_kwargs.args[0] == "https://example.com/power-automate-hook"
assert call_kwargs.kwargs["json"]["email"] == "setpw@test.com"
assert "token=" in call_kwargs.kwargs["json"]["set_password_url"]
payload = call_kwargs.kwargs["json"]
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
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.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() == []