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.
- pinecall/__init__.py +0 -0
- pinecall/channels/__init__.py +0 -0
- pinecall/channels/_chooser.py +45 -0
- pinecall/channels/offers.py +219 -0
- pinecall/channels/rooms.py +176 -0
- pinecall/channels/routes.py +275 -0
- pinecall/channels/telephony/__init__.py +0 -0
- pinecall/channels/telephony/_twilio.py +349 -0
- pinecall/channels/telephony/carrier.py +229 -0
- pinecall/channels/telephony/carrier_catalog.py +92 -0
- pinecall/channels/telephony/carriers.csv +41 -0
- pinecall/channels/telephony/dialing.py +419 -0
- pinecall/channels/telephony/firewall.py +58 -0
- pinecall/channels/telephony/hand_over.py +78 -0
- pinecall/channels/telephony/number_path.py +176 -0
- pinecall/channels/telephony/numbers.py +530 -0
- pinecall/channels/telephony/origination.py +77 -0
- pinecall/channels/telephony/sip.py +448 -0
- pinecall/channels/whatsapp.py +413 -0
- pinecall/cli/__init__.py +0 -0
- pinecall/cli/_archive.py +85 -0
- pinecall/cli/_drift.py +38 -0
- pinecall/cli/_facts.py +67 -0
- pinecall/cli/_load.py +433 -0
- pinecall/cli/_operator.py +626 -0
- pinecall/cli/_sessions.py +164 -0
- pinecall/cli/_traceback.py +95 -0
- pinecall/cli/_usage.py +34 -0
- pinecall/cli/main.py +521 -0
- pinecall/domain/__init__.py +0 -0
- pinecall/domain/agent.py +446 -0
- pinecall/domain/call.py +152 -0
- pinecall/domain/country_codes.py +32 -0
- pinecall/domain/errors.py +151 -0
- pinecall/domain/names.py +115 -0
- pinecall/domain/org.py +120 -0
- pinecall/domain/person.py +208 -0
- pinecall/domain/scope.py +21 -0
- pinecall/evals/__init__.py +0 -0
- pinecall/evals/_evidence.py +158 -0
- pinecall/evals/calibration.py +112 -0
- pinecall/evals/callers.py +160 -0
- pinecall/evals/case.py +327 -0
- pinecall/evals/checks.py +437 -0
- pinecall/evals/compliance.py +197 -0
- pinecall/evals/dataset.py +451 -0
- pinecall/evals/goldens.py +331 -0
- pinecall/evals/judges.py +525 -0
- pinecall/evals/runs.py +252 -0
- pinecall/evals/spoken.py +356 -0
- pinecall/fleet/__init__.py +0 -0
- pinecall/fleet/client.py +569 -0
- pinecall/fleet/demand.py +51 -0
- pinecall/fleet/heartbeat.py +194 -0
- pinecall/fleet/measures.py +157 -0
- pinecall/fleet/roster.py +232 -0
- pinecall/fleet/worlds.py +44 -0
- pinecall/gateway/__init__.py +0 -0
- pinecall/gateway/_call_setup.py +53 -0
- pinecall/gateway/_deps.py +556 -0
- pinecall/gateway/_gateway.py +75 -0
- pinecall/gateway/_served.py +396 -0
- pinecall/gateway/_sockets.py +444 -0
- pinecall/gateway/_streams.py +83 -0
- pinecall/gateway/_text_calls.py +182 -0
- pinecall/gateway/api/__init__.py +0 -0
- pinecall/gateway/api/accounts.py +324 -0
- pinecall/gateway/api/agents.py +150 -0
- pinecall/gateway/api/apps.py +478 -0
- pinecall/gateway/api/box.py +658 -0
- pinecall/gateway/api/calibration.py +58 -0
- pinecall/gateway/api/callbacks.py +105 -0
- pinecall/gateway/api/calls.py +655 -0
- pinecall/gateway/api/chat.py +223 -0
- pinecall/gateway/api/dataset.py +163 -0
- pinecall/gateway/api/desk.py +113 -0
- pinecall/gateway/api/entries.py +77 -0
- pinecall/gateway/api/evals.py +647 -0
- pinecall/gateway/api/fleet.py +73 -0
- pinecall/gateway/api/hosting.py +241 -0
- pinecall/gateway/api/judges.py +97 -0
- pinecall/gateway/api/keys.py +110 -0
- pinecall/gateway/api/line.py +142 -0
- pinecall/gateway/api/members.py +197 -0
- pinecall/gateway/api/metrics.py +226 -0
- pinecall/gateway/api/numbers.py +476 -0
- pinecall/gateway/api/ops.py +428 -0
- pinecall/gateway/api/org.py +283 -0
- pinecall/gateway/api/personas.py +157 -0
- pinecall/gateway/api/pipeline.py +206 -0
- pinecall/gateway/api/prompts.py +50 -0
- pinecall/gateway/api/providers.py +240 -0
- pinecall/gateway/api/recordings.py +89 -0
- pinecall/gateway/api/relay.py +169 -0
- pinecall/gateway/api/retrieval.py +403 -0
- pinecall/gateway/api/runner.py +109 -0
- pinecall/gateway/api/sessions.py +102 -0
- pinecall/gateway/api/settings.py +463 -0
- pinecall/gateway/api/signup.py +164 -0
- pinecall/gateway/api/sso_login.py +168 -0
- pinecall/gateway/api/threads.py +187 -0
- pinecall/gateway/api/usage.py +303 -0
- pinecall/gateway/api/visitors.py +222 -0
- pinecall/gateway/api/whatsapp.py +48 -0
- pinecall/gateway/api/widget.py +49 -0
- pinecall/gateway/app.py +504 -0
- pinecall/gateway/calls/__init__.py +0 -0
- pinecall/gateway/calls/binding.py +63 -0
- pinecall/gateway/calls/commands.py +18 -0
- pinecall/gateway/calls/inbox.py +100 -0
- pinecall/gateway/calls/known.py +23 -0
- pinecall/gateway/calls/owners.py +87 -0
- pinecall/gateway/calls/pump.py +33 -0
- pinecall/gateway/calls/serving.py +118 -0
- pinecall/gateway/calls/threads.py +367 -0
- pinecall/gateway/dispatching/__init__.py +0 -0
- pinecall/gateway/dispatching/arrivals.py +58 -0
- pinecall/gateway/dispatching/sweep.py +43 -0
- pinecall/gateway/ending/__init__.py +0 -0
- pinecall/gateway/ending/reaper.py +160 -0
- pinecall/gateway/ending/seal.py +312 -0
- pinecall/gateway/ending/stranded.py +117 -0
- pinecall/log/__init__.py +0 -0
- pinecall/log/_histogram.py +74 -0
- pinecall/log/_relay.py +259 -0
- pinecall/log/_writer.py +454 -0
- pinecall/log/days.py +154 -0
- pinecall/log/drift.py +550 -0
- pinecall/log/facts.py +358 -0
- pinecall/log/inbox.py +169 -0
- pinecall/log/lists.py +155 -0
- pinecall/log/logs.py +581 -0
- pinecall/log/openings.py +71 -0
- pinecall/log/private.py +135 -0
- pinecall/log/queries.py +229 -0
- pinecall/log/readers.py +207 -0
- pinecall/log/reduce.py +659 -0
- pinecall/log/refold.py +217 -0
- pinecall/log/store.py +370 -0
- pinecall/log/usage.py +242 -0
- pinecall/postgres/__init__.py +0 -0
- pinecall/postgres/migrate.py +202 -0
- pinecall/postgres/migrations/0001_schema.sql +591 -0
- pinecall/postgres/migrations/0002_worlds.sql +15 -0
- pinecall/postgres/migrations/0003_lent.sql +4 -0
- pinecall/postgres/migrations/0004_carriers.sql +23 -0
- pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
- pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
- pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
- pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
- pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
- pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
- pinecall/postgres/migrations/0011_erasure.sql +32 -0
- pinecall/postgres/migrations/0012_org_policy.sql +10 -0
- pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
- pinecall/postgres/migrations/0014_consents.sql +24 -0
- pinecall/postgres/migrations/0015_disclosure.sql +7 -0
- pinecall/postgres/migrations/0016_call_records.sql +21 -0
- pinecall/postgres/migrations/0017_reads.sql +17 -0
- pinecall/postgres/migrations/0018_hosting.sql +41 -0
- pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
- pinecall/postgres/migrations/0020_written_once.sql +7 -0
- pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
- pinecall/postgres/migrations/0022_sealed.sql +39 -0
- pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
- pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
- pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
- pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
- pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
- pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
- pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
- pinecall/postgres/migrations/0060_drift.sql +62 -0
- pinecall/postgres/migrations/0061_canaries.sql +24 -0
- pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
- pinecall/postgres/migrations/0063_prompts.sql +16 -0
- pinecall/postgres/migrations/0075_call_private.sql +14 -0
- pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
- pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
- pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
- pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
- pinecall/postgres/migrations/0080_call_openings.sql +20 -0
- pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
- pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
- pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
- pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
- pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
- pinecall/postgres/migrations/0086_route_origin.sql +17 -0
- pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
- pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
- pinecall/postgres/migrations/0089_offers.sql +21 -0
- pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
- pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
- pinecall/postgres/migrations/0092_key_parent.sql +6 -0
- pinecall/postgres/migrations/0093_code_tries.sql +10 -0
- pinecall/postgres/migrations/0094_route_approval.sql +12 -0
- pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
- pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
- pinecall/postgres/migrations/0097_case_status.sql +19 -0
- pinecall/postgres/pool.py +190 -0
- pinecall/process/__init__.py +0 -0
- pinecall/process/_objects.py +97 -0
- pinecall/process/box_settings.py +23 -0
- pinecall/process/connections.py +124 -0
- pinecall/process/metrics.py +127 -0
- pinecall/process/recordings.py +323 -0
- pinecall/process/resolver.py +41 -0
- pinecall/process/sealed_audio.py +173 -0
- pinecall/process/settings.py +534 -0
- pinecall/process/shared.py +158 -0
- pinecall/process/signal.py +416 -0
- pinecall/providers/__init__.py +0 -0
- pinecall/providers/build.py +570 -0
- pinecall/providers/catalog.py +266 -0
- pinecall/providers/credentials.py +230 -0
- pinecall/providers/declared.py +69 -0
- pinecall/providers/prices.py +240 -0
- pinecall/providers/tried.py +104 -0
- pinecall/providers/voices.py +174 -0
- pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
- pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
- pinecall/public/console/favicon.png +0 -0
- pinecall/public/console/index.html +20 -0
- pinecall/public/console/logo-mark.png +0 -0
- pinecall/public/console/pinecall-logo-dark.png +0 -0
- pinecall/public/console/pinecall-logo.png +0 -0
- pinecall/public/console/pinecall-mark.png +0 -0
- pinecall/public/console/sw.js +37 -0
- pinecall/public/widget/pinecall-widget.js +853 -0
- pinecall/py.typed +0 -0
- pinecall/retrieval/__init__.py +0 -0
- pinecall/retrieval/_search.py +186 -0
- pinecall/retrieval/_sections.py +70 -0
- pinecall/retrieval/embed.py +340 -0
- pinecall/retrieval/extraction.py +403 -0
- pinecall/retrieval/knowledge.py +659 -0
- pinecall/retrieval/lookups.py +192 -0
- pinecall/retrieval/memory.py +603 -0
- pinecall/runner/__init__.py +0 -0
- pinecall/runner/_kube.py +459 -0
- pinecall/runner/_plan.py +157 -0
- pinecall/runner/main.py +306 -0
- pinecall/session/__init__.py +0 -0
- pinecall/session/_agent.py +141 -0
- pinecall/session/_hearing.py +101 -0
- pinecall/session/_livekit.py +195 -0
- pinecall/session/_prompt.py +147 -0
- pinecall/session/_recording.py +237 -0
- pinecall/session/call.py +367 -0
- pinecall/session/clock.py +40 -0
- pinecall/session/hold.py +239 -0
- pinecall/session/room.py +626 -0
- pinecall/session/session.py +690 -0
- pinecall/session/text.py +144 -0
- pinecall/session/tools.py +483 -0
- pinecall/session/voice.py +118 -0
- pinecall/session/widget.py +201 -0
- pinecall/tenancy/__init__.py +0 -0
- pinecall/tenancy/admission.py +204 -0
- pinecall/tenancy/agents.py +164 -0
- pinecall/tenancy/canary.py +126 -0
- pinecall/tenancy/carrier_networks.py +156 -0
- pinecall/tenancy/carriers.py +265 -0
- pinecall/tenancy/codes.py +196 -0
- pinecall/tenancy/consents.py +167 -0
- pinecall/tenancy/dial_policy.py +405 -0
- pinecall/tenancy/disclosure.py +41 -0
- pinecall/tenancy/erasure.py +204 -0
- pinecall/tenancy/export.py +167 -0
- pinecall/tenancy/hosted_running.py +166 -0
- pinecall/tenancy/hosting.py +475 -0
- pinecall/tenancy/judges.py +126 -0
- pinecall/tenancy/keys.py +467 -0
- pinecall/tenancy/knocks.py +40 -0
- pinecall/tenancy/letters.py +378 -0
- pinecall/tenancy/mail.py +458 -0
- pinecall/tenancy/org_secrets.py +123 -0
- pinecall/tenancy/orgs.py +78 -0
- pinecall/tenancy/people.py +543 -0
- pinecall/tenancy/personas.py +145 -0
- pinecall/tenancy/policy.py +65 -0
- pinecall/tenancy/prompts.py +48 -0
- pinecall/tenancy/reads.py +73 -0
- pinecall/tenancy/recording_keys.py +41 -0
- pinecall/tenancy/remembered.py +148 -0
- pinecall/tenancy/retention.py +86 -0
- pinecall/tenancy/scopes.py +406 -0
- pinecall/tenancy/signin.py +622 -0
- pinecall/tenancy/spend.py +69 -0
- pinecall/tenancy/sso.py +431 -0
- pinecall/tenancy/sso_domains.py +132 -0
- pinecall/tenancy/throttle.py +88 -0
- pinecall/tenancy/tokens.py +400 -0
- pinecall/tenancy/traceback.py +70 -0
- pinecall/tenancy/usage.py +136 -0
- pinecall/tenancy/vault.py +204 -0
- pinecall/tenancy/words.py +144 -0
- pinecall/wire/__init__.py +0 -0
- pinecall/wire/commands.py +350 -0
- pinecall/wire/events.py +655 -0
- pinecall/wire/frames.py +65 -0
- pinecall/wire/metrics.py +287 -0
- pinecall/wire/parts.py +320 -0
- pinecall/wire/rest/__init__.py +0 -0
- pinecall/wire/rest/accounts.py +512 -0
- pinecall/wire/rest/agents.py +172 -0
- pinecall/wire/rest/calls.py +397 -0
- pinecall/wire/rest/evals.py +453 -0
- pinecall/wire/rest/fleet.py +69 -0
- pinecall/wire/rest/hosting.py +174 -0
- pinecall/wire/rest/numbers.py +280 -0
- pinecall/wire/rest/ops.py +342 -0
- pinecall/wire/rest/providers.py +83 -0
- pinecall/wire/rest/retrieval.py +301 -0
- pinecall/wire/rest/settings.py +184 -0
- pinecall/wire/rest/usage.py +245 -0
- pinecall/wire/room.py +61 -0
- pinecall/wire/scores.py +49 -0
- pinecall/wire/state.py +243 -0
- pinecall/worker/__init__.py +0 -0
- pinecall/worker/_job.py +655 -0
- pinecall/worker/_recorder.py +82 -0
- pinecall/worker/_traces.py +48 -0
- pinecall/worker/main.py +307 -0
- pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
- pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
- pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
- pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
- pinecall_runtime-0.1.6.dist-info/licenses/LICENSE +202 -0
pinecall/tenancy/sso.py
ADDED
|
@@ -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)
|