Stage 194: multi-user tenants (invite flow)
Browse files- delivery/emails/__init__.py +2 -0
- delivery/emails/renderer.py +35 -0
- infra/api/app.py +150 -0
- infra/service.py +159 -0
- infra/storage/__init__.py +2 -0
- infra/storage/repositories.py +90 -0
- infra/storage/schema.py +26 -0
delivery/emails/__init__.py
CHANGED
|
@@ -16,6 +16,7 @@ the engine into email-shape coupling.
|
|
| 16 |
"""
|
| 17 |
from .renderer import (
|
| 18 |
render_daily_digest,
|
|
|
|
| 19 |
render_trial_expiring,
|
| 20 |
render_welcome,
|
| 21 |
render_weekly_executive,
|
|
@@ -23,6 +24,7 @@ from .renderer import (
|
|
| 23 |
|
| 24 |
__all__ = [
|
| 25 |
"render_daily_digest",
|
|
|
|
| 26 |
"render_trial_expiring",
|
| 27 |
"render_welcome",
|
| 28 |
"render_weekly_executive",
|
|
|
|
| 16 |
"""
|
| 17 |
from .renderer import (
|
| 18 |
render_daily_digest,
|
| 19 |
+
render_invitation,
|
| 20 |
render_trial_expiring,
|
| 21 |
render_welcome,
|
| 22 |
render_weekly_executive,
|
|
|
|
| 24 |
|
| 25 |
__all__ = [
|
| 26 |
"render_daily_digest",
|
| 27 |
+
"render_invitation",
|
| 28 |
"render_trial_expiring",
|
| 29 |
"render_welcome",
|
| 30 |
"render_weekly_executive",
|
delivery/emails/renderer.py
CHANGED
|
@@ -108,6 +108,41 @@ def _shell(*, title: str, tenant_name: str, body_html: str,
|
|
| 108 |
|
| 109 |
# --- A. Welcome ---------------------------------------------------------
|
| 110 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
def render_welcome(*, tenant_name: str,
|
| 112 |
connect_cta_url: str = "#") -> str:
|
| 113 |
"""Sent right after tenant creation. One-CTA email pushing the
|
|
|
|
| 108 |
|
| 109 |
# --- A. Welcome ---------------------------------------------------------
|
| 110 |
|
| 111 |
+
def render_invitation(*, tenant_name: str,
|
| 112 |
+
invite_link: str,
|
| 113 |
+
role: str = "operator",
|
| 114 |
+
ttl_days: int = 7) -> str:
|
| 115 |
+
"""Stage 194 — invite-to-tenant email. Single CTA to the accept
|
| 116 |
+
page; secondary text shows the raw URL for the rare email
|
| 117 |
+
client that strips button links."""
|
| 118 |
+
body = (
|
| 119 |
+
'<tr><td style="padding:22px 32px 0">'
|
| 120 |
+
f'<div style="font-size:22px;font-weight:700">'
|
| 121 |
+
f'הוזמנת ל‑{_e(tenant_name)} ב‑OrgState</div>'
|
| 122 |
+
f'<p style="font-size:15px;line-height:1.6;color:#566073;'
|
| 123 |
+
f'margin:12px 0 0">הוזמנת להצטרף לארגון '
|
| 124 |
+
f'<b>{_e(tenant_name)}</b> ב‑OrgState בתפקיד '
|
| 125 |
+
f'<b>{_e(role)}</b>. לחץ על הקישור למטה כדי לקבל מפתח גישה '
|
| 126 |
+
f'ולהיכנס. הקישור תקף ל‑{_e(ttl_days)} ימים.</p></td></tr>'
|
| 127 |
+
f'<tr><td style="padding:22px 32px 0">'
|
| 128 |
+
f'<a href="{_e(invite_link)}" style="display:inline-block;'
|
| 129 |
+
f'background:#C97860;color:#fff;text-decoration:none;'
|
| 130 |
+
f'font-weight:600;font-size:15px;padding:12px 26px;'
|
| 131 |
+
f'border-radius:8px">קבל גישה</a></td></tr>'
|
| 132 |
+
f'<tr><td style="padding:18px 32px 0;font-size:13px;'
|
| 133 |
+
f'color:#6E7A82;line-height:1.6">או העתק את הקישור: '
|
| 134 |
+
f'<span style="font-family:monospace" dir="ltr">'
|
| 135 |
+
f'{_e(invite_link)}</span></td></tr>'
|
| 136 |
+
)
|
| 137 |
+
pre_footer = (
|
| 138 |
+
"אם לא ציפית להזמנה הזו, אפשר להתעלם — הקישור יפוג מעצמו."
|
| 139 |
+
)
|
| 140 |
+
return _shell(title=f"OrgState — הזמנה ל‑{tenant_name}",
|
| 141 |
+
tenant_name=tenant_name,
|
| 142 |
+
body_html=body,
|
| 143 |
+
footer_pre_html=pre_footer)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
def render_welcome(*, tenant_name: str,
|
| 147 |
connect_cta_url: str = "#") -> str:
|
| 148 |
"""Sent right after tenant creation. One-CTA email pushing the
|
infra/api/app.py
CHANGED
|
@@ -2520,6 +2520,156 @@ def create_app(db_path: Optional[str] = None,
|
|
| 2520 |
)
|
| 2521 |
return Response(status_code=204)
|
| 2522 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2523 |
@app.post("/tenants/{tenant_id}/api_keys", tags=["api_keys"], status_code=201)
|
| 2524 |
async def mint_api_key(
|
| 2525 |
tenant_id: str,
|
|
|
|
| 2520 |
)
|
| 2521 |
return Response(status_code=204)
|
| 2522 |
|
| 2523 |
+
# --- Stage 194: tenant invitations ---------------------------------
|
| 2524 |
+
# An admin invites a coworker by email. The invitee follows a
|
| 2525 |
+
# one-shot link, picks a display name, and the backend hands
|
| 2526 |
+
# them an API key bound to their name + the invited role.
|
| 2527 |
+
# The flow uses the Stage 185/191 email infra to deliver the link.
|
| 2528 |
+
|
| 2529 |
+
def _invite_link(token: str) -> str:
|
| 2530 |
+
"""Construct the accept URL. The frontend renders
|
| 2531 |
+
/invite/<token> which posts to /invitations/accept."""
|
| 2532 |
+
base = (os.environ.get("ORGSTATE_DASHBOARD_URL", "").strip()
|
| 2533 |
+
or "https://orgstate.1bigfam.com")
|
| 2534 |
+
base = base.rstrip("/")
|
| 2535 |
+
return f"{base}/invite/{token}"
|
| 2536 |
+
|
| 2537 |
+
@app.post("/tenants/{tenant_id}/invitations",
|
| 2538 |
+
tags=["invitations"], status_code=201)
|
| 2539 |
+
async def create_invitation_route(
|
| 2540 |
+
tenant_id: str,
|
| 2541 |
+
body: dict,
|
| 2542 |
+
authorization: Optional[str] = Header(default=None),
|
| 2543 |
+
):
|
| 2544 |
+
import os as _os
|
| 2545 |
+
from delivery.emails import render_invitation
|
| 2546 |
+
from infra.email_transport import EmailSendError
|
| 2547 |
+
key = require_tenant_or_admin(svc, authorization, tenant_id)
|
| 2548 |
+
if key is not None:
|
| 2549 |
+
require_role(key, ROLE_ADMIN)
|
| 2550 |
+
email = body.get("email")
|
| 2551 |
+
role = body.get("role", "operator")
|
| 2552 |
+
if not isinstance(email, str) or not email:
|
| 2553 |
+
raise ApiError("bad_request",
|
| 2554 |
+
"body must include 'email' (string)",
|
| 2555 |
+
status=400)
|
| 2556 |
+
try:
|
| 2557 |
+
invite = svc.invite_user(
|
| 2558 |
+
tenant_id, email, role=role,
|
| 2559 |
+
invited_by=_tenant_or_admin_actor(authorization, tenant_id),
|
| 2560 |
+
actor=_tenant_or_admin_actor(authorization, tenant_id),
|
| 2561 |
+
)
|
| 2562 |
+
except ValueError as e:
|
| 2563 |
+
raise ApiError("bad_request", str(e), status=400) from e
|
| 2564 |
+
|
| 2565 |
+
# Attempt to send the invite email. Failure does NOT roll back
|
| 2566 |
+
# the invitation — the admin can copy the URL manually.
|
| 2567 |
+
# `email_sent` flags the outcome so the dashboard can show
|
| 2568 |
+
# the right message.
|
| 2569 |
+
link = _invite_link(invite["raw_token"])
|
| 2570 |
+
t = svc.get_tenant(tenant_id)
|
| 2571 |
+
tenant_name = t["name"] if t else tenant_id
|
| 2572 |
+
html = render_invitation(
|
| 2573 |
+
tenant_name=tenant_name,
|
| 2574 |
+
invite_link=link,
|
| 2575 |
+
role=invite["role"],
|
| 2576 |
+
)
|
| 2577 |
+
email_sent = False
|
| 2578 |
+
email_error = None
|
| 2579 |
+
try:
|
| 2580 |
+
svc.send_tenant_email(
|
| 2581 |
+
tenant_id, to=email,
|
| 2582 |
+
subject=f"הזמנה ל‑{tenant_name} ב‑OrgState",
|
| 2583 |
+
html=html, template="invitation",
|
| 2584 |
+
actor=_tenant_or_admin_actor(authorization, tenant_id),
|
| 2585 |
+
)
|
| 2586 |
+
email_sent = True
|
| 2587 |
+
except EmailSendError as e:
|
| 2588 |
+
email_error = str(e)
|
| 2589 |
+
# Strip the raw token from the returned row — the email path
|
| 2590 |
+
# already received it. The accept URL is the only way to use
|
| 2591 |
+
# the token; we don't echo it back via the API response so an
|
| 2592 |
+
# admin watching network logs doesn't accidentally see it
|
| 2593 |
+
# twice.
|
| 2594 |
+
result = {k: v for k, v in invite.items()
|
| 2595 |
+
if k not in ("token_hash", "raw_token")}
|
| 2596 |
+
result["email_sent"] = email_sent
|
| 2597 |
+
if email_error:
|
| 2598 |
+
result["email_error"] = email_error
|
| 2599 |
+
result["accept_url"] = link # let the admin copy manually
|
| 2600 |
+
return result
|
| 2601 |
+
|
| 2602 |
+
@app.get("/tenants/{tenant_id}/invitations",
|
| 2603 |
+
tags=["invitations"])
|
| 2604 |
+
async def list_invitations_route(
|
| 2605 |
+
tenant_id: str,
|
| 2606 |
+
authorization: Optional[str] = Header(default=None),
|
| 2607 |
+
):
|
| 2608 |
+
key = require_tenant_or_admin(svc, authorization, tenant_id)
|
| 2609 |
+
if key is not None:
|
| 2610 |
+
require_role(key, ROLE_ADMIN)
|
| 2611 |
+
rows = svc.list_pending_invitations(tenant_id)
|
| 2612 |
+
# Don't leak token_hash to the dashboard.
|
| 2613 |
+
return {
|
| 2614 |
+
"invitations": [
|
| 2615 |
+
{k: v for k, v in r.items() if k != "token_hash"}
|
| 2616 |
+
for r in rows
|
| 2617 |
+
],
|
| 2618 |
+
}
|
| 2619 |
+
|
| 2620 |
+
@app.delete("/tenants/{tenant_id}/invitations/{invite_id}",
|
| 2621 |
+
tags=["invitations"], status_code=204)
|
| 2622 |
+
async def revoke_invitation_route(
|
| 2623 |
+
tenant_id: str,
|
| 2624 |
+
invite_id: str,
|
| 2625 |
+
authorization: Optional[str] = Header(default=None),
|
| 2626 |
+
):
|
| 2627 |
+
key = require_tenant_or_admin(svc, authorization, tenant_id)
|
| 2628 |
+
if key is not None:
|
| 2629 |
+
require_role(key, ROLE_ADMIN)
|
| 2630 |
+
try:
|
| 2631 |
+
out = svc.revoke_invitation(
|
| 2632 |
+
invite_id,
|
| 2633 |
+
actor=_tenant_or_admin_actor(authorization, tenant_id),
|
| 2634 |
+
)
|
| 2635 |
+
except ValueError as e:
|
| 2636 |
+
raise ApiError("bad_request", str(e), status=400) from e
|
| 2637 |
+
if out is None:
|
| 2638 |
+
raise ApiError("not_found",
|
| 2639 |
+
f"invitation {invite_id!r} not found",
|
| 2640 |
+
status=404)
|
| 2641 |
+
return Response(status_code=204)
|
| 2642 |
+
|
| 2643 |
+
@app.post("/invitations/accept", tags=["invitations"])
|
| 2644 |
+
async def accept_invitation_route(body: dict):
|
| 2645 |
+
"""Public — no auth header required. The token is the
|
| 2646 |
+
credential. Returns the minted API key + tenant + role.
|
| 2647 |
+
Same one-shot semantics as api_key creation: raw key is
|
| 2648 |
+
returned ONCE."""
|
| 2649 |
+
token = body.get("token")
|
| 2650 |
+
display_name = body.get("display_name", "")
|
| 2651 |
+
if not isinstance(token, str) or not token:
|
| 2652 |
+
raise ApiError("bad_request",
|
| 2653 |
+
"body must include 'token' (string)",
|
| 2654 |
+
status=400)
|
| 2655 |
+
if (not isinstance(display_name, str)
|
| 2656 |
+
or not display_name.strip()):
|
| 2657 |
+
raise ApiError("bad_request",
|
| 2658 |
+
"body must include 'display_name' (string)",
|
| 2659 |
+
status=400)
|
| 2660 |
+
try:
|
| 2661 |
+
return svc.accept_invitation(
|
| 2662 |
+
token, display_name=display_name)
|
| 2663 |
+
except ValueError as e:
|
| 2664 |
+
msg = str(e).lower()
|
| 2665 |
+
status = (404 if "not found" in msg
|
| 2666 |
+
else 410 if ("expired" in msg
|
| 2667 |
+
or "revoked" in msg
|
| 2668 |
+
or "already accepted" in msg)
|
| 2669 |
+
else 400)
|
| 2670 |
+
raise ApiError("bad_request", str(e),
|
| 2671 |
+
status=status) from e
|
| 2672 |
+
|
| 2673 |
@app.post("/tenants/{tenant_id}/api_keys", tags=["api_keys"], status_code=201)
|
| 2674 |
async def mint_api_key(
|
| 2675 |
tenant_id: str,
|
infra/service.py
CHANGED
|
@@ -53,6 +53,7 @@ from .storage import (
|
|
| 53 |
RunRepository,
|
| 54 |
ScimGroupRepository,
|
| 55 |
ScimUserRepository,
|
|
|
|
| 56 |
TenantOverridesRepository,
|
| 57 |
TenantPlanOverrideRepository,
|
| 58 |
TenantQuotaRepository,
|
|
@@ -160,6 +161,7 @@ class OrgStateService:
|
|
| 160 |
self.invoices = InvoiceRepository(self.db) # Stage 92
|
| 161 |
self.scim_users = ScimUserRepository(self.db) # Stage 103
|
| 162 |
self.scim_groups = ScimGroupRepository(self.db) # Stage 108
|
|
|
|
| 163 |
self.tenant_plan_overrides = TenantPlanOverrideRepository(
|
| 164 |
self.db,
|
| 165 |
) # Stage 109
|
|
@@ -1328,6 +1330,163 @@ class OrgStateService:
|
|
| 1328 |
)
|
| 1329 |
return ok
|
| 1330 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1331 |
# --- auth / api keys (Stage 5a) -------------------------------------
|
| 1332 |
def create_api_key(self, tenant_id: str, name: str = "",
|
| 1333 |
expires_at: Optional[str] = None,
|
|
|
|
| 53 |
RunRepository,
|
| 54 |
ScimGroupRepository,
|
| 55 |
ScimUserRepository,
|
| 56 |
+
TenantInvitationRepository,
|
| 57 |
TenantOverridesRepository,
|
| 58 |
TenantPlanOverrideRepository,
|
| 59 |
TenantQuotaRepository,
|
|
|
|
| 161 |
self.invoices = InvoiceRepository(self.db) # Stage 92
|
| 162 |
self.scim_users = ScimUserRepository(self.db) # Stage 103
|
| 163 |
self.scim_groups = ScimGroupRepository(self.db) # Stage 108
|
| 164 |
+
self.invitations = TenantInvitationRepository(self.db) # Stage 194
|
| 165 |
self.tenant_plan_overrides = TenantPlanOverrideRepository(
|
| 166 |
self.db,
|
| 167 |
) # Stage 109
|
|
|
|
| 1330 |
)
|
| 1331 |
return ok
|
| 1332 |
|
| 1333 |
+
# --- Stage 194 — tenant invitations ---------------------------------
|
| 1334 |
+
|
| 1335 |
+
_VALID_INVITE_ROLES = ("readonly", "operator", "admin")
|
| 1336 |
+
_INVITATION_TTL_DAYS = 7
|
| 1337 |
+
|
| 1338 |
+
@staticmethod
|
| 1339 |
+
def _hash_token(token: str) -> str:
|
| 1340 |
+
import hashlib
|
| 1341 |
+
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
| 1342 |
+
|
| 1343 |
+
def invite_user(self, tenant_id: str, email: str, *,
|
| 1344 |
+
role: str = "operator",
|
| 1345 |
+
invited_by: str = "operator",
|
| 1346 |
+
actor: str = "operator") -> dict:
|
| 1347 |
+
"""Stage 194 — create an invitation for ``email`` to join the
|
| 1348 |
+
tenant with the given role. Returns the row + a ``raw_token``
|
| 1349 |
+
field that the caller (API route) hands to the email send
|
| 1350 |
+
flow. The raw token is NEVER persisted; only sha256(token)
|
| 1351 |
+
sits in the row. A future revoke / expire / accept can re-
|
| 1352 |
+
identify the row via get_by_token(sha256(presented_token)).
|
| 1353 |
+
|
| 1354 |
+
Roles: readonly / operator / admin. Default operator —
|
| 1355 |
+
the most common "give a coworker access to triage" tier.
|
| 1356 |
+
|
| 1357 |
+
TTL: 7 days. Re-invite to refresh. After accept/revoke the
|
| 1358 |
+
row is retained for audit but excluded from list_pending.
|
| 1359 |
+
"""
|
| 1360 |
+
from datetime import datetime as _dt
|
| 1361 |
+
from datetime import timedelta as _td
|
| 1362 |
+
from datetime import timezone as _tz
|
| 1363 |
+
import secrets
|
| 1364 |
+
|
| 1365 |
+
self._require_tenant(tenant_id)
|
| 1366 |
+
email = (email or "").strip().lower()
|
| 1367 |
+
if not email or "@" not in email:
|
| 1368 |
+
raise ValueError(
|
| 1369 |
+
f"email must be a non-empty 'user@domain' string, "
|
| 1370 |
+
f"got {email!r}",
|
| 1371 |
+
)
|
| 1372 |
+
if role not in self._VALID_INVITE_ROLES:
|
| 1373 |
+
raise ValueError(
|
| 1374 |
+
f"role must be one of {self._VALID_INVITE_ROLES}, "
|
| 1375 |
+
f"got {role!r}",
|
| 1376 |
+
)
|
| 1377 |
+
token = secrets.token_urlsafe(32)
|
| 1378 |
+
token_hash = self._hash_token(token)
|
| 1379 |
+
expires_at = (
|
| 1380 |
+
_dt.now(_tz.utc) + _td(days=self._INVITATION_TTL_DAYS)
|
| 1381 |
+
).isoformat()
|
| 1382 |
+
row = self.invitations.create(
|
| 1383 |
+
tenant_id=tenant_id, email=email, role=role,
|
| 1384 |
+
token_hash=token_hash, invited_by=invited_by,
|
| 1385 |
+
expires_at=expires_at,
|
| 1386 |
+
)
|
| 1387 |
+
self.audit.log(
|
| 1388 |
+
actor, "invite_user",
|
| 1389 |
+
target_id=row["invite_id"],
|
| 1390 |
+
tenant_id=tenant_id,
|
| 1391 |
+
payload={"email": email, "role": role,
|
| 1392 |
+
"expires_at": expires_at},
|
| 1393 |
+
)
|
| 1394 |
+
# The raw token is returned to the caller — they need it for
|
| 1395 |
+
# the link in the email — but it does NOT live in the row we
|
| 1396 |
+
# return on subsequent reads.
|
| 1397 |
+
return {**row, "raw_token": token}
|
| 1398 |
+
|
| 1399 |
+
def list_pending_invitations(self, tenant_id: str) -> List[dict]:
|
| 1400 |
+
"""Stage 194 — pending invitations for the tenant (not yet
|
| 1401 |
+
accepted, not revoked, not expired). Sorted oldest first."""
|
| 1402 |
+
self._require_tenant(tenant_id)
|
| 1403 |
+
return self.invitations.list_pending(tenant_id)
|
| 1404 |
+
|
| 1405 |
+
def revoke_invitation(self, invite_id: str, *,
|
| 1406 |
+
actor: str = "operator",
|
| 1407 |
+
) -> Optional[dict]:
|
| 1408 |
+
"""Stage 194 — admin pulls back a pending invitation. The
|
| 1409 |
+
row remains for audit but the token can no longer be
|
| 1410 |
+
redeemed."""
|
| 1411 |
+
existing = self.invitations.get(invite_id)
|
| 1412 |
+
if existing is None:
|
| 1413 |
+
return None
|
| 1414 |
+
if existing.get("accepted_at"):
|
| 1415 |
+
raise ValueError(
|
| 1416 |
+
f"invitation {invite_id!r} was already accepted; "
|
| 1417 |
+
"revoking has no effect (revoke the resulting api key "
|
| 1418 |
+
"instead)",
|
| 1419 |
+
)
|
| 1420 |
+
row = self.invitations.mark_revoked(invite_id)
|
| 1421 |
+
self.audit.log(
|
| 1422 |
+
actor, "revoke_invitation",
|
| 1423 |
+
target_id=invite_id,
|
| 1424 |
+
tenant_id=existing["tenant_id"],
|
| 1425 |
+
payload={"email": existing.get("email")},
|
| 1426 |
+
)
|
| 1427 |
+
return row
|
| 1428 |
+
|
| 1429 |
+
def accept_invitation(self, token: str, *,
|
| 1430 |
+
display_name: str,
|
| 1431 |
+
) -> dict:
|
| 1432 |
+
"""Stage 194 — the invitee redeems their one-shot link.
|
| 1433 |
+
Validates the token, mints an API key bound to their
|
| 1434 |
+
display_name + the invited role, marks the invite accepted.
|
| 1435 |
+
Returns ``{tenant_id, role, api_key, key_id, name}`` —
|
| 1436 |
+
the raw API key in ``api_key`` is shown EXACTLY ONCE per
|
| 1437 |
+
the existing api_key contract.
|
| 1438 |
+
|
| 1439 |
+
Raises ValueError on: unknown token / already accepted /
|
| 1440 |
+
already revoked / expired.
|
| 1441 |
+
"""
|
| 1442 |
+
from datetime import datetime as _dt
|
| 1443 |
+
from datetime import timezone as _tz
|
| 1444 |
+
|
| 1445 |
+
display_name = (display_name or "").strip()
|
| 1446 |
+
if not display_name:
|
| 1447 |
+
raise ValueError("display_name is required")
|
| 1448 |
+
if not token:
|
| 1449 |
+
raise ValueError("token is required")
|
| 1450 |
+
row = self.invitations.get_by_token(self._hash_token(token))
|
| 1451 |
+
if row is None:
|
| 1452 |
+
raise ValueError("invitation not found")
|
| 1453 |
+
if row.get("accepted_at"):
|
| 1454 |
+
raise ValueError("invitation already accepted")
|
| 1455 |
+
if row.get("revoked_at"):
|
| 1456 |
+
raise ValueError("invitation revoked")
|
| 1457 |
+
try:
|
| 1458 |
+
exp = _dt.fromisoformat(row["expires_at"])
|
| 1459 |
+
except (TypeError, ValueError):
|
| 1460 |
+
exp = None
|
| 1461 |
+
if exp is None or exp < _dt.now(_tz.utc):
|
| 1462 |
+
raise ValueError("invitation expired")
|
| 1463 |
+
# Mint the api key first; if anything fails AFTER the mint we
|
| 1464 |
+
# still want the user to have a usable credential.
|
| 1465 |
+
api_key = self.create_api_key(
|
| 1466 |
+
row["tenant_id"], name=display_name,
|
| 1467 |
+
role=row["role"], actor=f"invite:{row['invite_id']}",
|
| 1468 |
+
)
|
| 1469 |
+
self.invitations.mark_accepted(
|
| 1470 |
+
row["invite_id"], key_id=api_key.key_id,
|
| 1471 |
+
)
|
| 1472 |
+
self.audit.log(
|
| 1473 |
+
f"invite:{row['invite_id']}", "accept_invitation",
|
| 1474 |
+
target_id=api_key.key_id,
|
| 1475 |
+
tenant_id=row["tenant_id"],
|
| 1476 |
+
payload={
|
| 1477 |
+
"email": row.get("email"),
|
| 1478 |
+
"display_name": display_name,
|
| 1479 |
+
"role": row["role"],
|
| 1480 |
+
},
|
| 1481 |
+
)
|
| 1482 |
+
return {
|
| 1483 |
+
"tenant_id": row["tenant_id"],
|
| 1484 |
+
"role": row["role"],
|
| 1485 |
+
"key_id": api_key.key_id,
|
| 1486 |
+
"api_key": api_key.raw,
|
| 1487 |
+
"name": display_name,
|
| 1488 |
+
}
|
| 1489 |
+
|
| 1490 |
# --- auth / api keys (Stage 5a) -------------------------------------
|
| 1491 |
def create_api_key(self, tenant_id: str, name: str = "",
|
| 1492 |
expires_at: Optional[str] = None,
|
infra/storage/__init__.py
CHANGED
|
@@ -35,6 +35,7 @@ from .repositories import (
|
|
| 35 |
ScimGroupRepository,
|
| 36 |
ScimUserConflict,
|
| 37 |
ScimUserRepository,
|
|
|
|
| 38 |
TenantOverridesRepository,
|
| 39 |
TenantPlanOverrideRepository,
|
| 40 |
TenantQuotaRepository,
|
|
@@ -71,6 +72,7 @@ __all__ = [
|
|
| 71 |
"ScimGroupRepository",
|
| 72 |
"ScimUserConflict",
|
| 73 |
"ScimUserRepository",
|
|
|
|
| 74 |
"TenantPlanOverrideRepository",
|
| 75 |
"TenantQuotaRepository",
|
| 76 |
"UsageRepository",
|
|
|
|
| 35 |
ScimGroupRepository,
|
| 36 |
ScimUserConflict,
|
| 37 |
ScimUserRepository,
|
| 38 |
+
TenantInvitationRepository,
|
| 39 |
TenantOverridesRepository,
|
| 40 |
TenantPlanOverrideRepository,
|
| 41 |
TenantQuotaRepository,
|
|
|
|
| 72 |
"ScimGroupRepository",
|
| 73 |
"ScimUserConflict",
|
| 74 |
"ScimUserRepository",
|
| 75 |
+
"TenantInvitationRepository",
|
| 76 |
"TenantPlanOverrideRepository",
|
| 77 |
"TenantQuotaRepository",
|
| 78 |
"UsageRepository",
|
infra/storage/repositories.py
CHANGED
|
@@ -2634,3 +2634,93 @@ class EntityMuteRepository:
|
|
| 2634 |
(mute_id,),
|
| 2635 |
)
|
| 2636 |
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2634 |
(mute_id,),
|
| 2635 |
)
|
| 2636 |
return True
|
| 2637 |
+
|
| 2638 |
+
|
| 2639 |
+
# --- Stage 194 — tenant invitations ----------------------------------
|
| 2640 |
+
|
| 2641 |
+
class TenantInvitationRepository:
|
| 2642 |
+
"""Persists pending + completed invitations. The raw token is
|
| 2643 |
+
NEVER stored — only ``sha256(token)``, mirroring the api_keys
|
| 2644 |
+
discipline. The same row carries lifecycle timestamps
|
| 2645 |
+
(accepted_at, revoked_at) so the table doubles as an audit
|
| 2646 |
+
trail of who joined when."""
|
| 2647 |
+
|
| 2648 |
+
def __init__(self, db: Database):
|
| 2649 |
+
self.db = db
|
| 2650 |
+
|
| 2651 |
+
def create(self, tenant_id: str, email: str, role: str,
|
| 2652 |
+
token_hash: str, invited_by: str,
|
| 2653 |
+
expires_at: str,
|
| 2654 |
+
invite_id: Optional[str] = None,
|
| 2655 |
+
) -> dict:
|
| 2656 |
+
import uuid
|
| 2657 |
+
invite_id = invite_id or "inv_" + uuid.uuid4().hex[:12]
|
| 2658 |
+
ts = _now()
|
| 2659 |
+
self.db.execute(
|
| 2660 |
+
"INSERT INTO tenant_invitations "
|
| 2661 |
+
"(invite_id, tenant_id, email, role, token_hash, "
|
| 2662 |
+
" invited_by, created_at, expires_at) "
|
| 2663 |
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
| 2664 |
+
(invite_id, tenant_id, email, role, token_hash,
|
| 2665 |
+
invited_by, ts, expires_at),
|
| 2666 |
+
)
|
| 2667 |
+
return self.get(invite_id)
|
| 2668 |
+
|
| 2669 |
+
def get(self, invite_id: str) -> Optional[dict]:
|
| 2670 |
+
return self.db.query_one(
|
| 2671 |
+
"SELECT * FROM tenant_invitations WHERE invite_id=?",
|
| 2672 |
+
(invite_id,),
|
| 2673 |
+
)
|
| 2674 |
+
|
| 2675 |
+
def get_by_token(self, token_hash: str) -> Optional[dict]:
|
| 2676 |
+
return self.db.query_one(
|
| 2677 |
+
"SELECT * FROM tenant_invitations WHERE token_hash=?",
|
| 2678 |
+
(token_hash,),
|
| 2679 |
+
)
|
| 2680 |
+
|
| 2681 |
+
def list_pending(self, tenant_id: str) -> List[dict]:
|
| 2682 |
+
"""Pending = not accepted AND not revoked AND not expired.
|
| 2683 |
+
Sorted oldest-first so the operator sees the longest-waiting
|
| 2684 |
+
first."""
|
| 2685 |
+
return self.db.query_all(
|
| 2686 |
+
"SELECT * FROM tenant_invitations "
|
| 2687 |
+
"WHERE tenant_id=? "
|
| 2688 |
+
" AND accepted_at IS NULL "
|
| 2689 |
+
" AND revoked_at IS NULL "
|
| 2690 |
+
" AND expires_at > ? "
|
| 2691 |
+
"ORDER BY created_at ASC",
|
| 2692 |
+
(tenant_id, _now()),
|
| 2693 |
+
)
|
| 2694 |
+
|
| 2695 |
+
def list_all(self, tenant_id: str,
|
| 2696 |
+
*, limit: int = 100) -> List[dict]:
|
| 2697 |
+
"""Every invitation row — pending + accepted + revoked +
|
| 2698 |
+
expired. Used by audit / compliance queries."""
|
| 2699 |
+
return self.db.query_all(
|
| 2700 |
+
"SELECT * FROM tenant_invitations "
|
| 2701 |
+
"WHERE tenant_id=? "
|
| 2702 |
+
"ORDER BY created_at DESC LIMIT ?",
|
| 2703 |
+
(tenant_id, limit),
|
| 2704 |
+
)
|
| 2705 |
+
|
| 2706 |
+
def mark_accepted(self, invite_id: str, *,
|
| 2707 |
+
key_id: str) -> Optional[dict]:
|
| 2708 |
+
if self.get(invite_id) is None:
|
| 2709 |
+
return None
|
| 2710 |
+
self.db.execute(
|
| 2711 |
+
"UPDATE tenant_invitations "
|
| 2712 |
+
"SET accepted_at=?, accepted_key_id=? "
|
| 2713 |
+
"WHERE invite_id=?",
|
| 2714 |
+
(_now(), key_id, invite_id),
|
| 2715 |
+
)
|
| 2716 |
+
return self.get(invite_id)
|
| 2717 |
+
|
| 2718 |
+
def mark_revoked(self, invite_id: str) -> Optional[dict]:
|
| 2719 |
+
if self.get(invite_id) is None:
|
| 2720 |
+
return None
|
| 2721 |
+
self.db.execute(
|
| 2722 |
+
"UPDATE tenant_invitations SET revoked_at=? "
|
| 2723 |
+
"WHERE invite_id=?",
|
| 2724 |
+
(_now(), invite_id),
|
| 2725 |
+
)
|
| 2726 |
+
return self.get(invite_id)
|
infra/storage/schema.py
CHANGED
|
@@ -686,6 +686,32 @@ CREATE TABLE IF NOT EXISTS entity_mutes (
|
|
| 686 |
CREATE INDEX IF NOT EXISTS idx_entity_mutes_tenant_until
|
| 687 |
ON entity_mutes (tenant_id, muted_until);
|
| 688 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 689 |
-- init_db() AFTER the forward migrations, so a stale v2 DB upgrades cleanly.
|
| 690 |
CREATE INDEX IF NOT EXISTS idx_schedules_tenant ON schedules (tenant_id);
|
| 691 |
CREATE INDEX IF NOT EXISTS idx_api_keys_tenant ON api_keys (tenant_id);
|
|
|
|
| 686 |
CREATE INDEX IF NOT EXISTS idx_entity_mutes_tenant_until
|
| 687 |
ON entity_mutes (tenant_id, muted_until);
|
| 688 |
|
| 689 |
+
-- v34 (Stage 194) — tenant invitations. An admin invites a coworker
|
| 690 |
+
-- by email; the row carries a one-time token that the invitee
|
| 691 |
+
-- redeems on the public /invite/accept page in exchange for an API
|
| 692 |
+
-- key bound to their display name + the chosen role. accepted_at /
|
| 693 |
+
-- revoked_at are timestamps that retire the row from the "pending"
|
| 694 |
+
-- list. token is hash-only (sha256) so a leaked DB doesn't hand
|
| 695 |
+
-- attackers a usable acceptance link.
|
| 696 |
+
CREATE TABLE IF NOT EXISTS tenant_invitations (
|
| 697 |
+
invite_id TEXT PRIMARY KEY,
|
| 698 |
+
tenant_id TEXT NOT NULL,
|
| 699 |
+
email TEXT NOT NULL,
|
| 700 |
+
role TEXT NOT NULL DEFAULT 'operator',
|
| 701 |
+
token_hash TEXT NOT NULL UNIQUE,
|
| 702 |
+
invited_by TEXT NOT NULL,
|
| 703 |
+
created_at TEXT NOT NULL,
|
| 704 |
+
expires_at TEXT NOT NULL,
|
| 705 |
+
accepted_at TEXT,
|
| 706 |
+
accepted_key_id TEXT,
|
| 707 |
+
revoked_at TEXT,
|
| 708 |
+
FOREIGN KEY (tenant_id) REFERENCES tenants (tenant_id)
|
| 709 |
+
);
|
| 710 |
+
CREATE INDEX IF NOT EXISTS idx_tenant_invitations_tenant
|
| 711 |
+
ON tenant_invitations (tenant_id);
|
| 712 |
+
CREATE INDEX IF NOT EXISTS idx_tenant_invitations_token
|
| 713 |
+
ON tenant_invitations (token_hash);
|
| 714 |
+
|
| 715 |
-- init_db() AFTER the forward migrations, so a stale v2 DB upgrades cleanly.
|
| 716 |
CREATE INDEX IF NOT EXISTS idx_schedules_tenant ON schedules (tenant_id);
|
| 717 |
CREATE INDEX IF NOT EXISTS idx_api_keys_tenant ON api_keys (tenant_id);
|