pinecall-runtime 0.1.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (328) hide show
  1. pinecall/__init__.py +0 -0
  2. pinecall/channels/__init__.py +0 -0
  3. pinecall/channels/_chooser.py +45 -0
  4. pinecall/channels/offers.py +219 -0
  5. pinecall/channels/rooms.py +176 -0
  6. pinecall/channels/routes.py +275 -0
  7. pinecall/channels/telephony/__init__.py +0 -0
  8. pinecall/channels/telephony/_twilio.py +349 -0
  9. pinecall/channels/telephony/carrier.py +229 -0
  10. pinecall/channels/telephony/carrier_catalog.py +92 -0
  11. pinecall/channels/telephony/carriers.csv +41 -0
  12. pinecall/channels/telephony/dialing.py +419 -0
  13. pinecall/channels/telephony/firewall.py +58 -0
  14. pinecall/channels/telephony/hand_over.py +78 -0
  15. pinecall/channels/telephony/number_path.py +176 -0
  16. pinecall/channels/telephony/numbers.py +530 -0
  17. pinecall/channels/telephony/origination.py +77 -0
  18. pinecall/channels/telephony/sip.py +448 -0
  19. pinecall/channels/whatsapp.py +413 -0
  20. pinecall/cli/__init__.py +0 -0
  21. pinecall/cli/_archive.py +85 -0
  22. pinecall/cli/_drift.py +38 -0
  23. pinecall/cli/_facts.py +67 -0
  24. pinecall/cli/_load.py +433 -0
  25. pinecall/cli/_operator.py +626 -0
  26. pinecall/cli/_sessions.py +164 -0
  27. pinecall/cli/_traceback.py +95 -0
  28. pinecall/cli/_usage.py +34 -0
  29. pinecall/cli/main.py +521 -0
  30. pinecall/domain/__init__.py +0 -0
  31. pinecall/domain/agent.py +446 -0
  32. pinecall/domain/call.py +152 -0
  33. pinecall/domain/country_codes.py +32 -0
  34. pinecall/domain/errors.py +151 -0
  35. pinecall/domain/names.py +115 -0
  36. pinecall/domain/org.py +120 -0
  37. pinecall/domain/person.py +208 -0
  38. pinecall/domain/scope.py +21 -0
  39. pinecall/evals/__init__.py +0 -0
  40. pinecall/evals/_evidence.py +158 -0
  41. pinecall/evals/calibration.py +112 -0
  42. pinecall/evals/callers.py +160 -0
  43. pinecall/evals/case.py +327 -0
  44. pinecall/evals/checks.py +437 -0
  45. pinecall/evals/compliance.py +197 -0
  46. pinecall/evals/dataset.py +451 -0
  47. pinecall/evals/goldens.py +331 -0
  48. pinecall/evals/judges.py +525 -0
  49. pinecall/evals/runs.py +252 -0
  50. pinecall/evals/spoken.py +356 -0
  51. pinecall/fleet/__init__.py +0 -0
  52. pinecall/fleet/client.py +569 -0
  53. pinecall/fleet/demand.py +51 -0
  54. pinecall/fleet/heartbeat.py +194 -0
  55. pinecall/fleet/measures.py +157 -0
  56. pinecall/fleet/roster.py +232 -0
  57. pinecall/fleet/worlds.py +44 -0
  58. pinecall/gateway/__init__.py +0 -0
  59. pinecall/gateway/_call_setup.py +53 -0
  60. pinecall/gateway/_deps.py +556 -0
  61. pinecall/gateway/_gateway.py +75 -0
  62. pinecall/gateway/_served.py +396 -0
  63. pinecall/gateway/_sockets.py +444 -0
  64. pinecall/gateway/_streams.py +83 -0
  65. pinecall/gateway/_text_calls.py +182 -0
  66. pinecall/gateway/api/__init__.py +0 -0
  67. pinecall/gateway/api/accounts.py +324 -0
  68. pinecall/gateway/api/agents.py +150 -0
  69. pinecall/gateway/api/apps.py +478 -0
  70. pinecall/gateway/api/box.py +658 -0
  71. pinecall/gateway/api/calibration.py +58 -0
  72. pinecall/gateway/api/callbacks.py +105 -0
  73. pinecall/gateway/api/calls.py +655 -0
  74. pinecall/gateway/api/chat.py +223 -0
  75. pinecall/gateway/api/dataset.py +163 -0
  76. pinecall/gateway/api/desk.py +113 -0
  77. pinecall/gateway/api/entries.py +77 -0
  78. pinecall/gateway/api/evals.py +647 -0
  79. pinecall/gateway/api/fleet.py +73 -0
  80. pinecall/gateway/api/hosting.py +241 -0
  81. pinecall/gateway/api/judges.py +97 -0
  82. pinecall/gateway/api/keys.py +110 -0
  83. pinecall/gateway/api/line.py +142 -0
  84. pinecall/gateway/api/members.py +197 -0
  85. pinecall/gateway/api/metrics.py +226 -0
  86. pinecall/gateway/api/numbers.py +476 -0
  87. pinecall/gateway/api/ops.py +428 -0
  88. pinecall/gateway/api/org.py +283 -0
  89. pinecall/gateway/api/personas.py +157 -0
  90. pinecall/gateway/api/pipeline.py +206 -0
  91. pinecall/gateway/api/prompts.py +50 -0
  92. pinecall/gateway/api/providers.py +240 -0
  93. pinecall/gateway/api/recordings.py +89 -0
  94. pinecall/gateway/api/relay.py +169 -0
  95. pinecall/gateway/api/retrieval.py +403 -0
  96. pinecall/gateway/api/runner.py +109 -0
  97. pinecall/gateway/api/sessions.py +102 -0
  98. pinecall/gateway/api/settings.py +463 -0
  99. pinecall/gateway/api/signup.py +164 -0
  100. pinecall/gateway/api/sso_login.py +168 -0
  101. pinecall/gateway/api/threads.py +187 -0
  102. pinecall/gateway/api/usage.py +303 -0
  103. pinecall/gateway/api/visitors.py +222 -0
  104. pinecall/gateway/api/whatsapp.py +48 -0
  105. pinecall/gateway/api/widget.py +49 -0
  106. pinecall/gateway/app.py +504 -0
  107. pinecall/gateway/calls/__init__.py +0 -0
  108. pinecall/gateway/calls/binding.py +63 -0
  109. pinecall/gateway/calls/commands.py +18 -0
  110. pinecall/gateway/calls/inbox.py +100 -0
  111. pinecall/gateway/calls/known.py +23 -0
  112. pinecall/gateway/calls/owners.py +87 -0
  113. pinecall/gateway/calls/pump.py +33 -0
  114. pinecall/gateway/calls/serving.py +118 -0
  115. pinecall/gateway/calls/threads.py +367 -0
  116. pinecall/gateway/dispatching/__init__.py +0 -0
  117. pinecall/gateway/dispatching/arrivals.py +58 -0
  118. pinecall/gateway/dispatching/sweep.py +43 -0
  119. pinecall/gateway/ending/__init__.py +0 -0
  120. pinecall/gateway/ending/reaper.py +160 -0
  121. pinecall/gateway/ending/seal.py +312 -0
  122. pinecall/gateway/ending/stranded.py +117 -0
  123. pinecall/log/__init__.py +0 -0
  124. pinecall/log/_histogram.py +74 -0
  125. pinecall/log/_relay.py +259 -0
  126. pinecall/log/_writer.py +454 -0
  127. pinecall/log/days.py +154 -0
  128. pinecall/log/drift.py +550 -0
  129. pinecall/log/facts.py +358 -0
  130. pinecall/log/inbox.py +169 -0
  131. pinecall/log/lists.py +155 -0
  132. pinecall/log/logs.py +581 -0
  133. pinecall/log/openings.py +71 -0
  134. pinecall/log/private.py +135 -0
  135. pinecall/log/queries.py +229 -0
  136. pinecall/log/readers.py +207 -0
  137. pinecall/log/reduce.py +659 -0
  138. pinecall/log/refold.py +217 -0
  139. pinecall/log/store.py +370 -0
  140. pinecall/log/usage.py +242 -0
  141. pinecall/postgres/__init__.py +0 -0
  142. pinecall/postgres/migrate.py +202 -0
  143. pinecall/postgres/migrations/0001_schema.sql +591 -0
  144. pinecall/postgres/migrations/0002_worlds.sql +15 -0
  145. pinecall/postgres/migrations/0003_lent.sql +4 -0
  146. pinecall/postgres/migrations/0004_carriers.sql +23 -0
  147. pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
  148. pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
  149. pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
  150. pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
  151. pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
  152. pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
  153. pinecall/postgres/migrations/0011_erasure.sql +32 -0
  154. pinecall/postgres/migrations/0012_org_policy.sql +10 -0
  155. pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
  156. pinecall/postgres/migrations/0014_consents.sql +24 -0
  157. pinecall/postgres/migrations/0015_disclosure.sql +7 -0
  158. pinecall/postgres/migrations/0016_call_records.sql +21 -0
  159. pinecall/postgres/migrations/0017_reads.sql +17 -0
  160. pinecall/postgres/migrations/0018_hosting.sql +41 -0
  161. pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
  162. pinecall/postgres/migrations/0020_written_once.sql +7 -0
  163. pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
  164. pinecall/postgres/migrations/0022_sealed.sql +39 -0
  165. pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
  166. pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
  167. pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
  168. pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
  169. pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
  170. pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
  171. pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
  172. pinecall/postgres/migrations/0060_drift.sql +62 -0
  173. pinecall/postgres/migrations/0061_canaries.sql +24 -0
  174. pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
  175. pinecall/postgres/migrations/0063_prompts.sql +16 -0
  176. pinecall/postgres/migrations/0075_call_private.sql +14 -0
  177. pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
  178. pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
  179. pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
  180. pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
  181. pinecall/postgres/migrations/0080_call_openings.sql +20 -0
  182. pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
  183. pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
  184. pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
  185. pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
  186. pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
  187. pinecall/postgres/migrations/0086_route_origin.sql +17 -0
  188. pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
  189. pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
  190. pinecall/postgres/migrations/0089_offers.sql +21 -0
  191. pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
  192. pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
  193. pinecall/postgres/migrations/0092_key_parent.sql +6 -0
  194. pinecall/postgres/migrations/0093_code_tries.sql +10 -0
  195. pinecall/postgres/migrations/0094_route_approval.sql +12 -0
  196. pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
  197. pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
  198. pinecall/postgres/migrations/0097_case_status.sql +19 -0
  199. pinecall/postgres/pool.py +190 -0
  200. pinecall/process/__init__.py +0 -0
  201. pinecall/process/_objects.py +97 -0
  202. pinecall/process/box_settings.py +23 -0
  203. pinecall/process/connections.py +124 -0
  204. pinecall/process/metrics.py +127 -0
  205. pinecall/process/recordings.py +323 -0
  206. pinecall/process/resolver.py +41 -0
  207. pinecall/process/sealed_audio.py +173 -0
  208. pinecall/process/settings.py +534 -0
  209. pinecall/process/shared.py +158 -0
  210. pinecall/process/signal.py +416 -0
  211. pinecall/providers/__init__.py +0 -0
  212. pinecall/providers/build.py +570 -0
  213. pinecall/providers/catalog.py +266 -0
  214. pinecall/providers/credentials.py +230 -0
  215. pinecall/providers/declared.py +69 -0
  216. pinecall/providers/prices.py +240 -0
  217. pinecall/providers/tried.py +104 -0
  218. pinecall/providers/voices.py +174 -0
  219. pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
  220. pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
  221. pinecall/public/console/favicon.png +0 -0
  222. pinecall/public/console/index.html +20 -0
  223. pinecall/public/console/logo-mark.png +0 -0
  224. pinecall/public/console/pinecall-logo-dark.png +0 -0
  225. pinecall/public/console/pinecall-logo.png +0 -0
  226. pinecall/public/console/pinecall-mark.png +0 -0
  227. pinecall/public/console/sw.js +37 -0
  228. pinecall/public/widget/pinecall-widget.js +853 -0
  229. pinecall/py.typed +0 -0
  230. pinecall/retrieval/__init__.py +0 -0
  231. pinecall/retrieval/_search.py +186 -0
  232. pinecall/retrieval/_sections.py +70 -0
  233. pinecall/retrieval/embed.py +340 -0
  234. pinecall/retrieval/extraction.py +403 -0
  235. pinecall/retrieval/knowledge.py +659 -0
  236. pinecall/retrieval/lookups.py +192 -0
  237. pinecall/retrieval/memory.py +603 -0
  238. pinecall/runner/__init__.py +0 -0
  239. pinecall/runner/_kube.py +459 -0
  240. pinecall/runner/_plan.py +157 -0
  241. pinecall/runner/main.py +306 -0
  242. pinecall/session/__init__.py +0 -0
  243. pinecall/session/_agent.py +141 -0
  244. pinecall/session/_hearing.py +101 -0
  245. pinecall/session/_livekit.py +195 -0
  246. pinecall/session/_prompt.py +147 -0
  247. pinecall/session/_recording.py +237 -0
  248. pinecall/session/call.py +367 -0
  249. pinecall/session/clock.py +40 -0
  250. pinecall/session/hold.py +239 -0
  251. pinecall/session/room.py +626 -0
  252. pinecall/session/session.py +690 -0
  253. pinecall/session/text.py +144 -0
  254. pinecall/session/tools.py +483 -0
  255. pinecall/session/voice.py +118 -0
  256. pinecall/session/widget.py +201 -0
  257. pinecall/tenancy/__init__.py +0 -0
  258. pinecall/tenancy/admission.py +204 -0
  259. pinecall/tenancy/agents.py +164 -0
  260. pinecall/tenancy/canary.py +126 -0
  261. pinecall/tenancy/carrier_networks.py +156 -0
  262. pinecall/tenancy/carriers.py +265 -0
  263. pinecall/tenancy/codes.py +196 -0
  264. pinecall/tenancy/consents.py +167 -0
  265. pinecall/tenancy/dial_policy.py +405 -0
  266. pinecall/tenancy/disclosure.py +41 -0
  267. pinecall/tenancy/erasure.py +204 -0
  268. pinecall/tenancy/export.py +167 -0
  269. pinecall/tenancy/hosted_running.py +166 -0
  270. pinecall/tenancy/hosting.py +475 -0
  271. pinecall/tenancy/judges.py +126 -0
  272. pinecall/tenancy/keys.py +467 -0
  273. pinecall/tenancy/knocks.py +40 -0
  274. pinecall/tenancy/letters.py +378 -0
  275. pinecall/tenancy/mail.py +458 -0
  276. pinecall/tenancy/org_secrets.py +123 -0
  277. pinecall/tenancy/orgs.py +78 -0
  278. pinecall/tenancy/people.py +543 -0
  279. pinecall/tenancy/personas.py +145 -0
  280. pinecall/tenancy/policy.py +65 -0
  281. pinecall/tenancy/prompts.py +48 -0
  282. pinecall/tenancy/reads.py +73 -0
  283. pinecall/tenancy/recording_keys.py +41 -0
  284. pinecall/tenancy/remembered.py +148 -0
  285. pinecall/tenancy/retention.py +86 -0
  286. pinecall/tenancy/scopes.py +406 -0
  287. pinecall/tenancy/signin.py +622 -0
  288. pinecall/tenancy/spend.py +69 -0
  289. pinecall/tenancy/sso.py +431 -0
  290. pinecall/tenancy/sso_domains.py +132 -0
  291. pinecall/tenancy/throttle.py +88 -0
  292. pinecall/tenancy/tokens.py +400 -0
  293. pinecall/tenancy/traceback.py +70 -0
  294. pinecall/tenancy/usage.py +136 -0
  295. pinecall/tenancy/vault.py +204 -0
  296. pinecall/tenancy/words.py +144 -0
  297. pinecall/wire/__init__.py +0 -0
  298. pinecall/wire/commands.py +350 -0
  299. pinecall/wire/events.py +655 -0
  300. pinecall/wire/frames.py +65 -0
  301. pinecall/wire/metrics.py +287 -0
  302. pinecall/wire/parts.py +320 -0
  303. pinecall/wire/rest/__init__.py +0 -0
  304. pinecall/wire/rest/accounts.py +512 -0
  305. pinecall/wire/rest/agents.py +172 -0
  306. pinecall/wire/rest/calls.py +397 -0
  307. pinecall/wire/rest/evals.py +453 -0
  308. pinecall/wire/rest/fleet.py +69 -0
  309. pinecall/wire/rest/hosting.py +174 -0
  310. pinecall/wire/rest/numbers.py +280 -0
  311. pinecall/wire/rest/ops.py +342 -0
  312. pinecall/wire/rest/providers.py +83 -0
  313. pinecall/wire/rest/retrieval.py +301 -0
  314. pinecall/wire/rest/settings.py +184 -0
  315. pinecall/wire/rest/usage.py +245 -0
  316. pinecall/wire/room.py +61 -0
  317. pinecall/wire/scores.py +49 -0
  318. pinecall/wire/state.py +243 -0
  319. pinecall/worker/__init__.py +0 -0
  320. pinecall/worker/_job.py +655 -0
  321. pinecall/worker/_recorder.py +82 -0
  322. pinecall/worker/_traces.py +48 -0
  323. pinecall/worker/main.py +307 -0
  324. pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
  325. pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
  326. pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
  327. pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
  328. pinecall_runtime-0.1.6.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,431 @@
1
+ """Sign-in with the org's identity provider over OpenID Connect: handshake, code, claims."""
2
+
3
+ import asyncio
4
+ import base64
5
+ import hashlib
6
+ import secrets
7
+ from dataclasses import dataclass
8
+
9
+ import httpx
10
+ import jwt
11
+ from cryptography.fernet import MultiFernet
12
+ from psycopg.rows import DictRow
13
+ from pydantic import BaseModel, ConfigDict, ValidationError
14
+
15
+ from pinecall.domain.errors import (
16
+ DeclarationRefused,
17
+ NotAllowed,
18
+ NotSignedIn,
19
+ UpstreamFailed,
20
+ )
21
+ from pinecall.domain.names import PRODUCTION
22
+ from pinecall.domain.org import Org
23
+ from pinecall.domain.person import ROLES, Member, Role
24
+ from pinecall.postgres.pool import Pool, box_wide
25
+ from pinecall.process import resolver
26
+ from pinecall.tenancy.admission import admit_seat, quotas_of
27
+ from pinecall.tenancy.people import (
28
+ Invitee,
29
+ by_email,
30
+ invite,
31
+ seated,
32
+ vouched,
33
+ )
34
+ from pinecall.tenancy.signin import (
35
+ A_DOMAIN,
36
+ ALGORITHMS,
37
+ AN_ISSUER,
38
+ ANOTHER_SIGN_IN,
39
+ CONFIGURATION,
40
+ DISABLED,
41
+ DROP_SSO,
42
+ LOCAL_SUFFIXES,
43
+ MISSING,
44
+ NO_EMAIL,
45
+ NO_GRANT,
46
+ NO_IDENTITY,
47
+ NO_KEY,
48
+ NOBODY_HERE,
49
+ NONCE_BYTES,
50
+ NOT_AN_ISSUER,
51
+ NOT_ITS_OWN_ISSUER,
52
+ NOT_REACHED,
53
+ NOT_THIS_CLIENTS,
54
+ NOT_VERIFIED,
55
+ PUT_SSO,
56
+ REFUSED,
57
+ REQUIRED,
58
+ SCOPE,
59
+ SSO,
60
+ SSO_WITH_DOMAIN,
61
+ TIMEOUT_S,
62
+ VERIFIER_BYTES,
63
+ Handshake,
64
+ OneUse,
65
+ an_address,
66
+ why_refused,
67
+ )
68
+ from pinecall.tenancy.vault import opened, sealed
69
+
70
+ # The provider is told to send the browser back here, and the org registers it there by hand.
71
+ CALLBACK = "/v1/login/sso/callback"
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class Client:
76
+ """An OpenID provider and this gateway's client at it."""
77
+
78
+ issuer: str
79
+ client_id: str
80
+ client_secret: str
81
+
82
+ def __post_init__(self) -> None:
83
+ if not AN_ISSUER.match(self.issuer):
84
+ raise DeclarationRefused(
85
+ f"an issuer is an https URL with no query and no trailing slash, not "
86
+ f"{self.issuer!r}: every id_token's `iss` is compared with it as a string"
87
+ )
88
+ if not self.client_id.strip() or not self.client_secret.strip():
89
+ raise DeclarationRefused("a provider's client carries its id and its secret")
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class OrgSso:
94
+ """An org's own identity provider, the domains it admits, and whom it seats unasked."""
95
+
96
+ org: str
97
+ client: Client
98
+ # Checked even when the provider vouches: a tenant of Entra can hold outside guests.
99
+ domains: tuple[str, ...]
100
+ # The role an uninvited person gets; None seats nobody uninvited.
101
+ role: Role | None = None
102
+ # No password opens the org; the operator's break-glass turns it off.
103
+ required: bool = False
104
+
105
+ def __post_init__(self) -> None:
106
+ if not self.domains:
107
+ raise DeclarationRefused("an SSO configuration names at least one email domain")
108
+ for domain in self.domains:
109
+ if not A_DOMAIN.match(domain):
110
+ raise DeclarationRefused(f"{domain!r} is not an email domain, folded and bare")
111
+
112
+ def admits(self, email: str) -> bool:
113
+ """Whether the address is of one of the org's domains."""
114
+ return email.rpartition("@")[2].strip().lower() in self.domains
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class Provider:
119
+ """The endpoints a provider's discovery names."""
120
+
121
+ issuer: str
122
+ authorization_endpoint: str
123
+ token_endpoint: str
124
+ jwks_uri: str
125
+ # client_secret_post unless the provider takes only client_secret_basic.
126
+ basic_auth: bool = False
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class Claims:
131
+ """Who a provider vouched for, from an id_token that checked out."""
132
+
133
+ subject: str
134
+ email: str
135
+ email_verified: bool
136
+ name: str | None
137
+
138
+
139
+ class _Discovery(BaseModel):
140
+ model_config = ConfigDict(extra="ignore")
141
+
142
+ issuer: str = ""
143
+ authorization_endpoint: str = ""
144
+ token_endpoint: str = ""
145
+ jwks_uri: str = ""
146
+ token_endpoint_auth_methods_supported: list[str] = []
147
+
148
+
149
+ class _Granted(BaseModel):
150
+ model_config = ConfigDict(extra="ignore")
151
+
152
+ id_token: str = ""
153
+
154
+
155
+ class _IdToken(BaseModel):
156
+ model_config = ConfigDict(extra="ignore")
157
+
158
+ sub: str
159
+ aud: str | list[str]
160
+ azp: str | None = None
161
+ nonce: str = ""
162
+ email: str = ""
163
+ email_verified: bool | str | None = None
164
+ name: str | None = None
165
+ given_name: str | None = None
166
+
167
+
168
+ async def sso_of(pool: Pool, vault: MultiFernet, org: str) -> OrgSso | None:
169
+ """The org's own provider, its secret opened."""
170
+ async with pool.connection() as connection:
171
+ row = await (await connection.execute(SSO, {"org": org})).fetchone()
172
+ return None if row is None else _sso(vault, row)
173
+
174
+
175
+ # The one read across orgs: the caller shows org names only.
176
+ async def sso_with_domain(pool: Pool, vault: MultiFernet, domain: str) -> list[OrgSso]:
177
+ """Every org that signs in the domain's addresses with its own provider, oldest first."""
178
+ with box_wide():
179
+ async with pool.connection() as connection:
180
+ found = await connection.execute(SSO_WITH_DOMAIN, {"domain": domain})
181
+ rows = await found.fetchall()
182
+ return [found for row in rows if (found := _sso(vault, row)) is not None]
183
+
184
+
185
+ async def put_sso(pool: Pool, vault: MultiFernet, sso: OrgSso) -> None:
186
+ """Keep the org's provider, the secret sealed."""
187
+ values = {
188
+ "org": sso.org,
189
+ "issuer": sso.client.issuer,
190
+ "client_id": sso.client.client_id,
191
+ "ciphertext": sealed(vault, sso.client.client_secret),
192
+ "domains": list(sso.domains),
193
+ "role": sso.role,
194
+ "required": sso.required,
195
+ }
196
+ async with pool.connection() as connection:
197
+ await connection.execute(PUT_SSO, values)
198
+
199
+
200
+ async def drop_sso(pool: Pool, org: str) -> bool:
201
+ """Forget the org's provider; passwords open it again. Whether it had one."""
202
+ async with pool.connection() as connection:
203
+ return await (await connection.execute(DROP_SSO, {"org": org})).fetchone() is not None
204
+
205
+
206
+ async def handshake(
207
+ states: OneUse[Handshake],
208
+ redirect_uri: str,
209
+ *,
210
+ org: str | None = None,
211
+ pairing: str | None = None,
212
+ ) -> Handshake:
213
+ """A sign-in at a provider begun: a fresh state, nonce and PKCE verifier, kept here."""
214
+ begun = Handshake(
215
+ state=states.word(),
216
+ org=org,
217
+ nonce=secrets.token_urlsafe(NONCE_BYTES),
218
+ verifier=secrets.token_urlsafe(VERIFIER_BYTES),
219
+ redirect_uri=redirect_uri,
220
+ pairing=pairing,
221
+ )
222
+ await states.keep(begun.state, begun)
223
+ return begun
224
+
225
+
226
+ def reachable(url: str, *, issuer: str, field: str) -> str:
227
+ """The URL, when it is https at a public name; refused otherwise, naming the field."""
228
+ try:
229
+ parsed = httpx.URL(url)
230
+ except httpx.InvalidURL:
231
+ raise NotAllowed(NOT_REACHED.format(issuer=issuer, field=field, url=url)) from None
232
+ host = parsed.host.lower().rstrip(".")
233
+ private = host in {"localhost", ""} or host.endswith(LOCAL_SUFFIXES) or an_address(host)
234
+ if parsed.scheme != "https" or private:
235
+ raise NotAllowed(NOT_REACHED.format(issuer=issuer, field=field, url=url))
236
+ return url
237
+
238
+
239
+ async def discovered(http: httpx.AsyncClient, issuer: str) -> Provider:
240
+ """The provider's endpoints, from its own discovery document."""
241
+ url = f"{await _resolves_public(issuer, issuer=issuer, field='issuer')}{CONFIGURATION}"
242
+ try:
243
+ answer = await http.get(url, timeout=TIMEOUT_S)
244
+ answer.raise_for_status()
245
+ payload = _Discovery.model_validate(answer.json())
246
+ except (httpx.HTTPError, ValueError, ValidationError):
247
+ raise UpstreamFailed(NOT_AN_ISSUER.format(issuer=issuer, path=CONFIGURATION)) from None
248
+ if payload.issuer != issuer:
249
+ raise UpstreamFailed(NOT_ITS_OWN_ISSUER.format(issuer=issuer, said=payload.issuer))
250
+ methods = payload.token_endpoint_auth_methods_supported
251
+ return Provider(
252
+ issuer=issuer,
253
+ authorization_endpoint=_endpoint(
254
+ payload.authorization_endpoint, issuer, "authorization_endpoint"
255
+ ),
256
+ token_endpoint=_endpoint(payload.token_endpoint, issuer, "token_endpoint"),
257
+ jwks_uri=_endpoint(payload.jwks_uri, issuer, "jwks_uri"),
258
+ basic_auth="client_secret_basic" in methods and "client_secret_post" not in methods,
259
+ )
260
+
261
+
262
+ def authorization_url(provider: Provider, client: Client, begun: Handshake) -> str:
263
+ """Where the browser goes to sign in at the provider, with the state and the challenge."""
264
+ challenge = base64.urlsafe_b64encode(hashlib.sha256(begun.verifier.encode()).digest())
265
+ params = httpx.QueryParams(
266
+ {
267
+ "response_type": "code",
268
+ "client_id": client.client_id,
269
+ "redirect_uri": begun.redirect_uri,
270
+ "scope": SCOPE,
271
+ "state": begun.state,
272
+ "nonce": begun.nonce,
273
+ "code_challenge": challenge.decode().rstrip("="),
274
+ "code_challenge_method": "S256",
275
+ }
276
+ )
277
+ return str(httpx.URL(provider.authorization_endpoint).copy_merge_params(params))
278
+
279
+
280
+ async def vouched_for(
281
+ http: httpx.AsyncClient, client: Client, begun: Handshake, code: str
282
+ ) -> Claims:
283
+ """The person the provider signed in, their address verified, from the code it returned."""
284
+ provider = await discovered(http, client.issuer)
285
+ id_token = await _exchanged(http, provider, client, begun, code)
286
+ data = await _claims(http, provider, id_token, client.client_id, begun.nonce)
287
+ if not data.email:
288
+ raise NotSignedIn(NO_EMAIL.format(issuer=client.issuer))
289
+ # An unverified address would let anybody claim a colleague's.
290
+ if not data.email_verified:
291
+ raise NotSignedIn(NOT_VERIFIED.format(issuer=client.issuer, email=data.email))
292
+ return data
293
+
294
+
295
+ async def seat_vouched(pool: Pool, org: Org, sso: OrgSso, claims: Claims) -> Member:
296
+ """The org's member for the address its provider vouched for, seated, invited or made."""
297
+ email = claims.email.strip().lower()
298
+ kept = await by_email(pool, org.id, email)
299
+ if kept is not None and kept.status == "disabled":
300
+ raise NotAllowed(DISABLED.format(email=email, org=org.slug))
301
+ if kept is None:
302
+ if sso.role is None:
303
+ raise NotAllowed(NOBODY_HERE.format(org=org.slug, email=email))
304
+ # A person the provider seats unasked takes a seat like an invited one.
305
+ seats = (await quotas_of(pool, org.id, PRODUCTION)).seats
306
+ await admit_seat(pool, org.id, PRODUCTION, seated=await seated(pool, org.id))
307
+ name = claims.name or email.partition("@")[0]
308
+ kept = (await invite(pool, org.id, Invitee(email, name, sso.role), seats=seats)).member
309
+ seated_now = await vouched(pool, org.id, kept.id)
310
+ if seated_now is None:
311
+ raise NotAllowed(NOBODY_HERE.format(org=org.slug, email=email))
312
+ return seated_now
313
+
314
+
315
+ # Asked before each request: a public name that resolves inside (10.x, a cluster's service) is
316
+ # refused like the address itself, and so is a name that resolves to nothing, which a hostile
317
+ # resolver could answer otherwise a moment later.
318
+ async def _resolves_public(url: str, *, issuer: str, field: str) -> str:
319
+ """The URL, when every address its name resolves to is a public one; refused otherwise."""
320
+ host = httpx.URL(reachable(url, issuer=issuer, field=field)).host
321
+ try:
322
+ addresses = await asyncio.to_thread(resolver.addresses_of, host)
323
+ except OSError:
324
+ addresses = []
325
+ if not resolver.all_public(addresses):
326
+ raise NotAllowed(NOT_REACHED.format(issuer=issuer, field=field, url=url))
327
+ return url
328
+
329
+
330
+ def _sso(vault: MultiFernet, row: DictRow) -> OrgSso | None:
331
+ secret = opened(vault, row["ciphertext"])
332
+ if not isinstance(secret, str):
333
+ return None
334
+ role = row["role"]
335
+ return OrgSso(
336
+ org=row["org"],
337
+ client=Client(issuer=row["issuer"], client_id=row["client_id"], client_secret=secret),
338
+ domains=tuple(row["domains"]),
339
+ role=role if role in ROLES else None,
340
+ required=row["required"],
341
+ )
342
+
343
+
344
+ def _endpoint(found_url: str, issuer: str, field: str) -> str:
345
+ if not found_url:
346
+ raise UpstreamFailed(MISSING.format(issuer=issuer, field=field))
347
+ return reachable(found_url, issuer=issuer, field=field)
348
+
349
+
350
+ async def _exchanged(
351
+ http: httpx.AsyncClient, provider: Provider, client: Client, begun: Handshake, code: str
352
+ ) -> str:
353
+ form = {
354
+ "grant_type": "authorization_code",
355
+ "code": code,
356
+ "redirect_uri": begun.redirect_uri,
357
+ "client_id": client.client_id,
358
+ "code_verifier": begun.verifier,
359
+ }
360
+ if not provider.basic_auth:
361
+ form["client_secret"] = client.client_secret
362
+ await _resolves_public(provider.token_endpoint, issuer=provider.issuer, field="token_endpoint")
363
+ try:
364
+ answer = await http.post(
365
+ provider.token_endpoint,
366
+ data=form,
367
+ auth=(client.client_id, client.client_secret)
368
+ if provider.basic_auth
369
+ else httpx.USE_CLIENT_DEFAULT,
370
+ timeout=TIMEOUT_S,
371
+ )
372
+ except httpx.HTTPError as unreachable:
373
+ raise UpstreamFailed(NO_GRANT.format(issuer=provider.issuer, said=unreachable)) from None
374
+ if answer.status_code >= httpx.codes.BAD_REQUEST:
375
+ raise NotSignedIn(NO_GRANT.format(issuer=provider.issuer, said=why_refused(answer)))
376
+ try:
377
+ granted = _Granted.model_validate(answer.json())
378
+ except (ValueError, ValidationError):
379
+ raise UpstreamFailed(NO_IDENTITY.format(issuer=provider.issuer)) from None
380
+ if not granted.id_token:
381
+ raise UpstreamFailed(NO_IDENTITY.format(issuer=provider.issuer))
382
+ return granted.id_token
383
+
384
+
385
+ async def _claims(
386
+ http: httpx.AsyncClient, provider: Provider, id_token: str, client_id: str, nonce: str
387
+ ) -> Claims:
388
+ key = await _signing_key(http, provider, id_token)
389
+ try:
390
+ decoded = jwt.decode(
391
+ id_token,
392
+ key=key,
393
+ algorithms=ALGORITHMS,
394
+ audience=client_id,
395
+ issuer=provider.issuer,
396
+ options={"require": REQUIRED},
397
+ )
398
+ data = _IdToken.model_validate(decoded)
399
+ except (jwt.InvalidTokenError, ValidationError) as refused:
400
+ raise NotSignedIn(REFUSED.format(said=refused)) from None
401
+ # The nonce binds the token to this sign-in; without it, a token replayed would pass.
402
+ if not secrets.compare_digest(data.nonce, nonce):
403
+ raise NotSignedIn(ANOTHER_SIGN_IN)
404
+ # OIDC Core 3.1.3.7: with several audiences `azp` is required, and it names this client.
405
+ if isinstance(data.aud, list) and len(data.aud) > 1 and data.azp is None:
406
+ raise NotSignedIn(NOT_THIS_CLIENTS.format(azp=None, client_id=client_id))
407
+ if data.azp is not None and data.azp != client_id:
408
+ raise NotSignedIn(NOT_THIS_CLIENTS.format(azp=data.azp, client_id=client_id))
409
+ verified = data.email_verified is True or str(data.email_verified).lower() == "true"
410
+ return Claims(
411
+ subject=data.sub,
412
+ email=data.email,
413
+ email_verified=verified,
414
+ name=data.name or data.given_name,
415
+ )
416
+
417
+
418
+ async def _signing_key(http: httpx.AsyncClient, provider: Provider, id_token: str) -> jwt.PyJWK:
419
+ await _resolves_public(provider.jwks_uri, issuer=provider.issuer, field="jwks_uri")
420
+ try:
421
+ answer = await http.get(provider.jwks_uri, timeout=TIMEOUT_S)
422
+ answer.raise_for_status()
423
+ published = jwt.PyJWKSet.from_dict(answer.json())
424
+ kid = jwt.get_unverified_header(id_token).get("kid")
425
+ except (httpx.HTTPError, ValueError, jwt.PyJWTError):
426
+ raise UpstreamFailed(NO_KEY.format(issuer=provider.issuer)) from None
427
+ # A token that names no key is checked against the one key published, when there is one.
428
+ matching = [key for key in published.keys if kid is None or key.key_id == kid]
429
+ if len(matching) != 1:
430
+ raise UpstreamFailed(NO_KEY.format(issuer=provider.issuer))
431
+ return matching[0]
@@ -0,0 +1,132 @@
1
+ """The proof that a domain an org's SSO admits is the org's: a TXT record the org publishes."""
2
+
3
+ import asyncio
4
+ import secrets
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+
9
+ from psycopg.rows import DictRow
10
+
11
+ from pinecall.domain.errors import DeclarationRefused, NotFound
12
+ from pinecall.postgres.pool import Pool
13
+ from pinecall.process import resolver
14
+
15
+ # The record's name, as the admin types it into their DNS: `pinecall-verify=<token>` at the domain.
16
+ TXT_PREFIX = "pinecall-verify="
17
+
18
+
19
+ PROOF_BYTES = 16
20
+
21
+
22
+ NOT_A_DOMAIN_OF_YOURS = "{domain} is not a domain of this org's SSO: PUT /v1/org/sso names them"
23
+
24
+
25
+ NOT_FOUND_AT = (
26
+ "no TXT record `{txt}` at {domain}: publish it there (what is there now: {found}), then ask "
27
+ "again; a record takes minutes to hours to be seen, by its TTL"
28
+ )
29
+
30
+
31
+ ASK = """
32
+ INSERT INTO sso_domain_proofs (org, domain, token) VALUES (%(org)s, %(domain)s, %(token)s)
33
+ ON CONFLICT (org, domain) DO NOTHING
34
+ """
35
+
36
+
37
+ FORGET = """
38
+ DELETE FROM sso_domain_proofs
39
+ WHERE org = %(org)s AND NOT (domain = ANY(%(domains)s::text[]))
40
+ """
41
+
42
+
43
+ OF_ORG = """
44
+ SELECT org, domain, token, verified_at FROM sso_domain_proofs
45
+ WHERE org = %(org)s ORDER BY asked_at, domain
46
+ """
47
+
48
+
49
+ ONE = """
50
+ SELECT org, domain, token, verified_at FROM sso_domain_proofs
51
+ WHERE org = %(org)s AND domain = %(domain)s
52
+ """
53
+
54
+
55
+ VERIFIED = """
56
+ UPDATE sso_domain_proofs SET verified_at = COALESCE(verified_at, now())
57
+ WHERE org = %(org)s AND domain = %(domain)s
58
+ RETURNING org, domain, token, verified_at
59
+ """
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class Proof:
64
+ """One domain of an org's SSO: the record that proves it, and whether it was seen."""
65
+
66
+ org: str
67
+ domain: str
68
+ token: str
69
+ verified_at: datetime | None
70
+
71
+ @property
72
+ def txt(self) -> str:
73
+ """The TXT record the org publishes at the domain."""
74
+ return f"{TXT_PREFIX}{self.token}"
75
+
76
+ @property
77
+ def verified(self) -> bool:
78
+ """Whether the record was seen at the domain."""
79
+ return self.verified_at is not None
80
+
81
+
82
+ async def proofs_for(pool: Pool, org: str, domains: Sequence[str]) -> list[Proof]:
83
+ """A proof for each domain named, the ones kept standing, the rest forgotten; all of them."""
84
+ params = {"org": org, "domains": list(domains)}
85
+ async with pool.connection() as connection, connection.transaction():
86
+ for domain in domains:
87
+ token = secrets.token_urlsafe(PROOF_BYTES)
88
+ await connection.execute(ASK, {"org": org, "domain": domain, "token": token})
89
+ await connection.execute(FORGET, params)
90
+ rows = await (await connection.execute(OF_ORG, params)).fetchall()
91
+ return [_proof(row) for row in rows]
92
+
93
+
94
+ async def of_org(pool: Pool, org: str) -> list[Proof]:
95
+ """Every domain of the org's SSO with its proof, oldest first."""
96
+ async with pool.connection() as connection:
97
+ rows = await (await connection.execute(OF_ORG, {"org": org})).fetchall()
98
+ return [_proof(row) for row in rows]
99
+
100
+
101
+ async def verified_domains(pool: Pool, org: str) -> frozenset[str]:
102
+ """The domains of the org's SSO whose record was seen."""
103
+ return frozenset(proof.domain for proof in await of_org(pool, org) if proof.verified)
104
+
105
+
106
+ # Read at the domain itself, where every DNS panel lets a TXT be added; a record once seen stands.
107
+ async def verify(pool: Pool, org: str, domain: str) -> Proof:
108
+ """Look for the domain's record now; the proof verified, or refused saying what was found."""
109
+ async with pool.connection() as connection:
110
+ row = await (await connection.execute(ONE, {"org": org, "domain": domain})).fetchone()
111
+ if row is None:
112
+ raise NotFound(NOT_A_DOMAIN_OF_YOURS.format(domain=domain))
113
+ proof = _proof(row)
114
+ if proof.verified:
115
+ return proof
116
+ records = await asyncio.to_thread(resolver.txt_of, domain)
117
+ if proof.txt not in records:
118
+ ours = [record for record in records if record.startswith(TXT_PREFIX)]
119
+ found = ", ".join(f"`{record}`" for record in ours) or "no pinecall-verify record"
120
+ raise DeclarationRefused(NOT_FOUND_AT.format(txt=proof.txt, domain=domain, found=found))
121
+ async with pool.connection() as connection:
122
+ seen = await (await connection.execute(VERIFIED, {"org": org, "domain": domain})).fetchone()
123
+ return _proof(seen) if seen is not None else proof
124
+
125
+
126
+ def _proof(row: DictRow) -> Proof:
127
+ return Proof(
128
+ org=str(row["org"]),
129
+ domain=str(row["domain"]),
130
+ token=str(row["token"]),
131
+ verified_at=row["verified_at"],
132
+ )
@@ -0,0 +1,88 @@
1
+ """How many requests an org sent a family of doors this minute, and whether one more is let in."""
2
+
3
+ import time
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass, field
6
+
7
+ from pydantic import TypeAdapter
8
+
9
+ from pinecall.process.shared import Shared
10
+ from pinecall.process.signal import LocalSignal, Signal
11
+
12
+ # Requests a minute an org may send one family of doors in one world. Generous on purpose: the
13
+ # console polls its busiest screen every second and the rest every 3 to 60 s, so fifty tabs open
14
+ # at once stay under half of it. It is a wall against a runaway client, not a plan's limit.
15
+ REQUESTS_A_MINUTE = 6000
16
+
17
+ MINUTE_S = 60
18
+
19
+ # Names of minutes gone by are dropped once this many are kept.
20
+ SWEEP_AT = 4096
21
+
22
+ # Each gateway says its busy names every second; a name under this many requests here is not
23
+ # said, so the wall is late by at most this many a gateway, and the share stays small.
24
+ PACED_CHANNEL = "paced"
25
+ PACED_EVERY_S = 1.0
26
+ SAID_FROM = 100
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Paced:
31
+ """One gateway's busy names this minute, and how many requests each sent it."""
32
+
33
+ minute: int = 0
34
+ counts: dict[str, int] = field(default_factory=dict[str, int])
35
+
36
+
37
+ # Counted here, and summed with what the other gateways said this minute: a request pays no round
38
+ # trip, and a name spread over N gateways meets the wall within a second of reaching it.
39
+ class Window:
40
+ """Requests counted per name in the clock's current minute, across the box's gateways."""
41
+
42
+ def __init__(
43
+ self, clock: Callable[[], float] = time.time, signal: Signal | None = None
44
+ ) -> None:
45
+ """A window nobody knocked at."""
46
+ self.clock = clock
47
+ self.counts: dict[str, tuple[int, int]] = {}
48
+ self.shared = Shared(signal or LocalSignal(), PACED_CHANNEL, _PACED, Paced(), lambda: None)
49
+ self.shared.every_s = PACED_EVERY_S
50
+
51
+ async def start(self) -> None:
52
+ """Hear the other gateways' busy names, and say this one's every second."""
53
+ await self.shared.start()
54
+
55
+ async def close(self) -> None:
56
+ """Stop hearing and saying."""
57
+ await self.shared.close()
58
+
59
+ async def counted(self, name: str, limit: int) -> float | None:
60
+ """Count a request of the name: None within the limit, else the seconds until it resets."""
61
+ now = self.clock()
62
+ minute = int(now // MINUTE_S)
63
+ if len(self.counts) >= SWEEP_AT:
64
+ self.counts = {kept: count for kept, count in self.counts.items() if count[0] == minute}
65
+ was, count = self.counts.get(name, (minute, 0))
66
+ count = count + 1 if was == minute else 1
67
+ self.counts[name] = (minute, count)
68
+ if count % SAID_FROM == 0:
69
+ self._keep_saying(minute)
70
+ elsewhere = sum(
71
+ heard.share.counts.get(name, 0)
72
+ for heard in self.shared.theirs.values()
73
+ if heard.share.minute == minute
74
+ )
75
+ if count + elsewhere <= limit:
76
+ return None
77
+ return (minute + 1) * MINUTE_S - now
78
+
79
+ def _keep_saying(self, minute: int) -> None:
80
+ busy = {
81
+ name: count
82
+ for name, (was, count) in self.counts.items()
83
+ if was == minute and count >= SAID_FROM
84
+ }
85
+ self.shared.mine = Paced(minute, busy)
86
+
87
+
88
+ _PACED: TypeAdapter[Paced] = TypeAdapter(Paced)