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/keys.py
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"""API keys: minted, revoked, found by fingerprint, and the world and scope a request acts in."""
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from psycopg import sql
|
|
9
|
+
from psycopg.rows import DictRow
|
|
10
|
+
|
|
11
|
+
from pinecall.domain.errors import DeclarationRefused, NotAllowed, NotFound, NotSignedIn
|
|
12
|
+
from pinecall.domain.names import ENVS, PRODUCTION, SANDBOX, Env, parse_env
|
|
13
|
+
from pinecall.domain.person import (
|
|
14
|
+
HOLDING,
|
|
15
|
+
KEY_SCOPES,
|
|
16
|
+
READS_ITS_OWN_CALL,
|
|
17
|
+
ROLE_SCOPES,
|
|
18
|
+
SERVER_SCOPES,
|
|
19
|
+
THE_FLEET,
|
|
20
|
+
THE_TEAM,
|
|
21
|
+
Key,
|
|
22
|
+
KeyScope,
|
|
23
|
+
Member,
|
|
24
|
+
Role,
|
|
25
|
+
RoomScope,
|
|
26
|
+
)
|
|
27
|
+
from pinecall.domain.scope import THE_ORGS_OWN, Scope
|
|
28
|
+
from pinecall.postgres.pool import Pool
|
|
29
|
+
from pinecall.tenancy.people import fingerprint, member_of
|
|
30
|
+
|
|
31
|
+
# The prefix says the world, so a key pasted into the wrong one is caught before it knocks. A
|
|
32
|
+
# person's one key is production's: it opens the sandbox too, and the request names the world.
|
|
33
|
+
PRODUCTION_PREFIX = "pc_live_"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
SANDBOX_PREFIX = "pc_test_"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
KEY_BYTES = 32
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
ID_PREFIX = "k_"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
ID_BYTES = 8
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
NOT_A_WORLD = "pinecall-env is sandbox or production, not {asked!r}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
ONE_WORLD = (
|
|
52
|
+
"this key is a {world} server's token, and this request is for {asked}: "
|
|
53
|
+
"a server's token opens the world it was made in"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
NO_PRODUCTION = "{name} has no production access: an admin gives it in Team"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
NOT_OPENED = "this key does not open {scope}: it opens {opens}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
EXPIRED = "this key expired at {at}: it opens nothing now, and a new one is made in the console"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
NOT_A_SERVERS = "a server's token opens some of {scopes}, and at least one; not {wanted}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
ALREADY_OVER = "a key's expiry is a moment to come, not {at}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
NOT_YOURS_TO_GRANT = (
|
|
73
|
+
"this key does not open everything {role} would: it opens {opens}, so it cannot grant {role}"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
NOT_THEIR_AGENT = (
|
|
78
|
+
"{name} works on {agents}, and agent {slug} is not one of them: an admin adds it in Team"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
NOT_YOURS_TO_SWITCH = "{name} has no production access, and cannot give it: an admin does"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
CANNOT_LOOK_THERE = "only a key that sees every corner opens a colleague's, and only in the sandbox"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
NOT_A_COLLEAGUE = "no active member of this org answers to that corner"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
NO_DISPATCH = "the fleet's key acts in the corner of the call it serves: name the call"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
NOT_THE_CALLS = "the call named is not in the scope the dispatch names: a worker acts in its call's"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
NOT_THIS_FLEET = (
|
|
98
|
+
"this is the {world} fleet's key, and the call is the {asked}'s: the unit holds the wrong key"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# A key that names a person opens nothing once the person is gone, disabled, or (on a visit to
|
|
103
|
+
# another org) no longer runs the box: the member row is read with the key, every request. Its
|
|
104
|
+
# use is written at most once a minute, so a request is not a write. An expired key is found, so
|
|
105
|
+
# the refusal can say it expired; it opens nothing and its use is not written.
|
|
106
|
+
VERIFY = sql.SQL("""
|
|
107
|
+
WITH found AS (
|
|
108
|
+
SELECT id, org, label, env, scopes, subject, name, expires_at, last_used_at,
|
|
109
|
+
expires_at IS NOT NULL AND expires_at <= now() AS expired
|
|
110
|
+
FROM api_keys
|
|
111
|
+
WHERE hash = %(hash)s AND revoked_at IS NULL
|
|
112
|
+
), touched AS (
|
|
113
|
+
UPDATE api_keys SET last_used_at = now() FROM found
|
|
114
|
+
WHERE api_keys.id = found.id AND NOT found.expired
|
|
115
|
+
AND (found.last_used_at IS NULL OR found.last_used_at < now() - interval '1 minute')
|
|
116
|
+
)
|
|
117
|
+
SELECT found.*, m.id AS member, {member}
|
|
118
|
+
FROM found LEFT JOIN members m ON m.id = found.subject
|
|
119
|
+
WHERE found.subject IS NULL
|
|
120
|
+
OR (m.status = 'active' AND (m.org = found.org OR m.operator))
|
|
121
|
+
""").format(
|
|
122
|
+
member=sql.SQL(", ").join(
|
|
123
|
+
sql.SQL("m.{} AS {}").format(sql.Identifier(column), sql.Identifier(f"m_{column}"))
|
|
124
|
+
for column in (
|
|
125
|
+
"org",
|
|
126
|
+
"email",
|
|
127
|
+
"name",
|
|
128
|
+
"role",
|
|
129
|
+
"agents",
|
|
130
|
+
"status",
|
|
131
|
+
"operator",
|
|
132
|
+
"production",
|
|
133
|
+
"verified_at",
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
ISSUE = """
|
|
140
|
+
INSERT INTO api_keys (id, hash, org, label, env, scopes, subject, name, created_by, expires_at,
|
|
141
|
+
parent)
|
|
142
|
+
VALUES (%(id)s, %(hash)s, %(org)s, %(label)s, %(env)s, %(scopes)s, %(subject)s, %(name)s,
|
|
143
|
+
%(created_by)s, %(expires_at)s, %(parent)s)
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
LISTED = """
|
|
148
|
+
SELECT id, hash, org, label, env, scopes, subject, name, created_by, created_at, last_used_at,
|
|
149
|
+
revoked_at, expires_at
|
|
150
|
+
FROM api_keys WHERE org = %(org)s ORDER BY created_at, id
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# A second revoke changes nothing and says so.
|
|
155
|
+
# The key and every copy made of it, however deep: each fingerprint stopped is answered, so every
|
|
156
|
+
# gateway forgets each one it remembers.
|
|
157
|
+
REVOKE = """
|
|
158
|
+
WITH RECURSIVE stopped AS (
|
|
159
|
+
SELECT id FROM api_keys WHERE hash = %(hash)s AND revoked_at IS NULL
|
|
160
|
+
UNION
|
|
161
|
+
SELECT copy.id FROM api_keys copy JOIN stopped ON copy.parent = stopped.id
|
|
162
|
+
WHERE copy.revoked_at IS NULL
|
|
163
|
+
)
|
|
164
|
+
UPDATE api_keys SET revoked_at = now() WHERE id IN (SELECT id FROM stopped) RETURNING hash
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# Room tokens. A room token is livekit's own JWT: the room is the call, our scope rides an
|
|
169
|
+
# attribute the worker reads without a lookup, and the same string joins the room and reads
|
|
170
|
+
# the log.
|
|
171
|
+
SUBJECT_ATTRIBUTE = "pinecall.subject"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
NAME_ATTRIBUTE = "pinecall.name"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
PROJECTION_ATTRIBUTE = "pinecall.projection"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
CODE_ATTRIBUTE = "pinecall.code"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
AGENT_ATTRIBUTE = "pinecall.agent"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
ENV_ATTRIBUTE = "pinecall.env"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
LONGEST_VISIT_TTL_S = 600
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
A_SEAT_LASTS_S = 15 * 60
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
A_VISITOR = "web_"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
A_SEAT = "sup_"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# The one source a visitor may publish; livekit refuses a source not listed.
|
|
202
|
+
THE_MICROPHONE = "microphone"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# An API key holds no dots, so its shape alone tells it from a token.
|
|
206
|
+
JWT_PARTS = 3
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
MINTED = """
|
|
210
|
+
INSERT INTO tokens (call, org, env, agent, scope, expires_at)
|
|
211
|
+
VALUES (%(call)s, %(org)s, %(env)s, %(agent)s, %(scope)s, to_timestamp(%(expires_at)s))
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# Valid for one call: the browser's scopes, and the one that sends verbs.
|
|
216
|
+
BOUND_TO_ONE_CALL: frozenset[RoomScope] = READS_ITS_OWN_CALL | frozenset[RoomScope]({"supervise"})
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@dataclass(frozen=True)
|
|
220
|
+
class Issued:
|
|
221
|
+
"""What a new key is made with."""
|
|
222
|
+
|
|
223
|
+
org: str
|
|
224
|
+
env: Env
|
|
225
|
+
scopes: frozenset[KeyScope] = KEY_SCOPES
|
|
226
|
+
label: str | None = None
|
|
227
|
+
subject: str | None = None
|
|
228
|
+
name: str | None = None
|
|
229
|
+
created_by: str | None = None
|
|
230
|
+
expires_at: datetime | None = None
|
|
231
|
+
# The key this one copies: revoked with it.
|
|
232
|
+
parent: str | None = None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@dataclass(frozen=True)
|
|
236
|
+
class Bearer:
|
|
237
|
+
"""A verified key, and the person it was minted for, read in the same query."""
|
|
238
|
+
|
|
239
|
+
key: Key
|
|
240
|
+
member: Member | None = None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@dataclass(frozen=True)
|
|
244
|
+
class ListedKey:
|
|
245
|
+
"""A key as the org's listing shows it: by fingerprint, never the key."""
|
|
246
|
+
|
|
247
|
+
fingerprint: str
|
|
248
|
+
key: Key
|
|
249
|
+
created_by: str | None
|
|
250
|
+
created_at: datetime
|
|
251
|
+
last_used_at: datetime | None
|
|
252
|
+
revoked_at: datetime | None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
async def issue(pool: Pool, issued: Issued) -> tuple[Key, str]:
|
|
256
|
+
"""Mint a key: the row keeps its fingerprint, and the key is handed back this once."""
|
|
257
|
+
prefix = PRODUCTION_PREFIX if issued.env == PRODUCTION else SANDBOX_PREFIX
|
|
258
|
+
secret = f"{prefix}{secrets.token_urlsafe(KEY_BYTES)}"
|
|
259
|
+
key = Key(
|
|
260
|
+
key_id=f"{ID_PREFIX}{secrets.token_hex(ID_BYTES)}",
|
|
261
|
+
org=issued.org,
|
|
262
|
+
label=issued.label,
|
|
263
|
+
env=issued.env,
|
|
264
|
+
scopes=issued.scopes,
|
|
265
|
+
subject=issued.subject,
|
|
266
|
+
name=issued.name,
|
|
267
|
+
expires_at=issued.expires_at,
|
|
268
|
+
)
|
|
269
|
+
values = {
|
|
270
|
+
"id": key.key_id,
|
|
271
|
+
"hash": fingerprint(secret),
|
|
272
|
+
"org": key.org,
|
|
273
|
+
"label": key.label,
|
|
274
|
+
"env": key.env,
|
|
275
|
+
"scopes": sorted(key.scopes),
|
|
276
|
+
"subject": key.subject,
|
|
277
|
+
"name": key.name,
|
|
278
|
+
"created_by": issued.created_by,
|
|
279
|
+
"expires_at": key.expires_at,
|
|
280
|
+
"parent": issued.parent,
|
|
281
|
+
}
|
|
282
|
+
async with pool.connection() as connection:
|
|
283
|
+
await connection.execute(ISSUE, values)
|
|
284
|
+
return key, secret
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
async def person_key(
|
|
288
|
+
pool: Pool, member: Member, *, parent: Key | None = None, label: str | None = None
|
|
289
|
+
) -> tuple[Key, str]:
|
|
290
|
+
"""A person's one key: the role's scopes, both worlds, never outliving the key it came from."""
|
|
291
|
+
issued = Issued(
|
|
292
|
+
org=member.org,
|
|
293
|
+
env=PRODUCTION,
|
|
294
|
+
scopes=member.scopes,
|
|
295
|
+
label=label,
|
|
296
|
+
subject=member.id,
|
|
297
|
+
name=member.name,
|
|
298
|
+
created_by=member.id,
|
|
299
|
+
expires_at=None if parent is None else parent.expires_at,
|
|
300
|
+
)
|
|
301
|
+
return await issue(pool, issued)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
async def verify(pool: Pool, bearer: str) -> Bearer | None:
|
|
305
|
+
"""The key and its person; None for a key unknown, revoked, or whose person is gone."""
|
|
306
|
+
async with pool.connection() as connection:
|
|
307
|
+
row = await (await connection.execute(VERIFY, {"hash": fingerprint(bearer)})).fetchone()
|
|
308
|
+
if row is None:
|
|
309
|
+
return None
|
|
310
|
+
if row["expired"]:
|
|
311
|
+
raise NotSignedIn(EXPIRED.format(at=row["expires_at"].isoformat(timespec="seconds")))
|
|
312
|
+
member = None if row["member"] is None else member_of(_member_columns(row))
|
|
313
|
+
return Bearer(key=_key(row), member=member)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
async def listed(pool: Pool, org: str) -> list[ListedKey]:
|
|
317
|
+
"""Every key of the org, oldest first, the revoked ones too."""
|
|
318
|
+
async with pool.connection() as connection:
|
|
319
|
+
rows = await (await connection.execute(LISTED, {"org": org})).fetchall()
|
|
320
|
+
return [
|
|
321
|
+
ListedKey(
|
|
322
|
+
fingerprint=row["hash"],
|
|
323
|
+
key=_key(row),
|
|
324
|
+
created_by=row["created_by"],
|
|
325
|
+
created_at=row["created_at"],
|
|
326
|
+
last_used_at=row["last_used_at"],
|
|
327
|
+
revoked_at=row["revoked_at"],
|
|
328
|
+
)
|
|
329
|
+
for row in rows
|
|
330
|
+
]
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
async def revoke(pool: Pool, key_fingerprint: str) -> list[str]:
|
|
334
|
+
"""Stop a key and every copy of it from the next request on; the fingerprints stopped."""
|
|
335
|
+
async with pool.connection() as connection:
|
|
336
|
+
revoked = await connection.execute(REVOKE, {"hash": key_fingerprint})
|
|
337
|
+
return [str(row["hash"]) for row in await revoked.fetchall()]
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def world_of(bearer: Bearer, params: str | None) -> Env:
|
|
341
|
+
"""The world a request acts in: a server key's own, else the one its header asks for."""
|
|
342
|
+
if params is not None and params not in ENVS:
|
|
343
|
+
raise NotAllowed(NOT_A_WORLD.format(asked=params))
|
|
344
|
+
wanted = None if params is None else parse_env(params)
|
|
345
|
+
if bearer.member is None:
|
|
346
|
+
if wanted is not None and wanted != bearer.key.env:
|
|
347
|
+
raise NotAllowed(ONE_WORLD.format(world=bearer.key.env, asked=wanted))
|
|
348
|
+
return bearer.key.env
|
|
349
|
+
world = SANDBOX if wanted is None else wanted
|
|
350
|
+
if world == PRODUCTION and not bearer.member.opens_production:
|
|
351
|
+
raise NotAllowed(NO_PRODUCTION.format(name=bearer.member.name))
|
|
352
|
+
return world
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def scope_of(
|
|
356
|
+
bearer: Bearer,
|
|
357
|
+
world: Env,
|
|
358
|
+
*,
|
|
359
|
+
looking_at: Member | None = None,
|
|
360
|
+
dispatched: Scope | None = None,
|
|
361
|
+
called: Scope | None = None,
|
|
362
|
+
) -> Scope:
|
|
363
|
+
"""The scope a request acts in: the org's in production, the person's own in the sandbox."""
|
|
364
|
+
if THE_FLEET in bearer.key.scopes:
|
|
365
|
+
return _fleets_scope(bearer, dispatched, called)
|
|
366
|
+
if world == PRODUCTION or bearer.member is None:
|
|
367
|
+
return Scope(bearer.key.org, world, THE_ORGS_OWN)
|
|
368
|
+
if looking_at is None or looking_at.id == bearer.member.id:
|
|
369
|
+
return Scope(bearer.key.org, world, bearer.member.id)
|
|
370
|
+
# Both team and app: a manager holds team alone and does not open developers' sandboxes.
|
|
371
|
+
if not {THE_TEAM, HOLDING} <= bearer.key.scopes:
|
|
372
|
+
raise NotAllowed(CANNOT_LOOK_THERE)
|
|
373
|
+
if looking_at.org != bearer.key.org or looking_at.status != "active":
|
|
374
|
+
raise NotAllowed(NOT_A_COLLEAGUE)
|
|
375
|
+
return Scope(bearer.key.org, world, looking_at.id)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# Fewer scopes than a server's, never others: a token for pushing knowledge alone opens that.
|
|
379
|
+
def server_scopes(wanted: Sequence[str] | None) -> frozenset[KeyScope]:
|
|
380
|
+
"""The scopes a server's token is made with: all of a server's, or those of them asked for."""
|
|
381
|
+
if wanted is None:
|
|
382
|
+
return SERVER_SCOPES
|
|
383
|
+
scopes = frozenset[KeyScope](scope for scope in SERVER_SCOPES if scope in wanted)
|
|
384
|
+
if not scopes or len(scopes) != len(set(wanted)):
|
|
385
|
+
wanted = ", ".join(sorted(set(wanted))) or "none"
|
|
386
|
+
raise DeclarationRefused(
|
|
387
|
+
NOT_A_SERVERS.format(scopes=", ".join(sorted(SERVER_SCOPES)), wanted=wanted)
|
|
388
|
+
)
|
|
389
|
+
return scopes
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def check_expiry(expires_at: datetime | None, now: datetime) -> None:
|
|
393
|
+
"""Refuse an expiry that is not a moment to come; None is a key that never expires."""
|
|
394
|
+
if expires_at is not None and expires_at <= now:
|
|
395
|
+
raise DeclarationRefused(ALREADY_OVER.format(at=expires_at.isoformat(timespec="seconds")))
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def check_opens(bearer: Bearer, *scopes: KeyScope) -> None:
|
|
399
|
+
"""Refuse, naming what the key does open, unless it holds one of the scopes."""
|
|
400
|
+
if any(scope in bearer.key.scopes for scope in scopes):
|
|
401
|
+
return
|
|
402
|
+
opens = " · ".join(sorted(bearer.key.scopes)) or "nothing"
|
|
403
|
+
raise NotAllowed(NOT_OPENED.format(scope=" or ".join(sorted(scopes)), opens=opens))
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# A member's agents bind their own org alone: on a visit the member row is the visitor's own
|
|
407
|
+
# org's, and names none of the visited org's agents.
|
|
408
|
+
def check_agent(bearer: Bearer, slug: str) -> None:
|
|
409
|
+
"""Refuse a person whose list of agents leaves this one out; an empty list is every agent."""
|
|
410
|
+
member = bearer.member
|
|
411
|
+
if member is None or not member.agents or member.org != bearer.key.org:
|
|
412
|
+
return
|
|
413
|
+
if slug in member.agents:
|
|
414
|
+
return
|
|
415
|
+
agents = ", ".join(sorted(member.agents))
|
|
416
|
+
raise NotAllowed(NOT_THEIR_AGENT.format(name=member.name, agents=agents, slug=slug))
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
# A key grants only a role whose scopes it holds, or a manager would make an admin.
|
|
420
|
+
def check_may_grant(bearer: Bearer, role: Role | None, *, production: bool) -> None:
|
|
421
|
+
"""Refuse a role or production access the key's holder does not have to give."""
|
|
422
|
+
if (
|
|
423
|
+
role is not None
|
|
424
|
+
and bearer.member is not None
|
|
425
|
+
and not ROLE_SCOPES[role] <= bearer.key.scopes
|
|
426
|
+
):
|
|
427
|
+
opens = " · ".join(sorted(bearer.key.scopes)) or "nothing"
|
|
428
|
+
raise NotAllowed(NOT_YOURS_TO_GRANT.format(role=role, opens=opens))
|
|
429
|
+
acts_there = (
|
|
430
|
+
bearer.key.env == PRODUCTION if bearer.member is None else bearer.member.opens_production
|
|
431
|
+
)
|
|
432
|
+
if production and not acts_there:
|
|
433
|
+
name = bearer.key.name or "this key"
|
|
434
|
+
raise NotAllowed(NOT_YOURS_TO_SWITCH.format(name=name))
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# The call's head row is the proof; the dispatch's word stands only where no call exists yet.
|
|
438
|
+
def _fleets_scope(bearer: Bearer, dispatched: Scope | None, called: Scope | None) -> Scope:
|
|
439
|
+
acting = called if called is not None else dispatched
|
|
440
|
+
if acting is None:
|
|
441
|
+
raise DeclarationRefused(NO_DISPATCH)
|
|
442
|
+
if dispatched is not None and dispatched != acting:
|
|
443
|
+
raise NotFound(NOT_THE_CALLS)
|
|
444
|
+
# A fleet serves one world: a unit holding the other world's key is refused here too.
|
|
445
|
+
if acting.env != bearer.key.env:
|
|
446
|
+
raise NotAllowed(NOT_THIS_FLEET.format(world=bearer.key.env, asked=acting.env))
|
|
447
|
+
return acting
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _key(row: DictRow) -> Key:
|
|
451
|
+
return Key(
|
|
452
|
+
key_id=row["id"],
|
|
453
|
+
org=row["org"],
|
|
454
|
+
label=row["label"],
|
|
455
|
+
env=row["env"],
|
|
456
|
+
scopes=frozenset(row["scopes"]),
|
|
457
|
+
subject=row["subject"],
|
|
458
|
+
name=row["name"],
|
|
459
|
+
expires_at=row["expires_at"],
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _member_columns(row: DictRow) -> DictRow:
|
|
464
|
+
columns = {
|
|
465
|
+
name.removeprefix("m_"): value for name, value in row.items() if name.startswith("m_")
|
|
466
|
+
}
|
|
467
|
+
return {**columns, "id": row["member"]}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""How many times a name knocked in the last minute, counted in Postgres for every gateway."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
|
|
6
|
+
from pinecall.postgres.pool import Pool
|
|
7
|
+
|
|
8
|
+
WINDOW_S = 60.0
|
|
9
|
+
|
|
10
|
+
# One name's knocks are counted and taken one at a time, whichever gateway they land on.
|
|
11
|
+
ONE_AT_A_TIME = "select pg_advisory_xact_lock(hashtext('knock:' || %(name)s))"
|
|
12
|
+
|
|
13
|
+
# A refused knock is not counted: the name waits for its oldest knock to leave the window.
|
|
14
|
+
KNOCKED = """
|
|
15
|
+
insert into knocks (name, at)
|
|
16
|
+
select %(name)s, %(now)s
|
|
17
|
+
where (select count(*) from knocks where name = %(name)s and at > %(since)s) < %(tries)s
|
|
18
|
+
returning at
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
GONE = "delete from knocks where at <= %(since)s"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Throttle:
|
|
25
|
+
"""So many knocks a minute per name, and the next one waits: across every gateway."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, pool: Pool, tries: int, clock: Callable[[], float] = time.time) -> None:
|
|
28
|
+
"""A throttle of so many tries a minute."""
|
|
29
|
+
self.pool = pool
|
|
30
|
+
self.tries = tries
|
|
31
|
+
self.clock = clock
|
|
32
|
+
|
|
33
|
+
async def allowed(self, name: str) -> bool:
|
|
34
|
+
"""Count a knock, and say whether it is within the window's tries."""
|
|
35
|
+
now = self.clock()
|
|
36
|
+
wanted = {"name": name, "now": now, "since": now - WINDOW_S, "tries": self.tries}
|
|
37
|
+
async with self.pool.connection() as connection, connection.transaction():
|
|
38
|
+
await connection.execute(ONE_AT_A_TIME, wanted)
|
|
39
|
+
await connection.execute(GONE, wanted)
|
|
40
|
+
return await (await connection.execute(KNOCKED, wanted)).fetchone() is not None
|