feat(org): platform invites, roles, org sharing (v2 phase A1-1)
- New platform_invites table (migration a1b2c3d4e5f1) + PlatformInviteRow - Owner-only endpoints in admin_users.py: issue/list/revoke platform invites - invites.py: two-kind resolution (org + platform), email-supplied accept path - auth.py /me gains needs_onboarding (manager, 0 orgs, not owner) - organizations.py: transfer-ownership, leave (last owner guard), list/revoke org invites - contracts + web UI: admin/users invite modal, accept-invite platform variant, onboarding gating - Tests: test_org_platform_invites.py (18), org-sharing.test.tsx; report under docs/plan/reports Note: (app)/settings "اشتراکگذاری سازمان" section (1.10) remains presentational work; endpoints are done and tested.
This commit is contained in:
@@ -20,21 +20,40 @@ and expensive to get wrong:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import hash_token
|
||||
from app.domain.base import utcnow
|
||||
from app.domain.identity.models import (
|
||||
Membership,
|
||||
MembershipStatus,
|
||||
PlatformInvite,
|
||||
PlatformRole,
|
||||
Role,
|
||||
User,
|
||||
)
|
||||
from app.infrastructure.repositories.identity import (
|
||||
MembershipRepository,
|
||||
OrganizationRepository,
|
||||
PlatformInviteRepository,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("agy.org.invites")
|
||||
|
||||
#: Raw token length. ``secrets.token_urlsafe(24)`` is ~144 bits — well past
|
||||
#: anything a brute force against a rate-limited endpoint can reach.
|
||||
TOKEN_BYTES = 24
|
||||
|
||||
#: Floor on the expiry, whatever the setting says. A link that dies in five
|
||||
#: minutes is worse than useless: the invitee opens it hours later.
|
||||
MIN_TTL_HOURS = 1
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OrganizationTransfer:
|
||||
@@ -84,3 +103,112 @@ async def demote_manager_accounts_organizations(
|
||||
return await transfer_owned_organizations(
|
||||
session, from_user_id=manager.id, to_user=owner
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform invites — the door into the product (D12)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class IssuedInvite:
|
||||
"""The invite plus its raw token — the only moment that token exists."""
|
||||
|
||||
invite: PlatformInvite
|
||||
token: str
|
||||
|
||||
@property
|
||||
def invite_url(self) -> str:
|
||||
"""The link the owner pastes into a chat message."""
|
||||
return f"{settings.app_url.rstrip('/')}/accept-invite?token={self.token}"
|
||||
|
||||
|
||||
class PlatformInviteService:
|
||||
"""Issue, list and revoke the owner's account invitations.
|
||||
|
||||
A *manager* account is the only kind that can create an organization, and
|
||||
accounts are never self-service, so the owner creates a link and that link
|
||||
turns a stranger into a manager.
|
||||
|
||||
What makes it a capability rather than a message: the token is stored only as
|
||||
a hash and shown exactly once, it is single-use, it expires after
|
||||
``PLATFORM_INVITE_TTL_HOURS``, and the owner can revoke it at any time.
|
||||
|
||||
One rule from the *organization* invite flow carries over unchanged — a link
|
||||
can never reset the password of an account that already exists. If the
|
||||
invited email already belongs to somebody, they sign in first; otherwise a
|
||||
leaked link would be an account takeover for anyone who ever received one.
|
||||
"""
|
||||
|
||||
def __init__(self, repo: PlatformInviteRepository) -> None:
|
||||
self.repo = repo
|
||||
|
||||
def _ttl(self) -> timedelta:
|
||||
hours = max(int(settings.platform_invite_ttl_hours or 1), MIN_TTL_HOURS)
|
||||
return timedelta(hours=hours)
|
||||
|
||||
async def issue(
|
||||
self,
|
||||
*,
|
||||
owner_user_id: str,
|
||||
email: str | None = None,
|
||||
platform_role: PlatformRole = PlatformRole.MANAGER,
|
||||
note: str = "",
|
||||
) -> IssuedInvite:
|
||||
"""Create an invite and return it with its raw token, exactly once.
|
||||
|
||||
``platform_role`` is accepted so the owner can invite a plain ``user`` too,
|
||||
but ``owner`` is refused for the same reason ``create_user`` refuses it:
|
||||
the owner seat moves only through a deliberate ownership transfer.
|
||||
"""
|
||||
from app.core.errors import ValidationError
|
||||
|
||||
if platform_role == PlatformRole.OWNER:
|
||||
raise ValidationError(
|
||||
"دعوت نقشی برای مالک سامانه قابل صدور نیست؛ انتقال مالکیت مسیر جداگانهای است."
|
||||
)
|
||||
|
||||
token = secrets.token_urlsafe(TOKEN_BYTES)
|
||||
invite = await self.repo.create(
|
||||
PlatformInvite(
|
||||
token_hash=hash_token(token),
|
||||
email=(email or "").strip().lower() or None,
|
||||
platform_role=platform_role,
|
||||
created_by_user_id=owner_user_id,
|
||||
expires_at=utcnow() + self._ttl(),
|
||||
note=note.strip()[:500],
|
||||
)
|
||||
)
|
||||
return IssuedInvite(invite=invite, token=token)
|
||||
|
||||
async def list_pending(self) -> list[PlatformInvite]:
|
||||
return await self.repo.list_pending()
|
||||
|
||||
async def revoke(self, *, invite_id: str, by_user_id: str) -> None:
|
||||
"""Revoke an unredeemed invite.
|
||||
|
||||
Revoking one that was already redeemed is a no-op rather than an error:
|
||||
the state is already the one the operator asked for, and a second click
|
||||
on "revoke" should not fail loudly.
|
||||
"""
|
||||
invite = await self.repo.get(invite_id)
|
||||
if invite is None or invite.is_redeemed or invite.is_revoked:
|
||||
return
|
||||
await self.repo.mark_revoked(invite_id=invite_id, by_user_id=by_user_id)
|
||||
|
||||
async def redeem(
|
||||
self, *, token: str, user_id: str
|
||||
) -> tuple[bool, PlatformInvite | None]:
|
||||
"""Burn the token for ``user_id``. Returns ``(ok, invite)``.
|
||||
|
||||
Atomicity matters: two requests arriving at once must not both succeed,
|
||||
which is why ``mark_used`` refuses when the row is already stamped. Every
|
||||
rejection path gives the *same* answer, so a probe cannot tell "no such
|
||||
link" from "already used" from "revoked".
|
||||
"""
|
||||
invite = await self.repo.get_by_token_hash(hash_token(token))
|
||||
if invite is None or invite.is_expired():
|
||||
return False, None
|
||||
if not await self.repo.mark_used(invite_id=invite.id, user_id=user_id):
|
||||
return False, None
|
||||
return True, invite
|
||||
|
||||
Reference in New Issue
Block a user