Legal-i commited on
Commit
a91d0ba
·
verified ·
1 Parent(s): 80839fe

Stage 194: multi-user tenants (invite flow)

Browse files
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);