"""Static mail-group administration REST API (HR / Global Admin). The write half of the 邮件组管理 desk. Every route is gated by ``can_manage_email_groups`` inside :class:`~app.domains.email_group.admin_service.EmailGroupAdminService`, and the router carries the runtime leaver gate like its siblings. POST /email-groups/manage/groups — create a group POST /email-groups/manage/{id}/members — add member(s) POST /email-groups/manage/{id}/members/remove — remove member(s) POST /email-groups/manage/{id}/owners — grant owner POST /email-groups/manage/{id}/owners/remove — revoke owner PUT /email-groups/manage/{id}/display — name / description POST /email-groups/manage/{id}/aliases — add alias DELETE /email-groups/manage/{id}/aliases — remove alias Mounted on the same ``/email-groups/manage`` prefix as the read surface so the whole desk is one path family. It is a separate module because the read side is deliberately side-effect-free and keeping the two in one file makes that boundary easy to lose. Every mutation touches Google, so all of them are idempotency-keyed: a Feishu re-delivery or a double-click must not add a member twice or fire a second notification. The Google calls are individually idempotent as well (409 → ``already_member``, 404 → ``already_absent``), so the key is defence in depth, not the only guard. """ from __future__ import annotations from uuid import UUID from fastapi import APIRouter, Depends, Request from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.auth.models import AuthUser from app.core.database import get_db from app.core.dependencies import get_current_user from app.core.dependencies import ( require_active_employee_for_mutations as _leaver_runtime_gate, ) from app.core.idempotent_route import idempotent from app.core.response import success_response from app.domains.email_group.admin_service import EmailGroupAdminService from app.domains.email_group.manage_service import EmailGroupManageService from app.domains.email_group.schemas import ( AdminGroupCreateRequest, EmailGroupCreateRequest, GroupAliasRequest, GroupDisplayUpdateRequest, GroupMemberAddRequest, GroupMemberBatchRemoveRequest, GroupOwnerGrantRequest, GroupOwnerRevokeRequest, ) from app.domains.email_group.service import EmailGroupService from app.identity.person_models import IdentityPerson # Idempotency scope note (security, not just dedupe): every scope below # includes ``current_user.id``. ``@idempotent`` returns the cached # envelope BEFORE the handler body runs (see # ``core/idempotent_route.py``), so a scope of ``family:`` # alone would let anyone holding an HR admin's Idempotency-Key replay # their result and receive 200 — with the group address, the per-address # outcomes and the resolved person ids — instead of the 403 their own # roles earn. Keying on the caller makes a replay reachable only by the # principal who performed it. Matches every other privileged write in # the repo (``cloud_grant.approve`` → ``("grant_id", "user.id")``). router = APIRouter( prefix="/api/v1/email-groups/manage", tags=["email_groups_manage"], dependencies=[Depends(_leaver_runtime_gate)], ) def _roles(user: AuthUser) -> list[str]: return list(user.role_names or []) # ── Create ── @router.post("/groups", summary="Create a mail group (HR / IT Admin)") # A duplicate submit provisions a second real Google Group nobody asked # for. Keyed on the creator: the same person replaying the SAME key is a # retry; a different person with a colliding key is a different op. @idempotent("email_group.admin_create", key_params=("current_user.id",)) async def admin_create_group( request: Request, body: AdminGroupCreateRequest, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """HR / global-admin group creation. Reuses ``EmailGroupService.create_mail_group`` in ``admin_mode`` rather than reimplementing it — the Google create, the freshly- created-group propagation retry, the owner-eligibility gate and the delete-the-Google-group compensation on local failure are all subtle and must not exist twice. Admin mode changes only the rate limit and the "creator is forced to be an owner" rule; see that method's docstring. """ # Defence in depth only. The AUTHORITATIVE gate for this flow lives # inside ``create_mail_group`` under ``admin_mode`` — a check in a # router body is skipped entirely on an idempotent replay (the cached # envelope returns before the handler runs), and it is also invisible # to any future second caller of ``create_mail_group(admin_mode=True)``. EmailGroupManageService(db).require_manage(_roles(current_user)) service = EmailGroupService(db) result = await service.create_mail_group( body=EmailGroupCreateRequest( local_part=body.local_part, suffix=body.suffix, name=body.name, description=body.description, reason=body.reason, additional_owner_ids=body.owner_ids, ), requester=current_user, trace_id=request.state.trace_id, admin_mode=True, actor_roles=_roles(current_user), ) await db.commit() group = await service.find_group_by_email(result.group.email) manage = EmailGroupManageService(db) persons: dict[UUID, IdentityPerson] = {} if group is not None and group.owners: rows = (await db.execute( select(IdentityPerson).where( IdentityPerson.id.in_([o.person_id for o in group.owners]) ) )).scalars().all() persons = {p.id: p for p in rows} return success_response( data={ "request_id": str(result.request.id), "request_number": result.request.request_number, "group": ( manage.serialize_group(group, persons) if group else None ), }, request=request, ) # ── Members ── @router.post("/{group_id}/members", summary="Add member(s) (HR / IT Admin)") @idempotent( "email_group.member_add", key_params=("group_id", "current_user.id"), ) async def add_members( group_id: UUID, request: Request, body: GroupMemberAddRequest, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): service = EmailGroupAdminService(db) data = await service.add_members( group_id=group_id, emails=body.emails, role=body.role, actor=current_user, actor_roles=_roles(current_user), reason=body.reason, trace_id=request.state.trace_id, ) await db.commit() return success_response(data=data, request=request) @router.post( "/{group_id}/members/remove", summary="Remove member(s) (HR / IT Admin)", ) @idempotent( "email_group.member_remove", key_params=("group_id", "current_user.id"), ) async def remove_members( group_id: UUID, request: Request, body: GroupMemberBatchRemoveRequest, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): service = EmailGroupAdminService(db) data = await service.remove_members( group_id=group_id, emails=body.emails, actor=current_user, actor_roles=_roles(current_user), reason=body.reason, trace_id=request.state.trace_id, ) await db.commit() return success_response(data=data, request=request) # ── Owners ── @router.post("/{group_id}/owners", summary="Grant owner (HR / IT Admin)") @idempotent( "email_group.owner_grant", key_params=("group_id", "current_user.id"), ) async def grant_owner( group_id: UUID, request: Request, body: GroupOwnerGrantRequest, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Make an address an owner — insert or promote. Distinct from ``POST /{id}/transfer-owner``, which needs an existing owner to transfer FROM and therefore cannot rescue one of the ownerless groups. This is the path that can. """ service = EmailGroupAdminService(db) data = await service.grant_owner( group_id=group_id, email=body.email, actor=current_user, actor_roles=_roles(current_user), reason=body.reason, trace_id=request.state.trace_id, ) await db.commit() return success_response(data=data, request=request) @router.post( "/{group_id}/owners/remove", summary="Revoke owner (HR / IT Admin)", ) @idempotent( "email_group.owner_revoke", key_params=("group_id", "current_user.id"), ) async def revoke_owner( group_id: UUID, request: Request, body: GroupOwnerRevokeRequest, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Drop one owner. Refuses to leave the group ownerless.""" service = EmailGroupAdminService(db) data = await service.revoke_owner( group_id=group_id, email=body.email, demote_to_member=body.demote_to_member, actor=current_user, actor_roles=_roles(current_user), reason=body.reason, trace_id=request.state.trace_id, ) await db.commit() return success_response(data=data, request=request) # ── Display metadata ── @router.put( "/{group_id}/display", summary="Edit name / description (HR / IT Admin)", ) async def update_display( group_id: UUID, request: Request, body: GroupDisplayUpdateRequest, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """No idempotency key: this write is naturally idempotent. Setting name/description to the same values twice produces the same state and the service short-circuits an unchanged edit to ``outcome='unchanged'`` without calling Google or writing audit. """ service = EmailGroupAdminService(db) data = await service.update_display( group_id=group_id, name=body.name, description=body.description, actor=current_user, actor_roles=_roles(current_user), trace_id=request.state.trace_id, ) await db.commit() return success_response(data=data, request=request) # ── Aliases ── @router.post("/{group_id}/aliases", summary="Add an alias (HR / IT Admin)") @idempotent( "email_group.alias_add", key_params=("group_id", "current_user.id"), ) async def add_alias( group_id: UUID, request: Request, body: GroupAliasRequest, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): service = EmailGroupAdminService(db) data = await service.add_alias( group_id=group_id, alias_email=body.alias_email, actor=current_user, actor_roles=_roles(current_user), trace_id=request.state.trace_id, reason=body.reason, ) await db.commit() return success_response(data=data, request=request) @router.delete("/{group_id}/aliases", summary="Remove an alias (HR / IT Admin)") async def remove_alias( group_id: UUID, request: Request, body: GroupAliasRequest, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """No idempotency key: removal is idempotent on Google's 404, which the driver maps to ``already_absent`` rather than an error.""" service = EmailGroupAdminService(db) data = await service.remove_alias( group_id=group_id, alias_email=body.alias_email, actor=current_user, actor_roles=_roles(current_user), trace_id=request.state.trace_id, reason=body.reason, ) await db.commit() return success_response(data=data, request=request)