feat(mail): enforce owned profile self-service
This commit is contained in:
@@ -127,18 +127,133 @@ def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Requires one of: " + ", ".join(scopes))
|
||||
|
||||
|
||||
def _require_profile_write_scope(principal: ApiPrincipal, scope_type: str) -> None:
|
||||
if scope_type == "system":
|
||||
_require_scope(principal, "system:settings:write")
|
||||
else:
|
||||
_require_scope(principal, "mail:profile:write")
|
||||
def _is_own_user_scope(principal: ApiPrincipal, scope_type: str, scope_id: str | None) -> bool:
|
||||
return scope_type == "user" and scope_id == principal.user.id
|
||||
|
||||
|
||||
def _require_profile_credentials_scope(principal: ApiPrincipal, scope_type: str) -> None:
|
||||
def _require_profile_write_scope(
|
||||
principal: ApiPrincipal,
|
||||
scope_type: str,
|
||||
scope_id: str | None = None,
|
||||
) -> None:
|
||||
if scope_type == "system":
|
||||
_require_scope(principal, "system:settings:write")
|
||||
else:
|
||||
_require_scope(principal, "mail:secret:manage")
|
||||
return
|
||||
if has_scope(principal, "mail:profile:write"):
|
||||
return
|
||||
if _is_own_user_scope(principal, scope_type, scope_id) and has_scope(
|
||||
principal, "mail:profile:write_own"
|
||||
):
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Missing scope: mail:profile:write",
|
||||
)
|
||||
|
||||
|
||||
def _require_profile_credentials_scope(
|
||||
principal: ApiPrincipal,
|
||||
scope_type: str,
|
||||
scope_id: str | None = None,
|
||||
) -> None:
|
||||
if scope_type == "system":
|
||||
_require_scope(principal, "system:settings:write")
|
||||
return
|
||||
if has_scope(principal, "mail:secret:manage"):
|
||||
return
|
||||
if _is_own_user_scope(principal, scope_type, scope_id) and has_scope(
|
||||
principal, "mail:secret:manage_own"
|
||||
):
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Missing scope: mail:secret:manage",
|
||||
)
|
||||
|
||||
|
||||
def _profile_for_mutation(
|
||||
session: Session,
|
||||
*,
|
||||
principal: ApiPrincipal,
|
||||
profile_id: str,
|
||||
):
|
||||
"""Resolve a profile through the actor's mutation boundary.
|
||||
|
||||
Mutation routes deliberately do not apply effective transport policy here:
|
||||
an owner must still be able to repair or deactivate a profile after policy
|
||||
becomes more restrictive. Missing and non-owned identifiers are kept
|
||||
indistinguishable for self-service actors.
|
||||
"""
|
||||
|
||||
try:
|
||||
profile = get_mail_server_profile(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
profile_id=profile_id,
|
||||
for_update=True,
|
||||
mutation_owner_user_id=(
|
||||
principal.user.id
|
||||
if has_scope(principal, "mail:profile:write_own")
|
||||
else None
|
||||
),
|
||||
can_manage_tenant_profiles=_principal_can_manage_tenant_profiles(principal),
|
||||
can_manage_system_profiles=_principal_can_manage_system_profiles(principal),
|
||||
)
|
||||
_require_profile_write_scope(
|
||||
principal,
|
||||
profile.scope_type or "tenant",
|
||||
profile.scope_id,
|
||||
)
|
||||
except (MailProfileError, HTTPException) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Mail-server profile not found",
|
||||
) from exc
|
||||
return profile
|
||||
|
||||
|
||||
_TRANSPORT_ENDPOINT_FIELDS = ("host", "port", "security")
|
||||
|
||||
|
||||
def _transport_endpoint_changed(
|
||||
current: dict[str, Any] | None,
|
||||
updated: SmtpConfig | ImapConfig,
|
||||
) -> bool:
|
||||
current_payload = current or {}
|
||||
updated_payload = updated.model_dump(mode="json")
|
||||
return any(
|
||||
current_payload.get(field) != updated_payload.get(field)
|
||||
for field in _TRANSPORT_ENDPOINT_FIELDS
|
||||
)
|
||||
|
||||
|
||||
def _profile_update_requires_credentials_scope(
|
||||
profile,
|
||||
*,
|
||||
payload: MailServerProfileUpdateRequest,
|
||||
smtp: SmtpConfig | None,
|
||||
imap: ImapConfig | None,
|
||||
) -> bool:
|
||||
if payload.credentials_supplied() or payload.clear_imap:
|
||||
return True
|
||||
if (
|
||||
smtp is not None
|
||||
and getattr(profile, "smtp_password_encrypted", None)
|
||||
and _transport_endpoint_changed(profile.smtp_config, smtp)
|
||||
):
|
||||
return True
|
||||
return bool(
|
||||
imap is not None
|
||||
and getattr(profile, "imap_password_encrypted", None)
|
||||
and _transport_endpoint_changed(profile.imap_config, imap)
|
||||
)
|
||||
|
||||
|
||||
def _profile_has_stored_credentials(profile) -> bool:
|
||||
return bool(
|
||||
getattr(profile, "smtp_password_encrypted", None)
|
||||
or getattr(profile, "imap_password_encrypted", None)
|
||||
)
|
||||
|
||||
|
||||
def _require_mailbox_read_scope(principal: ApiPrincipal) -> None:
|
||||
@@ -154,6 +269,10 @@ def _principal_can_manage_tenant_profiles(principal: ApiPrincipal) -> bool:
|
||||
return has_scope(principal, "mail:profile:write")
|
||||
|
||||
|
||||
def _principal_can_manage_own_profiles(principal: ApiPrincipal) -> bool:
|
||||
return has_scope(principal, "mail:profile:write_own")
|
||||
|
||||
|
||||
def _principal_can_manage_system_profiles(principal: ApiPrincipal) -> bool:
|
||||
return has_scope(principal, "system:settings:write")
|
||||
|
||||
@@ -166,6 +285,7 @@ def _profile_actor_kwargs(
|
||||
return {
|
||||
"actor_user_id": principal.user.id,
|
||||
"actor_group_ids": principal.group_ids,
|
||||
"actor_can_manage_own_profiles": _principal_can_manage_own_profiles(principal),
|
||||
"actor_can_manage_tenant_profiles": _principal_can_manage_tenant_profiles(principal),
|
||||
"actor_can_manage_system_profiles": _principal_can_manage_system_profiles(principal),
|
||||
"actor_tenant_admin": _principal_is_tenant_admin(principal),
|
||||
@@ -187,6 +307,7 @@ def _get_profile_for_principal(
|
||||
profile_id=profile_id,
|
||||
user_id=principal.user.id,
|
||||
group_ids=principal.group_ids,
|
||||
can_manage_own_profiles=_principal_can_manage_own_profiles(principal),
|
||||
can_manage_tenant_profiles=_principal_can_manage_tenant_profiles(principal),
|
||||
can_manage_system_profiles=_principal_can_manage_system_profiles(principal),
|
||||
tenant_admin=_principal_is_tenant_admin(principal),
|
||||
@@ -444,6 +565,7 @@ def _profile_change_visible_to_principal(
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=principal.user.id,
|
||||
group_ids=principal.group_ids,
|
||||
can_manage_own_profiles=_principal_can_manage_own_profiles(principal),
|
||||
can_manage_tenant_profiles=_principal_can_manage_tenant_profiles(principal),
|
||||
can_manage_system_profiles=_principal_can_manage_system_profiles(principal),
|
||||
tenant_admin=_principal_is_tenant_admin(principal),
|
||||
@@ -812,7 +934,12 @@ def create_profile(
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_profile_write_scope(principal, payload.scope_type)
|
||||
authorization_scope_id = (
|
||||
payload.scope_id or principal.user.id
|
||||
if payload.scope_type == "user"
|
||||
else payload.scope_id
|
||||
)
|
||||
_require_profile_write_scope(principal, payload.scope_type, authorization_scope_id)
|
||||
_require_campaign_profile_mutation_access(
|
||||
session,
|
||||
principal=principal,
|
||||
@@ -820,7 +947,11 @@ def create_profile(
|
||||
scope_id=payload.scope_id,
|
||||
)
|
||||
if payload.credentials_supplied():
|
||||
_require_profile_credentials_scope(principal, payload.scope_type)
|
||||
_require_profile_credentials_scope(
|
||||
principal,
|
||||
payload.scope_type,
|
||||
authorization_scope_id,
|
||||
)
|
||||
try:
|
||||
profile = create_mail_server_profile(
|
||||
session,
|
||||
@@ -881,16 +1012,17 @@ def update_profile(
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
||||
_require_profile_write_scope(principal, profile.scope_type or "tenant")
|
||||
profile = _profile_for_mutation(
|
||||
session,
|
||||
principal=principal,
|
||||
profile_id=profile_id,
|
||||
)
|
||||
_require_campaign_profile_mutation_access(
|
||||
session,
|
||||
principal=principal,
|
||||
scope_type=profile.scope_type or "tenant",
|
||||
scope_id=profile.scope_id,
|
||||
)
|
||||
if payload.credentials_supplied() or payload.clear_imap:
|
||||
_require_profile_credentials_scope(principal, profile.scope_type or "tenant")
|
||||
smtp_config = payload.smtp_config()
|
||||
if payload.smtp is None and "smtp" in payload.credentials.model_fields_set:
|
||||
smtp_data = dict(profile.smtp_config or {})
|
||||
@@ -901,6 +1033,17 @@ def update_profile(
|
||||
imap_data = dict(profile.imap_config or {})
|
||||
imap_data.update(payload.credentials.imap.model_dump(mode="json", exclude_unset=True))
|
||||
imap_config = ImapConfig.model_validate(imap_data)
|
||||
if _profile_update_requires_credentials_scope(
|
||||
profile,
|
||||
payload=payload,
|
||||
smtp=smtp_config,
|
||||
imap=imap_config,
|
||||
):
|
||||
_require_profile_credentials_scope(
|
||||
principal,
|
||||
profile.scope_type or "tenant",
|
||||
profile.scope_id,
|
||||
)
|
||||
update_mail_server_profile(
|
||||
session,
|
||||
profile,
|
||||
@@ -943,15 +1086,23 @@ def deactivate_profile(
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
||||
_require_profile_write_scope(principal, profile.scope_type or "tenant")
|
||||
profile = _profile_for_mutation(
|
||||
session,
|
||||
principal=principal,
|
||||
profile_id=profile_id,
|
||||
)
|
||||
_require_campaign_profile_mutation_access(
|
||||
session,
|
||||
principal=principal,
|
||||
scope_type=profile.scope_type or "tenant",
|
||||
scope_id=profile.scope_id,
|
||||
)
|
||||
_require_profile_credentials_scope(principal, profile.scope_type or "tenant")
|
||||
if _profile_has_stored_credentials(profile):
|
||||
_require_profile_credentials_scope(
|
||||
principal,
|
||||
profile.scope_type or "tenant",
|
||||
profile.scope_id,
|
||||
)
|
||||
was_active = bool(profile.is_active)
|
||||
profile.is_active = False
|
||||
deleted_protocols = delete_mail_profile_credentials(
|
||||
|
||||
Reference in New Issue
Block a user