feat(users): passwordless user creation via emailed set-password link, display full name instead of username
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

Admins now create users with a name + email (no password) and role;
a Send Email action (per-user, also reusable for resets) issues a
one-time token and POSTs it to an admin-configurable webhook URL —
intended for a Power Automate flow that delivers the actual email.
The old admin-sets-the-password flow is kept server-side
(LegacyUserCreateWithPassword) but no longer wired into the UI.

Every user-facing surface (top bar, sidebar, user management, audit
log, assignee pickers, dispute notifications) now shows full_name
instead of username, falling back to username when unset.

Also fixes long attack_procedure/expected_detection/suggested_text
text overflowing its container in the template review panels and
Red/Blue team fields (missing break-words on whitespace-pre-wrap
blocks).
This commit is contained in:
kitos
2026-07-17 16:58:25 +02:00
parent 60ab2e558f
commit 4dea19cae9
36 changed files with 974 additions and 159 deletions
+41 -1
View File
@@ -57,7 +57,7 @@ from app.models.user import User
from app.schemas.auth import TokenResponse, UserOut
# Import PasswordChange from app.schemas.user
from app.schemas.user import PasswordChange
from app.schemas.user import PasswordChange, SetPasswordRequest, SetPasswordTokenValidateOut
# Import log_action from app.services.audit_service
from app.services.audit_service import log_action
@@ -72,6 +72,12 @@ from app.services.auth_service import (
change_password as auth_change_password,
)
from app.domain.errors import EntityNotFoundError
from app.services.password_setup_service import (
consume_token_and_set_password,
validate_token as validate_password_setup_token,
)
# Assign router = APIRouter(prefix="/auth", tags=["auth"])
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -381,3 +387,37 @@ def change_password(
# Return {"detail": "Password changed successfully"}
return {"detail": "Password changed successfully"}
# ---------------------------------------------------------------------------
# Set password via a one-time emailed link — public (no auth), rate-limited
# ---------------------------------------------------------------------------
@router.get("/set-password/validate", response_model=SetPasswordTokenValidateOut)
@limiter.limit("20/minute")
def validate_set_password_token(request: Request, token: str, db: Session = Depends(get_db)) -> SetPasswordTokenValidateOut:
"""Check a set-password token before rendering the form. Public endpoint."""
try:
user = validate_password_setup_token(db, token)
except (EntityNotFoundError, BusinessRuleViolation):
return SetPasswordTokenValidateOut(valid=False)
return SetPasswordTokenValidateOut(valid=True, full_name=user.full_name)
@router.post("/set-password")
@limiter.limit("10/minute")
def set_password(request: Request, body: SetPasswordRequest, db: Session = Depends(get_db)) -> dict:
"""Complete a password setup/reset using a one-time token. Public endpoint."""
with UnitOfWork(db) as uow:
user = consume_token_and_set_password(db, body.token, body.new_password)
log_action(
db,
user.id,
"SET_PASSWORD_VIA_TOKEN",
"auth",
str(user.id),
details={},
)
uow.commit()
return {"detail": "Password set successfully — you can now log in"}
+42
View File
@@ -344,6 +344,48 @@ def update_jira_config(
)
class PasswordWebhookConfigOut(BaseModel):
configured: bool
url: str
class PasswordWebhookConfigUpdate(BaseModel):
url: str
@router.get("/password-webhook-config", response_model=PasswordWebhookConfigOut)
def get_password_webhook_config(
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Return the configured webhook URL used to email set-password links.
**Requires** the ``admin`` role.
"""
from app.services.password_setup_service import get_password_webhook_url
url = get_password_webhook_url(db) or ""
return PasswordWebhookConfigOut(configured=bool(url), url=url)
@router.patch("/password-webhook-config", response_model=PasswordWebhookConfigOut)
def update_password_webhook_config(
payload: PasswordWebhookConfigUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Set the webhook URL 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
set_password_webhook_url(db, payload.url)
db.commit()
url = get_password_webhook_url(db) or ""
return PasswordWebhookConfigOut(configured=bool(url), url=url)
@router.post("/jira-test")
def test_jira_connection(
db: Session = Depends(get_db),
+2
View File
@@ -1814,6 +1814,7 @@ def request_discussion(
if rejector_id else None
)
rejector_name = rejector.username if rejector else rejector_label
rejector_full_name = getattr(rejector, "full_name", None) if rejector else None
rejector_email = getattr(rejector, "email", None) if rejector else None
# Notify the rejecting lead
@@ -1852,6 +1853,7 @@ def request_discussion(
"status": "notification_sent",
"message": f"Discussion request sent to {rejector_name}",
"rejector_username": rejector_name,
"rejector_full_name": rejector_full_name,
"rejector_email": rejector_email,
"rejector_role": rejector_label,
}
+49 -12
View File
@@ -21,16 +21,24 @@ from app.domain.unit_of_work import UnitOfWork
# Import User from app.models.user
from app.models.user import User
from app.dependencies.auth import get_current_user
from app.schemas.user import UserCreate, UserUpdate, UserOut, UserPreferencesUpdate, UserOperatorOut
from app.schemas.user import (
UserCreate,
UserUpdate,
UserOut,
UserPreferencesUpdate,
UserOperatorOut,
SendPasswordEmailOut,
)
from app.services.audit_service import log_action
# Import from app.services.user_service
from app.services.user_service import (
create_user,
create_user_without_password,
get_user_or_raise,
list_users,
update_user,
)
from app.services.password_setup_service import send_password_setup_email
# Assign router = APIRouter(prefix="/users", tags=["users"])
router = APIRouter(prefix="/users", tags=["users"])
@@ -110,19 +118,17 @@ def create_user_route(
# Entry: current_user
current_user: User = Depends(require_role("admin")),
) -> UserOut:
"""Create a new user. **Requires admin role.**."""
"""Create a new user. **Requires admin role.**.
No password is set — use ``POST /users/{id}/send-password-email``
afterward to let the user set their own via a one-time link.
"""
# Open context manager
with UnitOfWork(db) as uow:
# Assign user = create_user(
user = create_user(
user = create_user_without_password(
db,
# Keyword argument: username
username=payload.username,
# Keyword argument: email
full_name=payload.full_name,
email=payload.email,
# Keyword argument: password
password=payload.password,
# Keyword argument: role
role=payload.role,
)
# Call log_action()
@@ -137,7 +143,7 @@ def create_user_route(
# Keyword argument: entity_id
entity_id=user.id,
# Keyword argument: details
details={"username": user.username, "role": user.role},
details={"full_name": user.full_name, "email": user.email, "role": user.role},
)
# Call uow.commit()
uow.commit()
@@ -241,3 +247,34 @@ def update_user_route(
# Return user
return user
# ---------------------------------------------------------------------------
# POST /users/{id}/send-password-email — set-password / reset link
# ---------------------------------------------------------------------------
@router.post("/{user_id}/send-password-email", response_model=SendPasswordEmailOut)
def send_password_email_route(
user_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
) -> SendPasswordEmailOut:
"""Email a one-time set-password link to a user. **Requires admin role.**.
Works for both a freshly-created passwordless user and an existing
one whose password needs resetting — same link, same flow either way.
"""
user = get_user_or_raise(db, user_id)
with UnitOfWork(db) as uow:
send_password_setup_email(db, user)
log_action(
db,
user_id=current_user.id,
action="send_password_setup_email",
entity_type="user",
entity_id=user.id,
details={},
)
uow.commit()
return SendPasswordEmailOut(detail=f"Password setup email sent to {user.email}")